diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3452f38ec2..49accfc403 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,7 +178,7 @@ jobs: 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 @@ -201,12 +201,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 @@ -226,23 +249,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 @@ -296,7 +302,7 @@ 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 @@ -304,9 +310,6 @@ jobs: - name: Run plugin SDK tests run: npm run test --workspace=@getpaseo/plugin - - name: Typecheck plugin examples - run: npm run typecheck --workspace=@getpaseo/plugin - - name: Run protocol tests run: npm run test --workspace=@getpaseo/protocol @@ -355,6 +358,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 @@ -440,14 +446,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) diff --git a/.github/workflows/desktop-packages.yml b/.github/workflows/desktop-packages.yml new file mode 100644 index 0000000000..e2f1fafb3c --- /dev/null +++ b/.github/workflows/desktop-packages.yml @@ -0,0 +1,90 @@ +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/**" + - "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 5c9bc567ba..c21de2ab4e 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -261,7 +261,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 +278,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 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..a4a248e1cf 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: @@ -110,8 +89,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/CHANGELOG.md b/CHANGELOG.md index 3dd69ac7ba..449ef34cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,91 +1,98 @@ # Changelog -## 0.8.0-beta.1 - 2026-09-08 +## 0.8.0 - 2026-09-10 -This release considerably extends what plugins can do: complete providers, custom components in the chat, slash commands, settings screens, and agent lifecycle hooks. +Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. -The plugin API has a breaking change. Plugins now use separate client and server entries and runtime-owned SDK entry points, so plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) before they load on 0.8. - -It also fixes regressions across desktop updates, mobile, Import session, and agent lifecycle. - -### Plugins - -- Added provider contributions: a plugin registers a complete provider, directly or through the bundled ACP shim, with its own icon, session settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314)) -- Added live timeline items: a plugin renders custom components in the chat by transforming timeline items at render time ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) -- Added durable timeline rows: a daemon handler appends its own persisted rows to an agent's timeline ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) -- Added slash commands: a plugin registers commands in the composer that run on the client without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) -- Added settings screens: a plugin contributes screens under Settings → Plugins from shared settings components, with host-scoped persistence, validation, and Command Center navigation ([#4357](https://github.com/getpaseo/paseo/pull/4357)) -- Added lifecycle hooks: server callbacks observe agent creation, turns, permissions, and archive events, and can answer permissions or send follow-ups ([#4435](https://github.com/getpaseo/paseo/pull/4435)) -- Added agent creation transforms: before hooks rewrite a new agent's provider, model, launch environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435)) -- Added modal bodies: a plugin owns the whole sheet body with custom padding, ScrollView or FlatList scrolling, horizontal tabs, keyboard-aware inputs, and a clipboard copy action ([#4392](https://github.com/getpaseo/paseo/pull/4392), [#4431](https://github.com/getpaseo/paseo/pull/4431)) -- Added terminals to the SDK: create, list, write, send keys, capture, and kill terminals by workspace ID ([#4358](https://github.com/getpaseo/paseo/pull/4358)) -- Added `paseo.projects.subscribe()` for live project updates ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet)) -- Added `agent.respondToPermission()` to answer pending permissions from a plugin ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet)) -- Added version requirements: `requirements.paseo` in `paseo-plugin.json` declares the supported Paseo versions, incompatible installs, updates, and reloads are rejected before any code runs, and prerelease Paseo versions satisfy any range their stable core satisfies ([#4430](https://github.com/getpaseo/paseo/pull/4430), [#4452](https://github.com/getpaseo/paseo/pull/4452)) -- Changed plugins to separate `index.client.ts` and `index.server.ts` entries with `client/`, `server/`, and `shared/` directories; plugins written for 0.7 must follow the [migration guide](https://paseo.sh/docs/plugins/v0.8/migration) ([#4206](https://github.com/getpaseo/paseo/pull/4206)) -- Changed the plugin SDK into runtime-owned entry points: `@getpaseo/plugin` for shared code, `@getpaseo/plugin/client` for the app, and `@getpaseo/plugin/server` for the daemon, with crossed imports rejected at compile time ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal)) -- Changed legacy plugin load failures to say the plugin was built for an older Paseo and link to the migration guide ([#4265](https://github.com/getpaseo/paseo/pull/4265)) -- Changed `paseo plugin ls` to report runtime state, source, 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 tab-rail menu ([#4446](https://github.com/getpaseo/paseo/pull/4446)) -- Added versioned plugin documentation for v0.7 and v0.8 with a migration guide ([#4245](https://github.com/getpaseo/paseo/pull/4245), [#4452](https://github.com/getpaseo/paseo/pull/4452)) +**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 Import session to the sidebar footer, the History and sidebar empty states, and the Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216)) -- Added search, Load more, and a This workspace / Show all scope to the Import session sheet ([#4216](https://github.com/getpaseo/paseo/pull/4216)) -- Added customizable top-level sidebar items: reorder and hide New workspace, History, Search, Schedules, and plugin items from Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203)) -- Added provider notifications from Pi extensions and OpenCode as timeline items with info, warning, and error levels ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21)) -- Added a fullscreen viewer for Mermaid diagrams 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, with Cmd/Ctrl+A and Escape ([#4229](https://github.com/getpaseo/paseo/pull/4229)) +- 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 Paseo tool policy in daemon config to disable all or selected Paseo tools for a provider ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger)) -- Added explicit setup approval for fork pull request workspaces: setup, automatic terminals, and scripts wait for Run setup or `paseo workspace setup ` ([#4215](https://github.com/getpaseo/paseo/pull/4215)) -- Added Hub follow-ups to existing agents, archived workspace recovery, and provider catalog discovery through the daemon API ([#4354](https://github.com/getpaseo/paseo/pull/4354), [#4242](https://github.com/getpaseo/paseo/pull/4242)) +- 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; the Home route and tile remain ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- 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 on app foreground instead of waiting for reconnect backoff ([#4160](https://github.com/getpaseo/paseo/pull/4160)) -- Smoothed mobile keyboard transitions by moving the timeline and composer together instead of relaying out every frame ([#4275](https://github.com/getpaseo/paseo/pull/4275)) -- Reloaded only the providers whose configuration changed instead of restarting every provider and resetting every catalog ([#4332](https://github.com/getpaseo/paseo/pull/4332)) -- Shortened agent tab tooltips to one title line with the agent ID and last activity ([#4214](https://github.com/getpaseo/paseo/pull/4214)) -- Added macOS updater and ShipIt state to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322)) +- 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 the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469)) - 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 while a new release had no updater manifests yet ([#4201](https://github.com/getpaseo/paseo/pull/4201)) +- 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 cached conversations and the workspace list not painting when live updates arrived during cache reads ([#4436](https://github.com/getpaseo/paseo/pull/4436)) +- 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 the New workspace form losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401)) -- Fixed a crash with `Select a model` when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332)) -- Fixed Codex model rows staying on Loading forever in the New workspace model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8)) -- Fixed reloading a Codex agent failing with `already has an active writer` ([#4353](https://github.com/getpaseo/paseo/pull/4353)) +- 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 nested provider subagents shown as direct children of the root agent and their notifications appearing in the root timeline ([#4321](https://github.com/getpaseo/paseo/pull/4321)) -- Fixed repeated provider tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413)) -- Fixed the Import session spinner never stopping when one provider hung; the provider now shows an inline error with Retry ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- 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 into a directory with an active workspace creating a duplicate workspace ([#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 diff file headers lagging behind the document while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240)) -- Fixed the Changes tree and its working diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199)) -- Fixed Mermaid diagrams shrinking on every streaming re-render and the inline diagram box collapsing ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen)) -- Fixed Mermaid source corrupted on reload when cached history overlapped the live message ([#4210](https://github.com/getpaseo/paseo/pull/4210)) +- 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), [#4358](https://github.com/getpaseo/paseo/pull/4358)) -- Fixed daemon Git commands honoring a repository's `core.fsmonitor`, which let a checkout run its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208)) -- Fixed terminal activity reporting for OpenCode 2 sessions started from a terminal ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan)) -- Changed the plugin SDK's React peer range to `~19.1.0` so CLI installs resolve a patched 19.1.x instead of the version dependency scanners flag ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506)) -- Fixed the paseo.sh homepage offering the Mac download to every visitor before hydration and desktop builds to phones ([#4222](https://github.com/getpaseo/paseo/pull/4222)) +- 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 diff --git a/CLAUDE.md b/CLAUDE.md index 9ed4aba4d0..b1f8a294ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,9 +106,14 @@ 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. -## Critical rules +## Release branches -- **Before changing the plugin SDK, compiler, host module maps, scaffold, or examples, read [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).** Classify the export by runtime first and preserve the enforced boundaries. +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. - **NEVER assume a timeout means the service needs restarting** — timeouts can be transient. @@ -120,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/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 ddf69fd7c5..57bd7889cb 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -109,16 +109,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, diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index 0d2fd30fa6..10eb569e4f 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`. @@ -21,15 +22,18 @@ Every provider delivers incremental text, so there is no provider that needs spe Arrival is lumpy and there is no fixing that at the source. A 60ms coalescing window carries however many characters the model produced in those 60ms, which swings by an order of magnitude within a single turn. Painting each delta as it lands makes the size of those lumps visible, and that is what reads as jagged. -So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst makes the text catch up faster; it does not make the text jump. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption. +So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst shortens the interval within the word cadence. A late frame releases one word, without spending missed frames as credit to show a group at once. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption. ## Invariants - **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.** `agent-stream/presentation.ts` retains completed native + blocks and parses only the growing last block during append. Splitting in the reducer discards + paragraph separators and exposes fragments to plugin callbacks, which need the whole source text. - **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. +- **Completion releases an unfinished word.** Every platform drains queued words through the same pacing when the provider finishes; the last word also finishes fading. - **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. - **A history row re-renders only when its item or layout item identity changes.** The inverted FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a @@ -43,11 +47,40 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste ## Measuring +Word reveal lives in `packages/app/src/word-stream`. Native attribution and pinned +upstream revisions are in `packages/app/modules/paseo-word-stream/UPSTREAM.md`. + +Judge the visible front across word boundaries at the recording's native frame +rate. No character should become more opaque than text before it in reading +order, and the front should not visibly change speed at word boundaries. +Sampling at 100 ms hid the previous defect: later letters appeared before earlier +letters, leaving holes that read as flashing words during playback. + +Exercise provisional Markdown and recycled views when checking fade-once behavior. +A table forming or inline formatting closing can change the rendered structure +without making previously visible text new again. + +On native, text and its fade metadata must become visible together. An earlier +implementation sent metadata asynchronously and flashed new text fully opaque +for one frame before dimming it. Preserve native layout, selection, and inline +styles, and keep animation ticks off the JS thread. Do not remove the animation +host's layout box with `display: contents`: RN forcibly flattens it and removes +the native animation owner. iOS table cells receive pacing without opacity +animation; include that limit in platform QA reports. + +Compare release APKs on the same Android device with the mock `bursty-stream` +provider, identical prompts, fresh conversations, and equal sampling windows. +Collect `agent-device perf frames` and `perf memory sample`; keep screen recording +out of the timed runs. Software-rendered emulator results are comparative +evidence, not physical-device frame-rate guarantees. Native instrumentation +tests in the module check pixels, append/replacement behavior, and cleanup. + - **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth. - **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly. -- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer. +- **Rate policy in isolation:** `packages/app/src/word-stream/internal/model.test.ts` checks the shared word scheduler without a renderer. +- **Fade behavior:** `word-stream-fade.spec.ts` checks web direction, layout stability, selection, and tail cleanup. Native tests in `modules/paseo-word-stream` drive the host view React mounts with the same range props the bridge sends, advance frame time through the host's injected frame clock, and check rendered pixels and animation completion on Android and iOS. -Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken: +Historical character-reveal baseline (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). The paint-on-arrival column disabled the former reveal horizon: | | paint on arrival | paced | | -------------------------------- | ---------------- | ----- | diff --git a/docs/architecture.md b/docs/architecture.md index 32fef93532..6d21103cb6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,7 +145,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` @@ -239,11 +239,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. @@ -297,7 +308,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 @@ -308,6 +319,36 @@ 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. + +`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`: @@ -418,12 +459,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..594aacf5f6 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 @@ -73,11 +72,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 +101,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/design.md b/docs/design.md index 0b54be46a3..541fe9c6f5 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/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 23e4292aeb..4275099cfd 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -645,7 +645,7 @@ function WorkspaceRowRightGroup({ const trailing = useSidebarWorkspaceTrailing(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -667,7 +667,7 @@ function WorkspaceRowRightGroup({ ) : null} {renderSlot ? ( - + { const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -907,7 +908,7 @@ function StatusWorkspaceRowInnerContent({ workspace={workspace} backdrop={backdrop} trailing={trailing} - showBase={showTrailing} + trailingPresentation={trailingPresentation} showKebab={showKebabInSlot} showScrim={showScrim} reserveSlotWidth={reserveSlotWidth} @@ -938,7 +939,7 @@ function StatusWorkspaceActionSlot({ workspace, backdrop, trailing, - showBase, + trailingPresentation, showKebab, showScrim, reserveSlotWidth, @@ -958,7 +959,7 @@ function StatusWorkspaceActionSlot({ workspace: SidebarWorkspaceEntry; backdrop: SidebarSurfaceBackdrop; trailing: SidebarWorkspaceTrailing; - showBase: boolean; + trailingPresentation: SidebarWorkspaceTrailingPresentation; showKebab: boolean; showScrim: boolean; reserveSlotWidth: boolean; @@ -978,7 +979,7 @@ function StatusWorkspaceActionSlot({ const kebab = useOpenKebabMenuVisibility(showKebab); return ( - + {children}; + if (presentation === "absent") return null; + return ( + + {children} + + ); } export function SidebarWorkspaceTrailingActionOverlay({ diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index f019158e97..f8dcdebaf5 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -403,7 +403,7 @@ function WorkspaceRowTrailingActions({ const { t } = useTranslation(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -425,7 +425,7 @@ function WorkspaceRowTrailingActions({ ) : null} {renderSlot ? ( - + { - 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 +541,7 @@ export function TerminalPane({ onOutput: handleStreamOutput, onRestore: handleStreamRestore, onSnapshot: handleStreamSnapshot, + onExit: handleStreamExit, getRestoreOptions: getStreamRestoreOptions, onStatusChange: handleStreamControllerStatus, }); @@ -581,6 +562,7 @@ export function TerminalPane({ handleStreamOutput, handleStreamRestore, handleStreamSnapshot, + handleStreamExit, isConnected, ]); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 7ee9407dca..74748e423a 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -34,6 +34,7 @@ import { 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, @@ -1266,7 +1267,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/floating-action-button.tsx b/packages/app/src/components/ui/floating-action-button.tsx new file mode 100644 index 0000000000..4b5d4df39e --- /dev/null +++ b/packages/app/src/components/ui/floating-action-button.tsx @@ -0,0 +1,65 @@ +import type { LucideIcon } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { ICON_SIZE, SPACING } from "@/styles/theme"; + +const FLOATING_ACTION_BUTTON_SIZE = 56; +const FLOATING_ACTION_BUTTON_INSET = SPACING[4]; + +/** Space a scrolling surface keeps below its content so the button traps nothing. */ +export const FLOATING_ACTION_BUTTON_CLEARANCE = + FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET; + +export interface FloatingActionButtonProps { + icon: LucideIcon; + /** Names the action; the button carries no label. */ + accessibilityLabel: string; + onPress: () => void; + testID?: string; +} + +/** + * The one floating action a scrolling surface can offer. It pins itself to the + * bottom-right of its nearest positioned ancestor, so the ancestor decides what + * the button may cover. + */ +export function FloatingActionButton({ + icon: Icon, + accessibilityLabel, + onPress, + testID, +}: FloatingActionButtonProps) { + return ( + + + + ); +} + +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.accent, + ...theme.shadow.md, + }, + pressed: { + backgroundColor: theme.colors.interactionHighlight, + }, + glyph: { + color: theme.colors.accentForeground, + }, +})); diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 4f5bb22de1..18cdb25dea 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -21,6 +21,7 @@ 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"; @@ -125,6 +126,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 ; } 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..b2b630f939 100644 --- a/packages/app/src/components/ui/pane-content-toolbar.tsx +++ b/packages/app/src/components/ui/pane-content-toolbar.tsx @@ -155,8 +155,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/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 968b721eb2..4b2da86ea5 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,9 +1,10 @@ +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"; @@ -32,7 +33,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"; @@ -85,20 +85,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 }, }); } @@ -208,7 +211,7 @@ export function WorkspaceSetupDialog() { setErrorMessage(null); setCreatedWorkspace(null); setPendingAction(null); - }, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]); + }, [pendingWorkspaceSetup?.creationId]); const handleClose = useCallback(() => { clearWorkspaceSetup(); @@ -249,18 +252,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, }); @@ -271,6 +281,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") { @@ -307,8 +318,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")); } @@ -322,21 +332,30 @@ 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; @@ -362,13 +381,13 @@ export function WorkspaceSetupDialog() { }, [ composerState, + pendingWorkspaceSetup, getIsStillActive, navigateAfterCreation, serverId, ensureWorkspace, t, toast, - withConnectedClient, supportsForgeSearch, ], ); 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.ts b/packages/app/src/composer/draft/create-flow.ts index d1cfce1161..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"; @@ -123,7 +122,7 @@ export function useDraftAgentCreateFlow({ onCreateError, }: UseDraftAgentCreateFlowOptions) { const { t } = useTranslation(); - const [machine, dispatch] = useReducer( + const [localMachine, dispatch] = useReducer( reducer, initialAttempt, (attempt): DraftAgentMachineState => @@ -135,10 +134,22 @@ export function useDraftAgentCreateFlow({ }, ); + 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"; @@ -233,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, @@ -255,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")); } @@ -296,10 +313,10 @@ 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 } : {}), }; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 93c800cbf9..0457dfb36f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; +import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; @@ -136,6 +137,7 @@ function resolveDraftModeId(input: { } async function submitDraftCreateRequest(input: { + draftId: string; attempt: { clientMessageId: string }; text: string; images?: UserMessageImageAttachment[]; @@ -194,16 +196,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, @@ -390,10 +395,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 { @@ -407,7 +409,7 @@ export function WorkspaceDraftAgentTab({ ? { attachments: pendingCreateAttempt.attachments } : {}), }; - }, [pendingAutoSubmit, pendingCreateAttempt]); + }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( @@ -487,8 +489,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, @@ -501,7 +508,8 @@ export function WorkspaceDraftAgentTab({ composerState, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), selectModelMessage: t("workspaceSetup.errors.selectModel"), - }), + }); + }, onCreateSuccess: ({ result }) => { clearDraftInput("sent"); clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); @@ -622,75 +630,83 @@ export function WorkspaceDraftAgentTab({ [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); return ( - - - {isSubmitting && draftAgent ? ( - - + + + {isSubmitting && draftAgent ? ( + + + + ) : ( + + + {formErrorMessage ? ( + + {formErrorMessage} + + ) : null} + + + )} + + + + + {importPillPress ? ( + + + + + + ) : null} + - - ) : ( - - - {formErrorMessage ? ( - - {formErrorMessage} - - ) : null} - - - )} - - - - {importPillPress ? ( - - - - - - ) : null} - - - + + + + ); } const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", + flexShrink: 1, }, }); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 84de49a3b9..fa2f994fc9 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -2407,8 +2407,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", }, diff --git a/packages/app/src/composer/viewport/capacity.test.ts b/packages/app/src/composer/viewport/capacity.test.ts new file mode 100644 index 0000000000..04b82e04ea --- /dev/null +++ b/packages/app/src/composer/viewport/capacity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { resolveComposerCapacity } from "./capacity"; + +describe("composer viewport", () => { + it("leaves five points below the header for a bottom-anchored composer", () => { + const height = resolveComposerCapacity({ + height: 582, + bottomInset: 24, + keyboardShift: 308, + centered: false, + }); + expect(height).toBe(245); + expect(582 - 24 - 308 - height).toBe(5); + }); + + it("keeps a centered tablet form below the header after translation", () => { + const height = resolveComposerCapacity({ + height: 1000, + bottomInset: 80, + keyboardShift: 300, + centered: true, + }); + expect(height).toBe(310); + expect((1000 - 80 - height) / 2 - 300).toBe(5); + }); + + it("releases space after closing and responds to rotation instead of using window height", () => { + expect( + resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }), + ).toBe(553); + expect( + resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }), + ).toBe(95); + }); +}); diff --git a/packages/app/src/composer/viewport/capacity.ts b/packages/app/src/composer/viewport/capacity.ts new file mode 100644 index 0000000000..7e80b22964 --- /dev/null +++ b/packages/app/src/composer/viewport/capacity.ts @@ -0,0 +1,13 @@ +export function resolveComposerCapacity(input: { + height: number; + bottomInset: number; + keyboardShift: number; + centered: boolean; +}): number { + "worklet"; + // A centered form grows upward by half its height. Reserve both halves so + // translating it still leaves five layout points below the header. + const clearance = input.keyboardShift + 5; + const reservedSpace = input.centered ? clearance * 2 : clearance; + return Math.max(0, input.height - input.bottomInset - reservedSpace); +} diff --git a/packages/app/src/composer/viewport/index.native.tsx b/packages/app/src/composer/viewport/index.native.tsx new file mode 100644 index 0000000000..0ee128a0f8 --- /dev/null +++ b/packages/app/src/composer/viewport/index.native.tsx @@ -0,0 +1,66 @@ +import { createContext, useCallback, useContext } from "react"; +import { View, type LayoutChangeEvent, type ViewProps } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + useDerivedValue, + type SharedValue, +} from "react-native-reanimated"; +import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { resolveComposerCapacity } from "./capacity"; + +const ViewportCapacity = createContext | null>(null); + +interface ComposerViewportProps extends ViewProps { + bottomInset?: number; + centered?: boolean; +} + +/** Measure the stationary space below the header, outside keyboard translation. */ +export function ComposerViewport({ + children, + onLayout, + bottomInset = 0, + centered = false, + ...props +}: ComposerViewportProps) { + const measuredHeight = useSharedValue(0); + const { layoutShift } = useKeyboardShift(); + const capacity = useDerivedValue(() => { + if (measuredHeight.value === 0) return undefined; + return resolveComposerCapacity({ + height: measuredHeight.value, + bottomInset, + keyboardShift: layoutShift.value, + centered, + }); + }); + 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. */ +export function ComposerViewportContent({ style, ...props }: ViewProps) { + 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: capacity.value, + })); + + return ( + + ); +} diff --git a/packages/app/src/composer/viewport/index.tsx b/packages/app/src/composer/viewport/index.tsx new file mode 100644 index 0000000000..bc2846a8bd --- /dev/null +++ b/packages/app/src/composer/viewport/index.tsx @@ -0,0 +1,18 @@ +import { View, type ViewProps } from "react-native"; + +interface ComposerViewportProps extends ViewProps { + bottomInset?: number; + centered?: boolean; +} + +export function ComposerViewport({ + bottomInset: _bottomInset, + centered: _centered, + ...props +}: ComposerViewportProps) { + return ; +} + +export function ComposerViewportContent(props: ViewProps) { + return ; +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index fff3a0f496..ca6138995a 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,8 +26,6 @@ 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"; @@ -50,15 +44,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"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -81,11 +72,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"); } @@ -233,7 +219,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); 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); @@ -251,7 +236,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()); @@ -271,23 +255,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]); @@ -417,24 +389,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], @@ -474,18 +484,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 }); }, @@ -497,15 +497,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider viewedTimelineSyncRef.current = sync; setViewedTimelineSync(serverId, sync); sync.setActive(getIsAppVisible(appStateRef.current)); + const stopObservingOpenChats = observeOpenWorkspaceAgentIds(serverId, (agentIds) => + sync.replaceOpenAgentIds(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); @@ -513,98 +517,57 @@ 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) { - getHostRuntimeStore().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 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) => { + const unsubProviderSubagentUpdate = onFeed("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, @@ -619,7 +582,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }); - 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; @@ -631,7 +594,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; @@ -740,7 +703,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; } @@ -762,14 +725,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(); @@ -792,7 +754,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setPendingPermissions, notifyAgentAttention, applyWorkspaceSetupProgress, - applyTimelineResponse, updateSessionServerInfo, toast, voiceRuntime, @@ -851,48 +812,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/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index b69b0f5d52..424657e3fa 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -130,6 +130,43 @@ 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); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index 04a10303e3..3871cbb1e0 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -1,6 +1,9 @@ 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, @@ -77,6 +80,17 @@ 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", cwd])}`; } @@ -303,7 +317,7 @@ export function createProviderSnapshotCache( const body = { ...stored, compactSnapshot: stored.compactSnapshot, - entries: expandProviderSnapshot(stored.compactSnapshot), + entries: normalizeCatalogs(expandProviderSnapshot(stored.compactSnapshot)), }; bodies.set(key, body); return body; @@ -328,7 +342,7 @@ export function createProviderSnapshotCache( return { async materialize(serverId, snapshot) { const { snapshotHash: hash, compactSnapshot } = snapshot; - if (!hash) return snapshot; + if (!hash) return { ...snapshot, entries: normalizeCatalogs(snapshot.entries) }; let body = await safely(async () => { const cached = await readBody(serverId, hash); if (cached) return cached; @@ -348,7 +362,7 @@ export function createProviderSnapshotCache( hash, generatedAt: snapshot.generatedAt, compactSnapshot, - entries: expandProviderSnapshot(compactSnapshot), + entries: normalizeCatalogs(expandProviderSnapshot(compactSnapshot)), }; } return { diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index ae99c5dfbc..01fba418a9 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,37 +95,89 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} } } + 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: snapshot.subscriptionId, + ready: Promise.resolve(snapshot), + subscribe: (observer) => { + observers.add(observer); + observer.snapshot(snapshot); + return () => { + observers.delete(observer); + }; + }, + release: async () => { + if (!active) return; + active = false; + cleanups.forEach((cleanup) => cleanup()); + observers.clear(); + released(); + }, + }; + } + return { client: { async getProvidersSnapshot() { throw new Error("Unexpected snapshot pull"); }, - on, - async subscribeCheckoutDiff(cwd, compare, requestOptions) { - subscribeCheckoutDiffCalls.push({ - cwd, - compare, - subscriptionId: requestOptions.subscriptionId, - }); - if (config.rejectCheckoutDiffSubscribe) { - throw new Error("subscribe failed"); - } + 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 { - subscriptionId: requestOptions.subscriptionId, - cwd, - files: [], - error: null, - requestId: requestOptions.requestId ?? "subscribe-checkout-diff", + ...handle, + subscribe: (observer) => { + queueMicrotask(() => observer.error?.(new Error("subscribe failed"))); + return () => {}; + }, }; }, - unsubscribeCheckoutDiff(subscriptionId) { - unsubscribeCheckoutDiffCalls.push(subscriptionId); - }, - subscribeTerminals(subscription) { - subscribeTerminalCalls.push(subscription); - }, - unsubscribeTerminals(subscription) { - unsubscribeTerminalCalls.push(subscription); + 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, @@ -153,17 +206,18 @@ describe("server data push router", () => { const cwd = "/repo"; const queryKey = checkoutDiffQueryKey(serverId, cwd, "uncommitted", undefined, false); const subscriptionId = "diff-sharing"; - queryClient.getQueryCache().build(queryClient, { + const observer = new QueryObserver(queryClient, { queryKey, queryFn: skipToken, meta: checkoutDiffPushRoute({ - enabled: false, + 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) => ({ @@ -195,7 +249,13 @@ describe("server data push router", () => { const publish = (incomingFiles: Payload["files"], requestId: string) => { fake.emit({ type: "subscribe_checkout_diff_response", - payload: { subscriptionId, cwd, files: incomingFiles, requestId, error: null }, + payload: { + subscriptionId: fake.subscribeCheckoutDiffCalls[0]!.subscriptionId, + cwd, + files: incomingFiles, + requestId, + error: null, + }, }); return queryClient.getQueryData(queryKey)!; }; @@ -212,6 +272,7 @@ describe("server data push router", () => { expect(next.files[0]!.hunks[0]!.lines[0]!.tokens![0]!.style).toBe("variable"); expect(publish([], "deleted").files).toEqual([]); } finally { + unsubscribe(); unmount(); queryClient.clear(); } @@ -277,18 +338,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, @@ -307,7 +369,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({ @@ -315,12 +383,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(); }); @@ -411,7 +479,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"; @@ -475,18 +543,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 72c8ef20b2..fc6d51ce4f 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, @@ -21,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"]; @@ -53,6 +47,10 @@ interface CheckoutDiffRoute { compare: CheckoutDiffCompare; } +interface CheckoutDiffRegistration extends CheckoutDiffRoute { + subscription: OwnedSubscription; +} + interface WorkspaceTerminalsRoute { domain: "workspaceTerminals"; enabled: boolean; @@ -61,6 +59,10 @@ interface WorkspaceTerminalsRoute { workspaceId?: string; } +interface WorkspaceTerminalsRegistration extends WorkspaceTerminalsRoute { + subscription: OwnedSubscription; +} + type ServerDataRoute = CheckoutDiffRoute | WorkspaceTerminalsRoute; export interface ServerDataQueryMeta extends Record { @@ -71,18 +73,9 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage; interface ServerDataPushClient { getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"]; - 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; + 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 { @@ -137,7 +130,6 @@ const RECONNECT_REPAIR_POLICIES: ReconnectRepairPolicy[] = [ }, }, ]; -const reconnectSubscriptionRepairsByServerId = new Map void>>(); export function checkoutDiffPushRoute(input: { enabled: boolean; @@ -182,10 +174,6 @@ export function invalidateServerDataQueriesAfterReconnect(input: { for (const policy of RECONNECT_REPAIR_POLICIES) { policy.invalidate(input); } - for (const repairSubscriptions of reconnectSubscriptionRepairsByServerId.get(input.serverId) ?? - []) { - repairSubscriptions(); - } } export async function applyProvidersSnapshotUpdate(input: { @@ -226,8 +214,8 @@ export async function applyProvidersSnapshotUpdate(input: { } 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( @@ -262,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, { @@ -291,137 +279,131 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { } reconcileSubscriptions(); }); - const unsubscribeProviders = input.client.on("providers_snapshot_update", (message) => { - void applyProvidersSnapshotUpdate({ - client: input.client, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }).catch(() => { - /* Query state owns fetch failures; reconnect/refetch repairs them. */ - }); - }); - 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 }); + }, + }); } } @@ -443,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; @@ -523,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, { @@ -532,7 +469,7 @@ function applyTerminalsChanged(input: { if ( !route || route.domain !== "workspaceTerminals" || - route.cwd !== input.message.payload.cwd + !areWorkspaceTerminalsRoutesEqual(route, input.route) ) { continue; } @@ -776,14 +713,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/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/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 1dec26d5dd..bb5c1536a6 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -12,7 +12,11 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; -import { getCliDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { + getCliDaemonStatus, + shouldUseDesktopDaemon, + confirmAndStopDesktopDaemon, +} from "@/desktop/daemon/desktop-daemon"; import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings"; @@ -330,6 +334,17 @@ export function LocalDaemonSection() { const daemonStatus = data?.status ?? null; const daemonLogs = data?.logs ?? null; const daemonVersion = daemonStatus?.version ?? null; + const [isStopping, setIsStopping] = useState(false); + const handleStop = useCallback(() => { + setIsStopping(true); + void confirmAndStopDesktopDaemon() + .then((status) => { + if (status) setStatus(status); + return undefined; + }) + .catch((error) => Alert.alert(t("desktop.daemon.lifecycle.stopFailed"), String(error))) + .finally(() => setIsStopping(false)); + }, [setStatus, t]); const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const daemonStatusStateText = @@ -445,6 +460,26 @@ export function LocalDaemonSection() { isLoadingCliStatus={isLoadingCliStatus} /> + {daemonStatus?.pid ? ( + + + + + {daemonStatus.ownedByDesktop + ? t("desktop.daemon.lifecycle.owned") + : t("desktop.daemon.lifecycle.attached")} + + {daemonStatus.home} + + + + + ) : null} + {daemonVersionMismatch ? ( {t("desktop.daemon.versionMismatch")} 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 f465c32ac1..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; } @@ -128,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), }; } @@ -190,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")); } @@ -302,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/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/diagnostics/desktop-diagnostic-report.test.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts index 1ffa37b950..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 () => ({ @@ -145,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 531ae11fbb..22eb7e388f 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.ts @@ -1,4 +1,6 @@ import { + getDesktopSandboxDiagnostics, + type DesktopSandboxDiagnostics, getDesktopAppLogs, getDesktopDaemonLogs, getDesktopDaemonStatus, @@ -19,6 +21,7 @@ export interface DesktopDiagnosticCollectionResult { } export interface DesktopDiagnosticSources { + getSandboxDiagnostics: () => Promise; getStatus: () => Promise; getDaemonLogs: () => Promise; getAppLogs: () => Promise; @@ -26,6 +29,7 @@ export interface DesktopDiagnosticSources { } const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = { + getSandboxDiagnostics: getDesktopSandboxDiagnostics, getStatus: getDesktopDaemonStatus, getDaemonLogs: getDesktopDaemonLogs, getAppLogs: getDesktopAppLogs, @@ -38,12 +42,33 @@ export async function collectDesktopDiagnosticSections( const sections: string[] = []; let failed = false; - const [daemonResult, appLogsResult, updaterResult] = 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; 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/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..228cac3cc0 100644 --- a/packages/app/src/git/commits-section/commits-section.tsx +++ b/packages/app/src/git/commits-section/commits-section.tsx @@ -6,6 +6,7 @@ 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"; @@ -175,7 +176,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/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 1aa78ab3c6..c2b6d86c9c 100644 --- a/packages/app/src/git/diff-document/model.test.ts +++ b/packages/app/src/git/diff-document/model.test.ts @@ -71,6 +71,78 @@ 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) => ({ diff --git a/packages/app/src/git/diff-document/model.ts b/packages/app/src/git/diff-document/model.ts index a8b2baed90..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, @@ -571,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 a6813685e9..6cf8f2527c 100644 --- a/packages/app/src/git/diff-document/surface.native.tsx +++ b/packages/app/src/git/diff-document/surface.native.tsx @@ -256,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 ( 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 144676e550..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, @@ -56,6 +57,7 @@ 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"]); }); @@ -105,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 a141d170a7..905a8915d1 100644 --- a/packages/app/src/git/diff-document/text-measurement.ts +++ b/packages/app/src/git/diff-document/text-measurement.ts @@ -10,6 +10,7 @@ export interface CachedAsciiTextMetrics { measure(text: string): number; hasEveryGlyph(text: string): boolean; measureAdvances(graphemes: readonly string[]): number[]; + measureWidth(graphemes: readonly string[]): number; } const CODE_LIGATURE_CANDIDATE = /(?:===?|!==?|=>|<=|>=|->|::|\+\+|--)/; @@ -68,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. @@ -144,6 +161,11 @@ export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAs return true; }, 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 0c6f16bb5d..c482bed1a2 100644 --- a/packages/app/src/git/diff-document/text.native.ts +++ b/packages/app/src/git/diff-document/text.native.ts @@ -10,6 +10,7 @@ import { codeTextColor } from "./palette"; import { createCachedAsciiTextMetrics, createChunkedAdvanceMeasurer, + createChunkedWidthMeasurer, requiresShaping, type CachedAsciiTextMetrics, } from "./text-measurement"; @@ -202,43 +203,40 @@ export function createNativeTextMeasurer(input: { }; const asciiMetrics = createCachedAsciiTextMetrics(primary); const black = Skia.Color("black"); - const measureParagraph = (text: string) => { - const paragraph = createParagraph({ + const shapeParagraph = (text: string) => + createParagraph({ text, families, fontSize: input.fontSize, lineHeight: Math.round(input.fontSize * 1.5), color: black, }); - const width = paragraph.getLongestLine(); - paragraph.dispose(); - return width; - }; return { measure(text) { - return requiresRetainedParagraph(text, asciiMetrics) - ? measureParagraph(text) - : asciiMetrics.measure(text); + if (!requiresRetainedParagraph(text, asciiMetrics)) return asciiMetrics.measure(text); + const paragraph = shapeParagraph(text); + const width = paragraph.getLongestLine(); + paragraph.dispose(); + return width; }, + 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: 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; @@ -247,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); } 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-pane.tsx b/packages/app/src/git/diff-pane.tsx index c5de2585e6..139387ccb1 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -8,7 +8,6 @@ import { View, Text, Pressable, - FlatList, type PressableStateCallbackType, type StyleProp, type ViewStyle, @@ -33,15 +32,8 @@ import { type ParsedDiffFile } from "@/git/use-diff-query"; import type { ChangesState } from "@/panels/changes/state"; import { defaultChangesState } from "@/panels/changes/state"; 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 +73,7 @@ import { paneContentToolbarIconSize, paneContentToolbarIconButtonStyle, paneContentToolbarTrailingPadding, + type ToolbarTrailingControl, ToolbarButton, ToolbarControls, } from "@/components/ui/pane-content-toolbar"; @@ -568,7 +561,12 @@ function ChangesDiffOnlyToolbar({ sidebarSurface: boolean; }) { return ( - + @@ -582,19 +580,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 +626,7 @@ function ChangesRepositoryToolbar({ compact={compact} sidebarSurface={sidebarSurface} testID="changes-repository-header" + trailing={model.gitActions ? "framed" : "glyph"} > + [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({ @@ -1834,6 +1730,13 @@ export function ChangesSurface({ ); 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], @@ -1890,6 +1793,7 @@ export function ChangesSurface({ > {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/jump-to-file/index.tsx b/packages/app/src/git/jump-to-file/index.tsx new file mode 100644 index 0000000000..d33db75f36 --- /dev/null +++ b/packages/app/src/git/jump-to-file/index.tsx @@ -0,0 +1,127 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +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 [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/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 35ef268dac..091d053307 100644 --- a/packages/app/src/git/use-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -91,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/hooks/keyboard-shift-context.ts b/packages/app/src/hooks/keyboard-shift-context.ts index 9d41f13224..dcb0b4cf64 100644 --- a/packages/app/src/hooks/keyboard-shift-context.ts +++ b/packages/app/src/hooks/keyboard-shift-context.ts @@ -3,6 +3,8 @@ 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; } diff --git a/packages/app/src/hooks/keyboard-shift-policy.ts b/packages/app/src/hooks/keyboard-shift-policy.ts index 19c021a26c..97435027fa 100644 --- a/packages/app/src/hooks/keyboard-shift-policy.ts +++ b/packages/app/src/hooks/keyboard-shift-policy.ts @@ -1,5 +1,15 @@ 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; 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 c23512758e..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 @@ -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-keyboard-shift-style.test.ts b/packages/app/src/hooks/use-keyboard-shift-style.test.ts index bada7dd74c..c85f26dde4 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.test.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.test.ts @@ -3,9 +3,32 @@ import { resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, resolveKeyboardShift, + reserveKeyboardLayoutShift, shouldUseCompactExplorerKeyboardPadding, } from "./keyboard-shift-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({ diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index 126ab9e9b3..2bd57d105c 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -17,6 +17,7 @@ import { scheduleOnRN } from "react-native-worklets"; import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, + reserveKeyboardLayoutShift, shouldReconcileHiddenKeyboardEnd, } from "@/hooks/keyboard-shift-policy"; import { @@ -33,6 +34,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { 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)); @@ -55,12 +57,36 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { useGenericKeyboardHandler( { - onStart: () => { + 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; @@ -68,7 +94,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { isMoving.value = false; }, }, - [isIos, isMoving, keyboardHeight, keyboardProgress], + [bottomInset, isIos, isMoving, keyboardHeight, keyboardProgress, layoutShift], ); useAnimatedReaction( @@ -84,10 +110,11 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ shift, + layoutShift, isMoving, bottomInset, }), - [bottomInset, isMoving, shift], + [bottomInset, isMoving, layoutShift, shift], ); return createElement( diff --git a/packages/app/src/hooks/use-revealed-text.ts b/packages/app/src/hooks/use-revealed-text.ts index d3e1c905ab..beac3eea70 100644 --- a/packages/app/src/hooks/use-revealed-text.ts +++ b/packages/app/src/hooks/use-revealed-text.ts @@ -1,87 +1 @@ -import { useEffect, useRef, useState } from "react"; - -import { - advanceTextReveal, - beginTextReveal, - completeTextReveal, - isTextRevealPacingSupported, - isTextRevealSettled, - nextTextRevealFrame, - retargetTextReveal, - type TextRevealState, - visibleRevealedText, -} from "@/agent-stream/text-reveal"; -import type { MarkdownPhase } from "@/components/markdown/fence/types"; - -/** - * Binds the paced reveal in @/agent-stream/text-reveal to a frame clock. - * - * All of the policy — what is revealed when, and where it is safe to cut — lives - * in that module and is tested there. This hook only owns the requestAnimationFrame - * wiring, so the rendered behavior is covered end to end by - * `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`. - */ -export function useRevealedText(text: string, phase: MarkdownPhase): string { - const pacingSupported = isTextRevealPacingSupported(); - const stateRef = useRef(beginTextReveal(text)); - const [, forceRender] = useState(0); - const frameRef = useRef(null); - const lastFrameAtRef = useRef(null); - - stateRef.current = retargetTextReveal(stateRef.current, text); - - useEffect(() => { - const settle = () => { - lastFrameAtRef.current = null; - const next = completeTextReveal(stateRef.current); - if (next !== stateRef.current) { - stateRef.current = next; - forceRender((tick) => tick + 1); - } - }; - - if (!pacingSupported || phase !== "streaming") { - settle(); - return; - } - if (isTextRevealSettled(stateRef.current)) { - lastFrameAtRef.current = null; - return; - } - if (typeof requestAnimationFrame !== "function") { - settle(); - return; - } - - const tick = (timestamp: number) => { - frameRef.current = null; - const frame = nextTextRevealFrame(lastFrameAtRef.current, timestamp); - if (!frame) { - frameRef.current = requestAnimationFrame(tick); - return; - } - lastFrameAtRef.current = frame.frameAtMs; - - const next = advanceTextReveal(stateRef.current, frame.elapsedMs); - if (next !== stateRef.current) { - stateRef.current = next; - forceRender((count) => count + 1); - } - if (!isTextRevealSettled(stateRef.current)) { - frameRef.current = requestAnimationFrame(tick); - } - }; - - frameRef.current = requestAnimationFrame(tick); - return () => { - if (frameRef.current !== null) { - cancelAnimationFrame(frameRef.current); - frameRef.current = null; - } - }; - }, [text, pacingSupported, phase]); - - return pacingSupported - ? visibleRevealedText(stateRef.current, { streaming: phase === "streaming" }) - : text; -} +export { useWordPacedText as useRevealedText } from "@/word-stream"; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e90f4264ef..0bbe1c78e3 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -34,6 +34,7 @@ export const ar: TranslationResources = { copy: "ينسخ", copyLine: "نسخ السطر", dismiss: "رفض", + reload: "إعادة التحميل", retry: "أعد المحاولة", search: "يبحث", select: "يختار", @@ -175,7 +176,7 @@ export const ar: TranslationResources = { initialPromptRequired: "مطلوب موجه الأولي", alreadyLoading: "جارٍ التحميل بالفعل", uploadFailed: "Failed to upload file", - noClipboardImage: "لا توجد صورة في الحافظة", + noClipboardImage: "لا توجد صورة في الحافظة الحالية. جرّب اللصق من لوحة المفاتيح.", pasteImageFailed: "تعذر لصق الصورة", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, @@ -230,6 +231,10 @@ export const ar: TranslationResources = { historyLoadFailed: "تعذر تحميل سجل الوكيل", messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).", permission: { + rejectedPlan: "خطة مرفوضة", + approvedPlan: "خطة معتمدة", + canceledPlan: "خطة ملغاة", + plan: "يخطط", required: "الإذن مطلوب", deny: "ينكر", @@ -243,7 +248,8 @@ export const ar: TranslationResources = { states: { notFound: "لم يتم العثور على Agent", failedToLoad: "فشل تحميل الوكيل", - reconnecting: "جارٍ إعادة الاتصال", + reconnecting: "جارٍ إعادة الاتصال بالمضيف", + updating: "جارٍ تحديث الرسائل", timelineSyncFailed: "تعذر تحديث سجل الوكيل.", timelineSyncRetrying: "جارٍ إعادة المحاولة…", archivingTitle: "وكيل الارشيف...", @@ -901,6 +907,9 @@ export const ar: TranslationResources = { openChangesTab: "فتح علامة تبويب التغييرات", openDiffTab: "فتح علامة تبويب الفرق", closeChangesTab: "إغلاق علامة تبويب التغييرات", + jumpToFile: { + title: "الانتقال إلى ملف", + }, binaryFile: "ملف ثنائي", tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه", previewTooLargeTitle: "هذا الفرق كبير جدًا بحيث لا يمكن معاينته", @@ -1295,6 +1304,7 @@ export const ar: TranslationResources = { detail: "إيقاف البرنامج الخفي المحلي.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "حالة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 42aee2127e..e1efdc613d 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -30,6 +30,7 @@ export const en = { copy: "Copy", copyLine: "Copy line", dismiss: "Dismiss", + reload: "Reload", retry: "Retry", search: "Search", select: "Select", @@ -171,7 +172,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}})", }, @@ -226,6 +227,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", @@ -239,7 +244,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...", @@ -909,6 +915,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", @@ -1304,6 +1313,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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 153c0e2340..ba66d68224 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -35,6 +35,7 @@ export const es: TranslationResources = { copy: "Copiar", copyLine: "Copiar línea", dismiss: "Despedir", + reload: "Volver a cargar", retry: "Rever", search: "Buscar", select: "Seleccionar", @@ -176,7 +177,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}})", }, @@ -231,6 +233,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", @@ -244,7 +250,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...", @@ -933,6 +940,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", @@ -1330,6 +1340,7 @@ export const es: TranslationResources = { detail: "Deteniendo el demonio local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Estado", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 98b25c1a86..b4ca2d7468 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -35,6 +35,7 @@ export const fr: TranslationResources = { copy: "Copie", copyLine: "Copier la ligne", dismiss: "Rejeter", + reload: "Recharger", retry: "Réessayer", search: "Recherche", select: "Sélectionner", @@ -178,7 +179,8 @@ export const fr: TranslationResources = { 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", + noClipboardImage: + "Aucune image dans le presse-papiers actuel. Essayez de coller depuis votre clavier.", pasteImageFailed: "Impossible de coller l’image", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, @@ -233,6 +235,10 @@ export const fr: TranslationResources = { 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", @@ -246,7 +252,8 @@ export const fr: TranslationResources = { states: { notFound: "Agentintrouvable", failedToLoad: "Échec du chargement de l'agent", - reconnecting: "Reconnexion", + 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...", @@ -932,6 +939,9 @@ export const fr: TranslationResources = { 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é", previewTooLargeTitle: "Ce diff est trop volumineux pour être prévisualisé", @@ -1330,6 +1340,7 @@ export const fr: TranslationResources = { detail: "Arrêt du démon local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Statut", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 39bc61a1bb..4f1236698e 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -35,6 +35,7 @@ export const ja: TranslationResources = { copy: "コピー", copyLine: "行をコピー", dismiss: "閉じる", + reload: "再読み込み", retry: "再試行", search: "検索", select: "選択", @@ -176,7 +177,8 @@ export const ja: TranslationResources = { initialPromptRequired: "初期プロンプトが必要です", alreadyLoading: "すでに読み込み中です", uploadFailed: "ファイルのアップロードに失敗しました", - noClipboardImage: "クリップボードに画像がありません", + noClipboardImage: + "現在のクリップボードに画像がありません。キーボードから貼り付けてみてください。", pasteImageFailed: "画像を貼り付けられませんでした", fileTooLarge: "{{fileName}}が大きすぎます(最大{{size}})", }, @@ -231,6 +233,10 @@ export const ja: TranslationResources = { historyLoadFailed: "エージェントの履歴を読み込めませんでした", messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。", permission: { + rejectedPlan: "却下されたプラン", + approvedPlan: "承認されたプラン", + canceledPlan: "キャンセルされたプラン", + plan: "プラン", required: "権限が必要です", deny: "拒否", @@ -244,7 +250,8 @@ export const ja: TranslationResources = { states: { notFound: "エージェントが見つかりません", failedToLoad: "エージェントの読み込みに失敗しました", - reconnecting: "再接続中", + reconnecting: "ホストに再接続中", + updating: "メッセージを更新中", timelineSyncFailed: "エージェントの履歴を更新できませんでした。", timelineSyncRetrying: "再試行しています…", archivingTitle: "エージェントをアーカイブ中...", @@ -913,6 +920,9 @@ export const ja: TranslationResources = { openChangesTab: "変更タブを開く", openDiffTab: "差分タブを開く", closeChangesTab: "変更タブを閉じる", + jumpToFile: { + title: "ファイルへ移動", + }, binaryFile: "バイナリファイル", tooLarge: "差分が大きすぎて表示できません", previewTooLargeTitle: "この差分は大きすぎるためプレビューできません", @@ -1308,6 +1318,7 @@ export const ja: TranslationResources = { detail: "ローカルデーモンを停止中。", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "デーモン", status: { title: "ステータス", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index eeb555b654..76d35a3ac4 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -34,6 +34,7 @@ export const ko: TranslationResources = { copy: "복사", copyLine: "줄 복사", dismiss: "닫기", + reload: "다시 로드", retry: "다시 시도", search: "검색", select: "선택", @@ -175,7 +176,7 @@ export const ko: TranslationResources = { initialPromptRequired: "초기 프롬프트가 필요합니다", alreadyLoading: "이미 불러오는 중입니다", uploadFailed: "파일을 업로드하지 못했습니다", - noClipboardImage: "클립보드에 이미지가 없습니다.", + noClipboardImage: "현재 클립보드에 이미지가 없습니다. 키보드에서 붙여넣기를 시도해 보세요.", pasteImageFailed: "이미지를 붙여넣지 못했습니다.", fileTooLarge: "{{fileName}}이(가) 너무 큽니다 (최대 {{size}})", }, @@ -230,6 +231,10 @@ export const ko: TranslationResources = { historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.", messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).", permission: { + rejectedPlan: "거부된 계획", + approvedPlan: "승인된 계획", + canceledPlan: "취소된 계획", + plan: "계획", required: "권한 필요", deny: "거부", @@ -243,7 +248,8 @@ export const ko: TranslationResources = { states: { notFound: "에이전트를 찾을 수 없습니다", failedToLoad: "에이전트를 불러오지 못했습니다", - reconnecting: "다시 연결하는 중", + reconnecting: "호스트에 다시 연결하는 중", + updating: "메시지 업데이트 중", timelineSyncFailed: "에이전트 기록을 새로고침할 수 없습니다.", timelineSyncRetrying: "재시도 중…", archivingTitle: "에이전트 보관 중...", @@ -908,6 +914,9 @@ export const ko: TranslationResources = { openChangesTab: "변경사항 탭 열기", openDiffTab: "Diff 탭 열기", closeChangesTab: "변경사항 탭 닫기", + jumpToFile: { + title: "파일로 이동", + }, binaryFile: "바이너리 파일", tooLarge: "표시하기에 diff가 너무 큽니다", previewTooLargeTitle: "Diff가 너무 커서 미리 볼 수 없습니다", @@ -1302,6 +1311,7 @@ export const ko: TranslationResources = { detail: "로컬 데몬을 중지하는 중입니다.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "데몬", status: { title: "상태", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 84e732668d..88373ae583 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -35,6 +35,7 @@ export const ptBR: TranslationResources = { copy: "Copiar", copyLine: "Copiar linha", dismiss: "Dispensar", + reload: "Recarregar", retry: "Tentar novamente", search: "Buscar", select: "Selecionar", @@ -176,7 +177,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}})", }, @@ -231,6 +232,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", @@ -244,7 +249,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...", @@ -924,6 +930,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", @@ -1321,6 +1330,7 @@ export const ptBR: TranslationResources = { detail: "Parando o daemon local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Status", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 6e4d385fae..c77395d892 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -35,6 +35,7 @@ export const ru: TranslationResources = { copy: "Копировать", copyLine: "Копировать строку", dismiss: "Отклонить", + reload: "Перезагрузить", retry: "Повторить", search: "Поиск", select: "Выбрать", @@ -176,7 +177,8 @@ export const ru: TranslationResources = { initialPromptRequired: "Необходим инициализирующий промпт", alreadyLoading: "Уже загружается", uploadFailed: "Ошибка загрузки файла", - noClipboardImage: "В буфере обмена нет изображения", + noClipboardImage: + "В текущем буфере обмена нет изображения. Попробуйте вставить его с клавиатуры.", pasteImageFailed: "Не удалось вставить изображение", fileTooLarge: "Файл {{fileName}} слишком большой (максимальный размер: {{size}})", }, @@ -231,6 +233,10 @@ export const ru: TranslationResources = { historyLoadFailed: "Не удалось загрузить историю агента", messageCapped: "Это сообщение было обрезано ({{bytes}} байт).", permission: { + rejectedPlan: "Отклонённый план", + approvedPlan: "Одобренный план", + canceledPlan: "Отменённый план", + plan: "План", required: "Требуется разрешение", deny: "Отклонить", @@ -244,7 +250,8 @@ export const ru: TranslationResources = { states: { notFound: "Агент не найден", failedToLoad: "Не удалось загрузить агента", - reconnecting: "Повторное подключение", + reconnecting: "Повторное подключение к хосту", + updating: "Обновление сообщений", timelineSyncFailed: "Не удалось обновить историю агента.", timelineSyncRetrying: "Повторная попытка…", archivingTitle: "Агент перемещается в архив...", @@ -917,6 +924,9 @@ export const ru: TranslationResources = { openChangesTab: "Открыть вкладку «Изменения»", openDiffTab: "Открыть вкладку «Дифф»", closeChangesTab: "Закрыть вкладку «Изменения»", + jumpToFile: { + title: "Перейти к файлу", + }, binaryFile: "Бинарный файл", tooLarge: "Дифф слишком велик для отображения", previewTooLargeTitle: "Этот дифф слишком велик для предварительного просмотра", @@ -1313,6 +1323,7 @@ export const ru: TranslationResources = { detail: "Остановка локального демона.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Демон", status: { title: "Статус", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1717506d97..c47f65b153 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -34,6 +34,7 @@ export const zhCN: TranslationResources = { copy: "复制", copyLine: "复制行", dismiss: "关闭", + reload: "重新加载", retry: "重试", search: "搜索", select: "选择", @@ -175,7 +176,7 @@ export const zhCN: TranslationResources = { initialPromptRequired: "初始 prompt 必填", alreadyLoading: "正在加载", uploadFailed: "Failed to upload file", - noClipboardImage: "剪贴板中没有图片", + noClipboardImage: "当前剪贴板中没有图片。请尝试通过键盘粘贴。", pasteImageFailed: "无法粘贴图片", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, @@ -230,6 +231,10 @@ export const zhCN: TranslationResources = { historyLoadFailed: "无法加载智能体历史记录", messageCapped: "此消息已被截断({{bytes}} 字节)。", permission: { + rejectedPlan: "已拒绝的计划", + approvedPlan: "已批准的计划", + canceledPlan: "已取消的计划", + plan: "Plan", required: "需要权限", deny: "拒绝", @@ -243,7 +248,8 @@ export const zhCN: TranslationResources = { states: { notFound: "未找到 Agent", failedToLoad: "加载 Agent 失败", - reconnecting: "正在重连", + reconnecting: "正在重新连接主机", + updating: "正在更新消息", timelineSyncFailed: "无法刷新代理历史记录。", timelineSyncRetrying: "正在重试…", archivingTitle: "正在归档 Agent...", @@ -893,6 +899,9 @@ export const zhCN: TranslationResources = { openChangesTab: "打开“更改”标签页", openDiffTab: "打开 Diff 标签页", closeChangesTab: "关闭“更改”标签页", + jumpToFile: { + title: "跳转到文件", + }, binaryFile: "二进制文件", tooLarge: "Diff 过大,无法显示", previewTooLargeTitle: "此 diff 过大,无法预览", @@ -1284,6 +1293,7 @@ export const zhCN: TranslationResources = { detail: "正在停止本地 daemon。", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "状态", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index c2129c654c..7a695ef058 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -24,8 +24,10 @@ 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 { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; 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 { @@ -35,12 +37,7 @@ import { 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 { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { @@ -57,14 +54,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"; @@ -72,7 +67,6 @@ import { useHasPluginComposerPills } from "@/plugins"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; import { type HostRuntimeConnectionStatus, - getHostRuntimeConnectionStatusSince, useHostRuntimeClient, useHostRuntimeConnectionStatus, useHostRuntimeIsConnected, @@ -125,10 +119,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; @@ -384,13 +374,15 @@ function AgentPanel() { invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( - + + + ); } @@ -577,7 +569,6 @@ function AgentPanelContent({ isPaneFocused={isPaneFocused} client={runtimeClient} isConnected={runtimeIsConnected} - connectionStatus={connectionStatus} onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); @@ -590,7 +581,6 @@ function AgentPanelBody({ isPaneFocused, client, isConnected, - connectionStatus, onOpenWorkspaceFile, }: { serverId: string; @@ -599,7 +589,6 @@ function AgentPanelBody({ isPaneFocused: boolean; client: ReturnType; isConnected: boolean; - connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); @@ -626,8 +615,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; @@ -639,9 +626,6 @@ function AgentPanelBody({ return; } if (agentState.id) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } if (lookupState.tag !== "idle") { setLookupState({ tag: "idle" }); } @@ -664,9 +648,6 @@ function AgentPanelBody({ return; } if (!result) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "not_found", message: `Agent not found: ${agentId}`, @@ -675,9 +656,6 @@ function AgentPanelBody({ } storeFetchedAgentDetail({ serverId, result }); - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "idle" }); return; }) @@ -687,25 +665,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 ( @@ -759,7 +724,6 @@ function AgentPanelBody({ isPaneFocused={isPaneFocused} client={client} isConnected={isConnected} - connectionStatus={connectionStatus} onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); @@ -772,7 +736,6 @@ function ChatAgentContent({ isPaneFocused, client, isConnected, - connectionStatus, onOpenWorkspaceFile, }: { serverId: string; @@ -781,7 +744,6 @@ function ChatAgentContent({ isPaneFocused: boolean; client: ReturnType; isConnected: boolean; - connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); @@ -791,7 +753,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; @@ -878,73 +839,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(() => { @@ -1198,6 +1092,7 @@ function ChatAgentContent({ handleComposerHeightChange={handleComposerHeightChange} handleMessageSent={handleMessageSent} handleRewindComplete={handleRewindComplete} + timelineSync={isPaneVisible && viewState.tag === "ready" ? viewState.sync : null} showHistorySyncOverlay={showHistorySyncOverlay} showHistorySyncError={showHistorySyncError} isRetryingHistorySync={isRetryingHistorySync} @@ -1227,6 +1122,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ handleComposerHeightChange, handleMessageSent, handleRewindComplete, + timelineSync, showHistorySyncOverlay, showHistorySyncError, isRetryingHistorySync, @@ -1252,6 +1148,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ handleComposerHeightChange: (height: number) => void; handleMessageSent: () => void; handleRewindComplete: () => void; + timelineSync: AgentScreenReadySyncState | null; showHistorySyncOverlay: boolean; showHistorySyncError: boolean; isRetryingHistorySync: boolean; @@ -1383,7 +1280,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ setText={agentInputDraft.replaceText} onRewindComplete={handleRewindComplete} > - + {contentContainer} @@ -1410,7 +1307,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : null} - {composerSection} + + {composerSection} + {showHistorySyncOverlay ? ( @@ -1418,7 +1317,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : null} - + {isArchivingCurrentAgent ? ( @@ -1428,7 +1327,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ {t("agentPanel.states.archivingSubtitle")} ) : null} - + ); }); @@ -1893,12 +1792,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/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 80323561ba..8fc18eb0e3 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -21,6 +21,7 @@ import { providerSubagentKey, providerSubagentLifecycleStatus, refreshProviderSubagents, + observeProviderSubagentTimeline, useProviderSubagentStore, } from "@/subagents/provider-store"; import { useTranslation } from "react-i18next"; @@ -126,8 +127,8 @@ 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({ @@ -150,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 @@ -191,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(); 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/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index b6ea249337..028114c862 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -6,6 +6,7 @@ import { PluginButtonStore, buttonMatches } from "./model"; function installation(): InstalledPlugin { return { + lifetime: new AbortController(), id: "review", serverId: "host-a", clientBundle: "bundle", diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index 42067043c2..b737b3ebf0 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -36,7 +36,6 @@ import type { Theme } from "@/styles/theme"; import { createPluginClientStateSource } from "../client-state/source"; import { Icon } from "../icons"; import { PluginRuntimeBoundary } from "../runtime-boundary"; -import { createPluginSurfaceRuntime, type PluginSurfaceRuntime } from "../surface-runtime"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { toPluginTheme } from "../theme"; import { buttonMatches, type RegisteredPluginButton } from "./model"; @@ -45,7 +44,7 @@ import { pluginButtonStore } from "./store"; interface ButtonView { entry: RegisteredPluginButton; props: PluginHostProps & RegisteredPluginButton["context"]; - runtime: PluginSurfaceRuntime; + client: NonNullable>; state: ReturnType; toast: ReturnType; } @@ -79,7 +78,7 @@ function resolvePlatform(): PluginHostProps["layout"]["platform"] { function ButtonEnvironment({ view, children }: { view: ButtonView; children: ReactNode }) { return ( - + {children} @@ -219,11 +218,11 @@ function ButtonSurfaceBody({ path: readonly string[]; }) { return ( - - + + - - + + ); } @@ -396,11 +395,10 @@ function createButtonView({ hostLabel: string; theme: PluginTheme; }): ButtonView | null { - const runtime = createPluginSurfaceRuntime(client, entry.installation.id); - if (!runtime) return null; + if (!client) return null; return { entry, - runtime, + client, toast, state: createPluginClientStateSource(entry.installation.serverId), props: { @@ -486,11 +484,15 @@ function OverflowPages({ const view = createButtonView({ entry, client, toast, compact, hostLabel, theme }); if (!view) continue; rows.push( - - + + - - , + + , ); if (entry.button.behavior.kind === "action") continue; pages.push( diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index a7300d75c2..484124971e 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -48,25 +48,36 @@ export function PluginCatalogSync({ ); return refreshQueue; }; - void refresh(); - const unsubscribe = client.on("status", (message) => { - 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) }); + 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); - } + 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]); diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index cb953d16b2..4424c240fa 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -16,7 +16,7 @@ export function createPluginClientRuntime( installation: InstalledPlugin, daemonClient: DaemonClient, ): PluginClientRuntime { - const runtime = createPluginSurfaceRuntime(daemonClient, installation.id); + const runtime = createPluginSurfaceRuntime(daemonClient, installation); if (!runtime) throw new Error("Plugin host is offline"); const state = createPluginClientStateSource(installation.serverId); const capabilities = createPluginCapabilities( diff --git a/packages/app/src/plugins/client-slash-commands/index.ts b/packages/app/src/plugins/client-slash-commands/index.ts index a99a0fa538..57d205a7b9 100644 --- a/packages/app/src/plugins/client-slash-commands/index.ts +++ b/packages/app/src/plugins/client-slash-commands/index.ts @@ -31,28 +31,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 +42,38 @@ export function usePluginClientSlashCommands(input: { description: contribution.description, argumentHint: contribution.argumentHint, async run(args: string) { - if (contribution.context === "agent" && context.context === "agent") { - await contribution.onSubmit({ ...context, args }); - } else if ( - contribution.context === "workspace" && - context.context === "workspace" - ) { - await contribution.onSubmit({ ...context, args }); + const runtime = createPluginSurfaceRuntime(client, plugin); + if (!runtime) return; + try { + const context = + contribution.context === "agent" + ? createPluginAgentActionContext({ + plugin, + runtime, + navigation, + state, + workspaceId, + agentId: input.agentId, + }) + : createPluginWorkspaceActionContext({ + plugin, + runtime, + navigation, + state, + workspaceId, + }); + if (contribution.context === "agent" && context?.context === "agent") + await contribution.onSubmit({ ...context, args }); + else if (contribution.context === "workspace" && context?.context === "workspace") + await contribution.onSubmit({ ...context, args }); + } finally { + await runtime.paseo.dispose(); } }, }, ]; - }); - }); + }), + ); return commands; }, [client, input.agentId, input.serverId, input.workspaceId, installed]); } diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index dc40b342b9..89313f907c 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -60,6 +60,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { id: "review", serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => {}, settingsScreens: [], @@ -120,7 +121,7 @@ function stateSource() { }; } -function createRuntime(pluginId: string) { +function createRuntime(installed: InstalledPlugin) { const client = new DaemonClient({ url: "ws://127.0.0.1:1", clientId: "plugin-command-test", @@ -129,7 +130,7 @@ function createRuntime(pluginId: string) { return { paseo: createPaseoApi(client), invoke: async (method: string, input: unknown) => { - expect(pluginId).toBe("review"); + expect(installed.id).toBe("review"); expect(method).toBe("review.inspect"); return { value: inspect.input.parse(input).value + 1 }; }, @@ -187,7 +188,7 @@ describe("plugin Command Center contributions", () => { context.openSurface("main"); context.openPanel("details", { location: "explorer" }); }); - const runtime = createRuntime("review"); + const runtime = createRuntime(installed); const actions = buildPluginCommandCenterContributions({ plugins: [installed], runtime: () => runtime, diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index 448b0068cb..9d40ee57e2 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -9,7 +9,7 @@ import { createPluginCapabilities, type PluginNavigation } from "../actions"; export interface PluginCommandCenterSource { plugins: readonly InstalledPlugin[]; - runtime(pluginId: string): PluginSurfaceRuntime; + runtime(plugin: InstalledPlugin): PluginSurfaceRuntime; state: PluginClientStateSource; workspaceId: string | null; agentId: string | null; @@ -22,12 +22,12 @@ 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 runtime = source.runtime(plugin); + const common = createPluginCapabilities(plugin, runtime, source.navigation); try { if (item.context === "global") { await item.onSelect({ context: "global", ...common }); @@ -73,6 +73,8 @@ export function buildPluginCommandCenterContributions( }); } catch (error) { source.reportError(error); + } finally { + await runtime.paseo.dispose().catch(source.reportError); } }; contributions.push({ diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index 2ee0d24ff2..cd85f9bff6 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -53,8 +53,8 @@ export function PluginCommandCenterActions() { if (!client || !serverId || !stateSource) return []; return buildPluginCommandCenterContributions({ plugins, - runtime(pluginId) { - const runtime = createPluginSurfaceRuntime(client, pluginId); + runtime(plugin) { + const runtime = createPluginSurfaceRuntime(client, plugin); if (!runtime) throw new Error("Plugin host is offline"); return runtime; }, diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index e56eb0e5be..d3cb17b150 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -392,19 +392,11 @@ export function runPluginClientBundle( if (typeof setup !== "function") { throw new Error(`Plugin ${id} must default export a function`); } - let entryCleanup: PluginCleanup; + let entryCleanup: PluginCleanup | undefined; try { entryCleanup = setup(pluginContext); - if (typeof entryCleanup !== "function") { + if (typeof entryCleanup !== "function") throw new Error(`Plugin ${id} contribution must return a cleanup function`); - } - } catch (error) { - stopped = true; - for (const remove of removals) remove(); - throw error; - } - - try { for (const item of collector.sidebarItems) { if (!surfaceIds.has(item.surface)) { throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`); @@ -412,22 +404,26 @@ export function runPluginClientBundle( } } catch (error) { stopped = true; - for (const remove of removals) remove(); 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; + const cleanupEntry = entryCleanup; const cleanup = async () => { if (stopped) return; stopped = true; try { - await entryCleanup(); + await cleanupEntry(); } finally { for (const remove of removals) remove(); } 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..1a239a3d30 --- /dev/null +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { PluginRegistry } from "./registry"; + +function registry() { + const client = new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "plugin-lifetime-test" }); + const released: string[] = []; + const plugins = new PluginRegistry({ + version: "0.8.0", + createRuntime: (installation) => { + const api = createPaseoApi(client); + return { + paseo: { + ...api, + dispose: async () => { + released.push(installation.id); + await api.dispose(); + }, + }, + rpc: async () => { + throw new Error("Unexpected plugin RPC"); + }, + openSurface: () => {}, + openSettings: () => {}, + openPanel: () => {}, + addComposerPill: () => ({ update() {}, remove() {} }), + addHeaderButton: () => ({ update() {}, remove() {} }), + }; + }, + }); + return { client, released, plugins }; +} + +function catalog(id: string, body: string) { + return { + id, + requirements: { paseo: ">=0.8.0" }, + clientBundle: `(function() { return { default: function(plugin) { ${body} } }; })`, + }; +} + +test("failed plugin initialization disposes its API scope", async () => { + const h = registry(); + h.plugins.installCatalog("host", [catalog("failed", 'throw new Error("setup failed");')], { + client: h.client, + }); + await expect.poll(() => h.released).toEqual(["failed"]); + expect(h.plugins.getSnapshot()).toEqual([]); +}); + +test("unloading releases the API 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 }); + h.plugins.installCatalog("host", [surviving], { client: h.client }); + await expect.poll(() => h.released).toEqual(["failing"]); + expect(h.plugins.getSnapshot().map((plugin) => plugin.id)).toEqual(["surviving"]); + h.plugins.removeHost("host"); + await expect.poll(() => h.released).toEqual(["failing", "surviving"]); +}); + +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 }, + ); + await expect.poll(() => h.released).toEqual(["async-entry"]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(h.plugins.getSnapshot()).toEqual([]); +}); diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index 918798f7e4..45e1ba546f 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -8,7 +8,7 @@ vi.mock("./navigation", () => ({ })); vi.mock("./client-runtime", () => ({ createPluginClientRuntime: () => ({ - paseo: {}, + paseo: { dispose: async () => {} }, rpc: async () => undefined, openSurface: () => undefined, openPanel: () => undefined, diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index f228c7202f..2894784fc7 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -4,7 +4,7 @@ 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 { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; import type { InstalledPlugin } from "./types"; type CatalogPlugin = Awaited>[number]; @@ -64,6 +64,8 @@ export class PluginRegistry { } const installed = catalog.flatMap((entry) => { const key = `${serverId}/${entry.id}`; + let runtime: PluginClientRuntime | undefined; + let lifetime: AbortController | undefined; try { if (!entry.clientBundle) return []; assertPluginCompatibility({ ...entry, version: this.dependencies.version, runtime: "app" }); @@ -74,7 +76,9 @@ export class PluginRegistry { this.evaluationErrors.delete(key); return [existing]; } + lifetime = new AbortController(); const installation: InstalledPlugin = { + lifetime, id: entry.id, serverId, clientBundle: entry.clientBundle, @@ -92,16 +96,28 @@ export class PluginRegistry { timelineTransformers: [], timelineRenderers: [], }; - const evaluated = runPluginClientBundle( - entry.id, - entry.clientBundle, - this.dependencies.createRuntime(installation, options.client), - () => this.publish(), + runtime = this.dependencies.createRuntime(installation, options.client); + const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () => + this.publish(), ); Object.assign(installation, evaluated); + const paseo = runtime.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) { + lifetime?.abort(); + void runtime?.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 []; @@ -138,6 +154,7 @@ export 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) => { diff --git a/packages/app/src/plugins/runtime-boundary.tsx b/packages/app/src/plugins/runtime-boundary.tsx index eaed2e5d54..7ae51af2b9 100644 --- a/packages/app/src/plugins/runtime-boundary.tsx +++ b/packages/app/src/plugins/runtime-boundary.tsx @@ -2,17 +2,20 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host"; import type { ReactNode } from "react"; import type { InstalledPlugin } from "./types"; -import type { PluginSurfaceRuntime } from "./surface-runtime"; +import { usePluginSurfaceRuntime } from "./surface-runtime"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; export function PluginRuntimeBoundary({ plugin, - runtime, + client, children, }: { plugin: InstalledPlugin; - runtime: PluginSurfaceRuntime; + client: DaemonClient; children: ReactNode; }) { + const runtime = usePluginSurfaceRuntime(client, plugin); + if (!runtime) return null; return ( diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx index dbf9dec18d..f4c66c3b32 100644 --- a/packages/app/src/plugins/settings/index.tsx +++ b/packages/app/src/plugins/settings/index.tsx @@ -13,7 +13,6 @@ import type { Theme } from "@/styles/theme"; import { useInstalledPlugin } from "../registry"; import { PluginRuntimeBoundary } from "../runtime-boundary"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; import { toPluginTheme } from "../theme"; import { buildPluginSettingsRoute } from "./routes"; @@ -101,8 +100,7 @@ function SettingsContent({ 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")}; - const runtime = createPluginSurfaceRuntime(client, pluginId); - if (!plugin || !screen || !runtime) + if (!plugin || !screen || !client) return {t("settings.plugins.screens.unavailable")}; const Component = screen.Component; return ( @@ -113,7 +111,7 @@ function SettingsContent({ resetKey={attempt} renderError={renderError} > - + diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index eb9ff4fa45..7a38bb8eb5 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -9,6 +9,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin { cleanup: () => undefined, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), settingsScreens: [], surfaces: [{ id: "surface", Component: () => null }], diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 685b57d3c2..e6b2eea757 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -16,6 +16,7 @@ function installation( id: pluginId, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => undefined, settingsScreens: [], diff --git a/packages/app/src/plugins/surface-runtime.test.tsx b/packages/app/src/plugins/surface-runtime.test.tsx index 88d8f694ec..30b2a7f76b 100644 --- a/packages/app/src/plugins/surface-runtime.test.tsx +++ b/packages/app/src/plugins/surface-runtime.test.tsx @@ -52,7 +52,10 @@ function borrowFromAppProvider(paseo: PaseoApi): PaseoApi { 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"); + const runtime = createPluginSurfaceRuntime(selected.client, { + id: "workspace-plugin", + lifetime: new AbortController(), + }); if (!runtime) throw new Error("Expected selected host runtime"); const paseo = borrowFromAppProvider(runtime.paseo); @@ -78,8 +81,14 @@ describe("plugin surface host runtime", () => { 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"); + const first = createPluginSurfaceRuntime(hostA.client, { + id: "same-plugin", + lifetime: new AbortController(), + }); + const second = createPluginSurfaceRuntime(hostB.client, { + id: "same-plugin", + lifetime: new AbortController(), + }); if (!first || !second) throw new Error("Expected online host runtimes"); await first.invoke("host", {}); @@ -95,7 +104,9 @@ describe("plugin surface host runtime", () => { it("keeps an offline selected host unavailable instead of borrowing another host", () => { const otherHost = clientWithWorkspace("workspace-online"); - expect(createPluginSurfaceRuntime(null, "same-plugin")).toBeNull(); + expect( + createPluginSurfaceRuntime(null, { id: "same-plugin", lifetime: new AbortController() }), + ).toBeNull(); expect(otherHost.createWorkspace).not.toHaveBeenCalled(); }); }); diff --git a/packages/app/src/plugins/surface-runtime.ts b/packages/app/src/plugins/surface-runtime.ts index b3635b9122..3c99e38aba 100644 --- a/packages/app/src/plugins/surface-runtime.ts +++ b/packages/app/src/plugins/surface-runtime.ts @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; +import type { InstalledPlugin } from "./types"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -8,11 +10,35 @@ export interface PluginSurfaceRuntime { export function createPluginSurfaceRuntime( client: DaemonClient | null, - pluginId: string, + plugin: Pick, ): PluginSurfaceRuntime | null { - if (!client) return null; + if (!client || plugin.lifetime.signal.aborted) return null; return { - paseo: createPaseoApi(client), - invoke: (method, input) => client.invokePluginRpc(pluginId, method, input), + paseo: createPaseoApi(client, { signal: plugin.lifetime.signal }), + invoke: (method, input) => client.invokePluginRpc(plugin.id, method, input), }; } + +/** A mounted surface owns its API; creating a React element creates no server demand. */ +export function usePluginSurfaceRuntime( + client: DaemonClient | null, + plugin: InstalledPlugin | null | undefined, +): PluginSurfaceRuntime | null { + const [mounted, setMounted] = useState<{ + client: DaemonClient; + plugin: InstalledPlugin; + runtime: PluginSurfaceRuntime; + } | null>(null); + useEffect(() => { + if (!client || !plugin) return; + const runtime = createPluginSurfaceRuntime(client, plugin); + if (!runtime) return; + setMounted({ client, plugin, runtime }); + return () => { + void runtime.paseo + .dispose() + .catch((error) => console.warn(`[Plugins] Surface cleanup failed for ${plugin.id}`, error)); + }; + }, [client, plugin]); + return mounted?.client === client && mounted.plugin === plugin ? mounted.runtime : null; +} diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index 6139897c5b..c827cf158b 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -21,7 +21,7 @@ import { useInstalledPlugin, usePluginInstallations } from "./registry"; import { buildPluginSurfaceRoute } from "./routes"; import { rememberPluginContributionHost } from "./contribution-host"; import { SurfaceErrorBoundary } from "./surface-error-boundary"; -import { createPluginSurfaceRuntime } from "./surface-runtime"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { PluginRuntimeBoundary } from "./runtime-boundary"; import { getPluginSurfaceContributionServerIds, @@ -55,14 +55,14 @@ const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon); function SurfaceRenderer({ Surface, - runtime, + client, plugin, layout, host, theme, }: { Surface: ComponentType; - runtime: NonNullable>; + client: DaemonClient; plugin: NonNullable>; layout: PluginSurfaceProps["layout"]; host: PluginSurfaceProps["host"]; @@ -70,7 +70,7 @@ function SurfaceRenderer({ }) { const navigation = usePluginHostNavigation(host.id); return ( - + ); @@ -162,7 +162,6 @@ 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), @@ -225,7 +224,7 @@ export function PluginSurfaceScreen() { - {plugin && surface && runtime ? ( + {plugin && surface && client ? ( undefined, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), settingsScreens: [], surfaces: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 5db3d3b327..68e1378199 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -11,6 +11,7 @@ function plugin(input: { id: input.id, serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => {}, settingsScreens: [], diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts index 4323f04075..22f65f05a0 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)) { @@ -77,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]; @@ -85,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, @@ -112,6 +120,7 @@ function transformSourceItem( export function projectPluginTimelineItems( items: StreamItem[], transformTimelineItem: TimelineItemTransform | undefined, + assistantPhase: AssistantPhase = "complete", ): StreamItem[] { if (!transformTimelineItem) return items; let bySource = projectionCache.get(transformTimelineItem); @@ -121,13 +130,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 9118a29a15..59d7c3ff36 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -2,14 +2,15 @@ * @vitest-environment jsdom */ import appPackage from "../../../package.json"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import React, { act } from "react"; +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", () => ({ @@ -21,6 +22,7 @@ vi.mock("../navigation", () => ({ vi.mock("../client-runtime", () => ({ createPluginClientRuntime: () => ({ paseo: {}, + dispose: () => {}, rpc: async () => undefined, openSurface: () => undefined, openPanel: () => undefined, @@ -107,12 +109,13 @@ 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", () => { + it("validates and renders the matching plugin component", async () => { pluginRegistry.installCatalog( "host-1", [{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], @@ -121,11 +124,16 @@ describe("PluginTimelineItemView", () => { }, ); - const markup = renderToStaticMarkup( - , + const container = document.createElement("div"); + containers.push(container); + const root = createRoot(container); + roots.push(root); + await act(async () => + root.render( + , + ), ); - - expect(markup).toContain("Four tests passed"); + expect(container.textContent).toContain("Four tests passed"); }); it("contains renderer crashes to one timeline item", async () => { @@ -201,3 +209,144 @@ describe("PluginTimelineItemView", () => { expect(container.textContent).toBe("Recovered"); }); }); + +it("releases a crashed renderer's observations and recovers a fresh scope 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(); + const failed = React.useRef(false); + failed.current = props.item.data.label === "explode"; + React.useEffect(() => { + const owner = paseo.observeEvents(["project.update"]); + return () => { if (failed.current) throw new Error("plugin cleanup failed"); 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 }, + ); + 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 6eba06c32b..1490d8e14e 100644 --- a/packages/app/src/plugins/timeline/view.tsx +++ b/packages/app/src/plugins/timeline/view.tsx @@ -11,7 +11,6 @@ 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 { SurfaceErrorBoundary } from "../surface-error-boundary"; import { toPluginTheme } from "../theme"; @@ -60,10 +59,6 @@ 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; @@ -71,7 +66,7 @@ function PluginTimelineItemBody({ const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); - if (!plugin || !renderer || !parsed || !runtime) { + if (!plugin || !renderer || !parsed || !client) { return ; } @@ -91,7 +86,7 @@ function PluginTimelineItemBody({ }; return ( - + diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 651e5f7b28..9305cc3ba8 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -38,6 +38,7 @@ export interface EvaluatedPlugin { } export interface InstalledPlugin extends EvaluatedPlugin { + lifetime: AbortController; serverId: string; requirements?: PluginRequirements; clientBundle: string; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index bf652140f5..78a8bb462a 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -24,7 +24,6 @@ import { toPluginTheme } from "../theme"; import { resolvePluginIcon } from "../icons"; import { useInstalledPlugin } from "../registry"; import { PluginRuntimeBoundary } from "../runtime-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { resolvePluginWorkspacePanel } from "./resolution"; @@ -54,10 +53,6 @@ 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; @@ -69,7 +64,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { if (!plugin || !contribution || !workspaceExists) { return ; } - if (!runtime) { + if (!client) { return ; } @@ -110,7 +105,7 @@ 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 36f1e35282..c4ad2ef302 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -8,6 +8,7 @@ function installed(): InstalledPlugin { id: "review", serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), cleanup: () => {}, settingsScreens: [], 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..e9e76fe1ad 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, }; 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 d7deff40a7..316e8e03bf 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,11 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { selectAgentTurnPresentation, 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"; @@ -53,6 +57,50 @@ 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(); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index 1bdf3ea3de..dac4ec5d28 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -134,7 +134,7 @@ export class AgentDirectoryReplica { if (!this.members.has(agentId)) this.advance(agentId); } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) this.storeProjection.remove(agentId); + if (!nextIds.has(agentId)) this.storeProjection.removeFromDirectory(agentId); } this.members.clear(); this.pendingCacheReads.clear(); diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 5fbeb3d3e9..9f64a4fc60 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -22,6 +22,8 @@ import { type DirectoryCheckpointStorage, } from "./index"; +import { subscriptionFixture } from "../subscription-fixture"; + type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; @@ -123,6 +125,25 @@ class FakeDirectoryClient { }; } + 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()); + }); + } + getLastServerInfoMessage(): null { return null; } @@ -304,7 +325,7 @@ 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(); @@ -351,6 +372,14 @@ describe("DirectorySync session readiness", () => { }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + directory.setDemand({}, true); + await directory.refreshDemand(); directory.acceptWorkspaces([workspace]); commits.length = 0; @@ -652,6 +681,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({ @@ -735,10 +766,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; @@ -843,14 +871,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 }, }); @@ -1045,8 +1073,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: { @@ -1100,7 +1129,7 @@ describe("DirectorySync session readiness", () => { 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(); @@ -1130,12 +1159,9 @@ 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(); }); }); @@ -1229,6 +1255,14 @@ it("fills every cached workspace beneath live updates received during the SQLite 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; diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index a490421ceb..51e8b37397 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 { @@ -40,6 +35,9 @@ import type { } 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 = [ { key: "updated_at", direction: "desc" }, @@ -58,7 +56,6 @@ function resolveAgentNextPage(pageInfo: AgentPageInfo): { interface AgentSnapshot { entries: FetchAgentsEntry[]; subscriptionId: string | null; - legacy: boolean; syncMode?: "snapshot" | "changes"; syncCursor?: DirectoryCursor; syncRemovals: Array<{ id: string; seq: number }>; @@ -146,7 +143,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; @@ -185,79 +184,11 @@ 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.persistCheckpoint(); - } - }), - 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.applyWorkspaceDelta(message.payload); - this.noteLiveCursor("workspaces", message.payload); - this.persistCheckpoint(); - } - }), - 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.applyWorkspaceDelta(message.payload); - this.noteLiveCursor("projects", message.payload); - this.persistCheckpoint(); - } - }), - 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.applyWorkspaceDelta(delta); - this.persistCheckpoint(); - } - }), - client.on("agent_deleted", (message) => { - if (message.type === "agent_deleted" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.remove(message.payload.agentId); - this.persistCheckpoint(); - } - }), - 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.persistCheckpoint(); - } - }), - ]; - this.unsubscribe = () => { - for (const unsubscribe of subscriptions) unsubscribe(); - }; if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined); return true; } @@ -266,6 +197,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); @@ -282,6 +214,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); } @@ -290,13 +223,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; } @@ -307,6 +292,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 && @@ -449,32 +435,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"); } @@ -482,36 +448,8 @@ export class DirectorySync { if (completion.kind === "stale") { throw new DirectoryRefreshSupersededError("agent completion was superseded"); } - if (completion.snapshot.legacy) { - const workspaces = buildLegacyWorkspaces(completion.snapshot.entries); - const previous = this.readWorkspaceState(); - this.workspaces.commitSnapshot( - { - workspaces, - projects: new Map( - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace).map( - (project) => [project.projectId, project], - ), - ), - }, - [], - ); - const next = this.workspaces.snapshot(); - this.persistWorkspaceChanges( - previous, - new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]), - new Set([...previous.projects.keys(), ...next.projects.keys()]), - ); - } - 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.persistCheckpoint(); this.callbacks.markAgentReady(); @@ -549,13 +487,8 @@ export class DirectorySync { 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.applyWorkspaceDelta(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); } @@ -589,12 +522,25 @@ export class DirectorySync { let cursor: string | null = null; let subscribe = initialSubscribe; while (true) { - const payload = await client.fetchWorkspaces({ + const query: Parameters[0] = { sort: [{ key: "activity_at", direction: "desc" }], - ...(subscribe ? { subscribe: {} } : {}), page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, ...(supportsDirectorySync ? { sync: this.readCursors().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); applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null); if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; @@ -672,15 +618,29 @@ 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 }), 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; @@ -731,7 +691,6 @@ export class DirectorySync { this.agentTransactions.begin(connection.source, () => ({ entries: [], subscriptionId: null, - legacy: false, syncRemovals: [], })); } diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index 0566da5aaf..97c8208da5 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -12,6 +12,15 @@ 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 = @@ -65,7 +74,7 @@ export class AgentStoreProjection { agent?: Agent; } { if (delta.kind === "remove") { - this.remove(delta.agentId); + this.removeFromDirectory(delta.agentId); return { agentId: delta.agentId, stoppedRunning: false }; } const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); @@ -141,28 +150,19 @@ export class AgentStoreProjection { useSessionStore.getState().setPendingPermissions(this.serverId, pending); } - remove(agentId: string): void { + removeFromDirectory(agentId: string): void { const store = useSessionStore.getState(); - const removeKey = (current: Map): Map => { - if (!current.has(agentId)) return current; - const next = new Map(current); - next.delete(agentId); - return next; - }; + 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.setAgentTimelineCursor(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; }); - store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); - store.setAgentStreamTail(this.serverId, removeKey); - store.clearAgentStreamHead(this.serverId, agentId); useSessionStore.setState((state) => { if (!state.agentLastActivity.has(agentId)) return state; const agentLastActivity = new Map(state.agentLastActivity); @@ -178,6 +178,18 @@ export class AgentStoreProjection { } } + 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) => { diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index cd6be83fae..7e9f619fc6 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -25,6 +25,8 @@ import { } from "./host-runtime"; import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; +import { subscriptionFixture } from "./subscription-fixture"; + class FakeDaemonClient { private state: ConnectionState = { status: "idle" }; private listeners = new Set<(status: ConnectionState) => void>(); @@ -34,6 +36,7 @@ class FakeDaemonClient { private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = []; public connectCalls = 0; public ensureConnectedCalls = 0; + public connectionVerifications = 0; public reconnectEnabledChanges: boolean[] = []; public fetchAgentsCalls: FetchAgentsOptions[] = []; public fetchAgentsResponses: Array< @@ -58,6 +61,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) => { @@ -108,8 +121,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" }); } @@ -119,8 +133,16 @@ class FakeDaemonClient { return this.state; } - getLastServerInfoMessage(): null { - return null; + public ownedSubscriptions = true; + + getLastServerInfoMessage(): ReturnType { + return { + status: "server_info", + serverId: "srv_test", + hostname: "test", + version: "0.8.0", + features: { ownedSubscriptions: this.ownedSubscriptions }, + }; } subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void { @@ -550,6 +572,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", @@ -1575,6 +1633,8 @@ describe("HostRuntimeStore", () => { 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"); @@ -1952,6 +2012,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 }, @@ -1968,6 +2029,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 }, }); @@ -2006,7 +2068,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); @@ -2019,7 +2081,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 }, }); @@ -2027,83 +2089,7 @@ describe("HostRuntimeStore", () => { expect(useSessionStore.getState().sessions[host.serverId]).toBeUndefined(); }); - it("bootstraps legacy daemons from unscoped agents and creates path-backed workspaces", async () => { - const host = makeHost({ - serverId: "srv_legacy_workspace_daemon", - 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, - connectToDaemon: async ({ host: hostProfile }) => ({ - client: fakeClient as unknown as DaemonClient, - serverId: hostProfile.serverId, - hostname: hostProfile.label ?? null, - }), - getClientId: async () => "cid_test_runtime", - }, - }); - - 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(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", - }), - ]); - - 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" }], @@ -2190,7 +2176,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); @@ -2212,8 +2198,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([]); @@ -2280,7 +2266,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); @@ -2290,7 +2276,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({ @@ -2370,7 +2356,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); @@ -2457,12 +2443,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" }, @@ -2508,7 +2495,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 }); @@ -2576,7 +2563,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); @@ -2749,7 +2736,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", @@ -3051,7 +3038,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", @@ -3132,7 +3119,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); @@ -3150,7 +3137,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); @@ -3159,13 +3146,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 }, }, ]); @@ -3242,7 +3229,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); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 0dd8bf776e..37154eab3e 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -722,8 +722,8 @@ export class HostRuntimeController { await this.runProbeCycleNow(); } - ensureConnected(): void { - this.activeClient?.ensureConnected(); + ensureConnected(options?: { verify?: boolean }): void { + this.activeClient?.ensureConnected(options); } markAgentDirectorySyncLoading(): void { @@ -1244,56 +1244,51 @@ 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; + 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, }); + try { + await client.close(); + } catch { + /* Preserve the compatibility/connection error. */ + } + }; + 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; + this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), + ...this.buildAgentDirectoryStatusPatch(), + client, + }); + }); + } catch (error) { + await failConnection(error); } } @@ -2325,9 +2320,9 @@ export class HostRuntimeStore { return earliestServerId; } - ensureConnectedAll(): void { + ensureConnectedAll(options?: { verify?: boolean }): void { for (const controller of this.controllers.values()) { - controller.ensureConnected(); + controller.ensureConnected(options); } } @@ -2339,7 +2334,7 @@ export class HostRuntimeStore { return; } - this.ensureConnectedAll(); + this.ensureConnectedAll({ verify: true }); } runProbeCycleNow(serverId?: string): Promise { @@ -2397,14 +2392,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( diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index df6c3a0aa0..e4e759bc15 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -110,6 +110,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( @@ -501,8 +507,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}`, { @@ -513,6 +518,22 @@ 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); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index c1db2f8418..4a6e284b32 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -124,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, @@ -435,8 +434,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 }; @@ -527,8 +524,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 }; 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/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index ca189cf7b4..ba858297f4 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,3 +1,9 @@ +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"; @@ -12,6 +18,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; +import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport"; +import { ScrollView } from "@/components/ui/scroll-view"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { resolveComposerAttachmentSubmitFormat, @@ -68,7 +76,6 @@ 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 +95,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"; @@ -123,6 +130,8 @@ import { 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 }); @@ -735,7 +744,7 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { if (input.isCompact) { return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }]; } - return [styles.content, styles.contentCentered]; + return [styles.content, styles.contentCentered, { paddingBottom: input.insetBottom }]; } function normalizeBranchDetails( @@ -747,10 +756,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; @@ -759,6 +781,8 @@ interface SubmitDraftInput { provider: AgentProvider; composerState: NewWorkspaceComposerState; supportsForgeSearch: boolean; + resolveClient: () => DaemonClient; + isStillOnCreateScreen: () => boolean; } type NewWorkspaceComposerState = NonNullable< @@ -775,37 +799,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: ( @@ -814,7 +823,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"; @@ -823,12 +832,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, } : { @@ -846,7 +858,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 { @@ -858,11 +870,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; @@ -918,8 +936,20 @@ function buildComposerInitialValues(input: { 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,31 +965,86 @@ 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: { @@ -1021,7 +1106,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { }; } -function submitWorkspaceDraft(input: SubmitDraftInput): void { +function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome { const { serverId, clearDraft, @@ -1035,7 +1120,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({ @@ -1049,7 +1134,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { composerState, initialSetup, }); - useCreateFlowStore.getState().setPending({ + const started = useCreateFlowStore.getState().trySetPending({ serverId, draftId, workspaceId, @@ -1060,6 +1145,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, @@ -1075,6 +1161,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { ...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}), ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), allowEmptyText: true, + agentCreation: input.agentCreation, }); clearDraft("sent"); navigateToWorkspace({ @@ -1082,6 +1169,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { workspaceId, target: submission.target, }); + return "navigated"; } function useNewWorkspaceHostSelector(input: { @@ -1575,10 +1663,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); @@ -1591,6 +1683,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 @@ -1634,7 +1727,7 @@ export function NewWorkspaceScreen({ return () => clearTimeout(timer); }, [pickerSearchQuery]); - const workspace = createdWorkspace; + const { workspace } = creationResult; const client = useHostRuntimeClient(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId); const { @@ -1696,11 +1789,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; @@ -1934,45 +2028,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"); @@ -1995,33 +2061,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, @@ -2043,31 +2105,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); @@ -2077,16 +2157,20 @@ export function NewWorkspaceScreen({ }, [ composerState, - draftId, + draftContextScopeKey, + creationIdentity, chatDraft.clear, + draftKey, ensureWorkspace, forkDraftSetup, + isStillOnCreateScreen, launchTarget, selectedServerId, supportsForgeSearch, t, toast, updateFormPreferences, + withConnectedClient, ], ); @@ -2095,12 +2179,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( @@ -2128,9 +2213,20 @@ export function NewWorkspaceScreen({ 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); @@ -2139,6 +2235,7 @@ export function NewWorkspaceScreen({ } }, [ ensureWorkspace, + isStillOnCreateScreen, launchTarget, queryClient, selectedServerId, @@ -2186,9 +2283,13 @@ export function NewWorkspaceScreen({ ], ); + const contentBottomInset = useMemo( + () => (isCompact ? insets.bottom : HEADER_INNER_HEIGHT + theme.spacing[6]), + [isCompact, insets.bottom, theme.spacing], + ); const contentStyle = useMemo( - () => getContentStyle({ isCompact, insetBottom: insets.bottom }), - [isCompact, insets.bottom], + () => getContentStyle({ isCompact, insetBottom: contentBottomInset }), + [isCompact, contentBottomInset], ); const agentControlsWithDisabled = useMemo( @@ -2274,85 +2375,97 @@ export function NewWorkspaceScreen({ return ( - + - - {t("newWorkspace.title")} - - {formStack} - {isTerminalLaunch ? ( - - ) : ( - - )} - {errorMessage ? {errorMessage} : null} + + + + {t("newWorkspace.title")} + + {formStack} + + {isTerminalLaunch ? ( + + ) : ( + + )} + {errorMessage ? {errorMessage} : null} + - + ); } const animatedStaticStyles = RNStyleSheet.create({ centered: { + flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH, }, + form: { + flexShrink: 1, + }, + setup: { + flexGrow: 0, + flexShrink: 1, + }, }); const styles = StyleSheet.create((theme) => ({ @@ -2368,7 +2481,6 @@ const styles = StyleSheet.create((theme) => ({ }, contentCentered: { justifyContent: "center", - paddingBottom: HEADER_INNER_HEIGHT + theme.spacing[6], }, contentCompact: { justifyContent: "flex-end", 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/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/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/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index ffe07c308e..23c7815294 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"; @@ -72,8 +66,16 @@ import { ICON_SIZE } from "@/styles/theme"; import type { Theme } from "@/styles/theme"; import { getProviderIcon } 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); @@ -553,14 +555,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 +574,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 +620,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({ timeout: 1500 })), + serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "", + version: daemonClient.getLastServerInfoMessage()?.version ?? null, + }), }, ); void waitForDaemonRestart(restartRequest); @@ -691,11 +640,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 +666,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 +703,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 +730,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 +763,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({ timeout: 1500 })), + 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 +801,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,6 +833,15 @@ 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 +1179,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 +1247,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 +1273,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 })} diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 8ec1e4df6b..f02cfa80a8 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 */ @@ -103,7 +104,11 @@ function plugin(enabled = true): PluginListItem { 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 => []), diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx index 086b1f1a53..60a797b951 100644 --- a/packages/app/src/screens/settings/plugins-page.tsx +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -227,11 +227,21 @@ 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(); + 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; diff --git a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx b/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx index 7b772b1d4c..a9b7656963 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx @@ -5,6 +5,7 @@ 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 { EXPLORER_TAB_RAIL_INSET } from "@/components/explorer-sidebar-layout"; import type { DraggableListDragHandleProps, DraggableRenderItemInfo, @@ -405,7 +406,7 @@ const styles = StyleSheet.create((theme) => ({ scrollContent: { flexDirection: "row", alignItems: "center", - paddingHorizontal: 4, + paddingHorizontal: EXPLORER_TAB_RAIL_INSET, }, trailingAccessory: { marginRight: 4, 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 5c183dc94c..286030fd86 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -88,7 +88,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 + @@ -740,6 +740,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]), ); @@ -889,6 +890,8 @@ function TabChip({ { @@ -874,7 +871,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId={workspaceId} isRouteFocused={isRouteFocused ?? navigationFocused} recoveryRequested={recoveryRequested ?? false} - recoveryAgentId={recoveryAgentId ?? null} /> ); }); @@ -1539,7 +1535,6 @@ function WorkspaceScreenContent({ workspaceId, isRouteFocused, recoveryRequested, - recoveryAgentId, }: WorkspaceScreenContentProps) { const { t } = useTranslation(); const _insets = useSafeAreaInsets(); @@ -1665,7 +1660,6 @@ function WorkspaceScreenContent({ const workspaceRecovery = useWorkspaceRecovery({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, - agentId: recoveryAgentId, enabled: shouldInspectWorkspaceRecovery( hasHydratedWorkspaces, workspaceDescriptor, 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/session-store.ts b/packages/app/src/stores/session-store.ts index 3f1ae6ffcf..dd5469df26 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, @@ -366,7 +366,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; @@ -442,7 +442,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 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 +164,7 @@ interface RetargetTabInLayoutResult { } interface ConvertDraftToAgentInLayoutInput { + explorerSidebarPaneId: string | null; layout: WorkspaceLayout; tabId: string; agentId: string; @@ -179,7 +183,7 @@ interface ReorderFocusedPaneTabsInLayoutInput { interface CloseTabInLayoutInput { layout: WorkspaceLayout; tabId: string; - preserveEmptyPaneId?: string | null; + explorerSidebarPaneId: string | null; } interface ClosePaneInLayoutInput { @@ -252,7 +256,6 @@ interface ReorderPaneTabsInLayoutInput { export interface WorkspaceTabReconcileState { layout: WorkspaceLayout; pinnedAgentIds?: ReadonlySet | null; - pendingAgentIds?: ReadonlySet | null; hiddenAgentIds?: ReadonlySet | null; explorerSidebarPaneId: string | null; } @@ -262,7 +265,6 @@ export interface WorkspaceTabSnapshot { terminalsHydrated: boolean; activeAgentIds: Iterable; autoOpenAgentIds: Iterable; - knownAgentIds: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingTerminalCreate?: boolean; @@ -1159,14 +1161,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), }; } @@ -1472,8 +1514,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 +1561,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 +1591,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 +1610,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 +1693,7 @@ export function retargetTabInLayout( closeTabInLayout({ layout: input.layout, tabId: input.tabId, + explorerSidebarPaneId: input.explorerSidebarPaneId, }) ?? input.layout; return { layout: @@ -1808,6 +1857,7 @@ export function convertDraftToAgentInLayout( closeTabInLayout({ layout: input.layout, tabId: input.tabId, + explorerSidebarPaneId: input.explorerSidebarPaneId, }) ?? input.layout; return { layout: @@ -2238,17 +2288,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 +2327,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 +2337,7 @@ function collapseStaleEntityTabs(input: { closeTabInLayout({ layout: nextLayout, tabId: tab.tabId, + explorerSidebarPaneId: input.explorerSidebarPaneId, }) ?? nextLayout; } if ( @@ -2304,6 +2349,7 @@ function collapseStaleEntityTabs(input: { closeTabInLayout({ layout: nextLayout, tabId: tab.tabId, + explorerSidebarPaneId: input.explorerSidebarPaneId, }) ?? nextLayout; } } @@ -2369,43 +2415,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 +2423,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 +2441,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 +2483,7 @@ export function reconcileWorkspaceTabs( closeTabInLayout({ layout: nextLayout, tabId: tab.tabId, + explorerSidebarPaneId: state.explorerSidebarPaneId, }) ?? nextLayout; } } @@ -2482,6 +2493,7 @@ export function reconcileWorkspaceTabs( snapshot, visibleAgentIds, knownTerminalIds, + explorerSidebarPaneId: state.explorerSidebarPaneId, }); nextLayout = addMissingEntityTabs({ @@ -2494,14 +2506,6 @@ export function reconcileWorkspaceTabs( explorerSidebarPaneId: state.explorerSidebarPaneId, }); - nextLayout = seedDraftForEmptyWorkspace({ - layout: nextLayout, - snapshot, - activeAgentIds, - knownTerminalIds, - explorerSidebarPaneId: state.explorerSidebarPaneId, - }); - if (reconciledFocusedTabId) { nextLayout = focusTabInLayout({ @@ -2513,5 +2517,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..ae916188c5 --- /dev/null +++ b/packages/app/src/stores/workspace-layout-storage.ts @@ -0,0 +1,114 @@ +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(), + // 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..37076a4a72 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -24,6 +24,7 @@ import { collectAllPanes, collectAllTabs, createWorkspaceLayoutStore, + observeOpenWorkspaceAgentIds, createDefaultLayout, createWorkspaceLayoutWithExplorerSidebar, findPaneById, @@ -73,6 +74,39 @@ 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"]]); +}); + function useWorkspaceLayoutIds(...values: string[]) { workspaceLayoutIds.useValues(values); } @@ -450,7 +484,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 +497,7 @@ describe("workspace-layout-store version 2 migration", () => { "explorerPaneIdByWorkspace", "explorerSidebarWidthByWorkspace", "layoutByWorkspace", + "pinnedAgentIdsByWorkspace", "sidePaneIdByWorkspace", "splitSizesByWorkspace", ]); @@ -1084,6 +1119,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 +1630,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: true, @@ -1497,7 +1643,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"], }); @@ -1580,7 +1725,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,6 +3087,7 @@ describe("workspace-layout-store actions", () => { expect(persisted).toEqual({ layoutByWorkspace: { [workspaceKey]: layout }, + pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: currentState.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: {}, @@ -3082,6 +3227,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 +3244,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 +3283,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", () => { @@ -3166,6 +3320,7 @@ describe("workspace-layout-store actions", () => { expect(partialize).toBeTypeOf("function"); expect(partialize?.(state)).toEqual({ layoutByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, explorerPaneIdByWorkspace: {}, @@ -3258,7 +3413,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 +3468,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3346,7 +3499,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3362,7 +3514,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3372,121 +3523,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 +3654,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 +3680,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 +3706,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3571,7 +3727,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["child-agent"], autoOpenAgentIds: [], - knownAgentIds: ["child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3609,7 +3764,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["term-script", "term-manual"], standaloneTerminalIds: ["term-manual"], hasActivePendingDraftCreate: false, @@ -3629,7 +3783,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["term-script"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3675,7 +3828,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 +3843,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3706,7 +3858,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3717,13 +3868,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 +3881,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 +3894,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3757,6 +3905,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 +3982,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["first-agent", "second-agent"], autoOpenAgentIds: ["first-agent", "second-agent"], - knownAgentIds: ["first-agent", "second-agent"], standaloneTerminalIds: [], }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 974b8c6964..31fa370ca7 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, @@ -107,7 +108,6 @@ interface WorkspaceLayoutStore { splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; pinnedAgentIdsByWorkspace: Record>; - pendingAgentIdsByWorkspace: Record>; hiddenAgentIdsByWorkspace: Record>; focusRestorationByWorkspace: Record; explorerSidebarPaneIdByWorkspace: Record; @@ -130,7 +130,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 +175,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 +523,7 @@ function ensurePersistedExplorerSidebarPane(input: { paneId: split.paneId, hidden: true, }); - const seededLayout = restoreEmptyPanesInLayout( + const seededLayout = restoreWorkspaceLayout( stripEphemeralTabsFromLayout(hiddenLayout ?? split.layout), split.paneId, ); @@ -764,7 +652,6 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, - pendingAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, @@ -830,13 +717,6 @@ export function createWorkspaceLayoutStore( normalizedTarget.agentId, ) : state.pinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace: shouldPinAgent - ? addAgentIdToWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedTarget.agentId, - ) - : state.pendingAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: input.parentTabId @@ -1000,14 +880,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 +1041,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 +1093,6 @@ export function createWorkspaceLayoutStore( { layout: currentLayout, pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, - pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, explorerSidebarPaneId, }, @@ -1223,11 +1103,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 +1130,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 +1291,7 @@ export function createWorkspaceLayoutStore( } const restoredLayout = normalizedToPaneId === explorerSidebarPaneId - ? restoreEmptyPanesInLayout(nextLayout, explorerSidebarPaneId) + ? restoreWorkspaceLayout(nextLayout, explorerSidebarPaneId) : nextLayout; const normalizedNextLayout = keepWorkspaceFocusOutOfExplorerSidebar( restoredLayout, @@ -1676,11 +1552,6 @@ export function createWorkspaceLayoutStore( delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey]; return { pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), }; } @@ -1692,11 +1563,6 @@ export function createWorkspaceLayoutStore( ...state.pinnedAgentIdsByWorkspace, [normalizedWorkspaceKey]: nextPinnedAgentIds, }, - pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), }; }); }, @@ -1756,7 +1622,6 @@ 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 || @@ -1774,8 +1639,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 } = @@ -1791,7 +1654,6 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace, explorerSidebarWidthByWorkspace, pinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace, hiddenAgentIdsByWorkspace, focusRestorationByWorkspace, explorerSidebarPaneIdByWorkspace, @@ -1817,6 +1679,12 @@ 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, @@ -1836,25 +1704,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 ?? @@ -1874,6 +1750,34 @@ export function createWorkspaceLayoutStore( export const useWorkspaceLayoutStore = createWorkspaceLayoutStore(); +/** Observe open chats independently of which workspace views are mounted. */ +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/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index ea863ea37a..96badfd354 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -1,5 +1,10 @@ +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; -import { providerSubagentKey, useProviderSubagentStore } from "./provider-store"; +import { + observeProviderSubagentTimeline, + providerSubagentKey, + useProviderSubagentStore, +} from "./provider-store"; const SERVER_ID = "server-1"; const PARENT_ID = "parent-1"; @@ -41,6 +46,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 +109,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 +331,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 +354,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 +381,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 +390,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 +428,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 +437,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 +460,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 +486,244 @@ 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); + }); +}); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index 15a9eb9b71..86e857d774 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -3,30 +3,25 @@ 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 { @@ -112,7 +107,8 @@ const EMPTY_TIMELINE: ProviderSubagentTimelineState = { epoch: null, lastSeq: 0, hasOlder: false, - rows: new Map(), + cursor: undefined, + needsRefresh: false, }; function providerSubagentTerminalEvent( @@ -130,68 +126,23 @@ 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(), @@ -229,10 +180,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 +205,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 +226,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 +264,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/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/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index e01b0f5ee6..f77e95b8c6 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -51,8 +51,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 }), diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 13897f8454..9e57142846 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -1901,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({ @@ -2173,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", () => { @@ -2212,7 +2212,6 @@ describe("processTimelineResponse", () => { expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ "user_message", "assistant_message", - "assistant_message", "tool_call", ]); }); @@ -2777,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( @@ -2792,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({ @@ -2815,8 +2813,7 @@ describe("processTimelineResponse", () => { }); expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ - "First paragraph.", - "Second paragraph.", + "First paragraph.\n\nSecond paragraph.", ]); }); @@ -3980,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: [ @@ -3992,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({ @@ -4009,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( @@ -4032,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), @@ -4057,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;", }); }); @@ -4175,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..ed1c9e71c7 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -1,9 +1,12 @@ 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 ViewedTimelineSyncPorts, } from "./viewed-timeline-sync"; interface Deferred { @@ -35,8 +38,91 @@ interface TimelineFetch { fail(message: string): void; } +test("split panes catch up together before hidden open chats start fetching", async () => { + const world = new TimelineWorld(); + world.sync.replaceOpenAgentIds(["a-hidden", "y-visible", "z-visible"]); + 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.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]); + 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.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]); + 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.sync.replaceOpenAgentIds(["a-hidden", "z-visible"]); + 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.sync.replaceOpenAgentIds(["a-hidden", "b-hidden", "z-visible"]); + 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(); @@ -48,8 +134,8 @@ class TimelineWorld { 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 +143,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 +202,14 @@ class TimelineWorld { }, }); + private openAgentIds = new Set(); + + show(sourceId: string, agentIds: string[]): void { + for (const id of agentIds) this.openAgentIds.add(id); + this.sync.replaceOpenAgentIds([...this.openAgentIds]); + this.sync.replaceVisibleAgentIds(sourceId, agentIds); + } + private readonly memberships: MembershipRequest[] = []; private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = []; private readonly fetches: TimelineFetch[] = []; @@ -370,43 +469,147 @@ 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]!; + 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("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.sync.replaceOpenAgentIds(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.replaceOpenAgentIds(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 () => { @@ -447,10 +650,10 @@ test("navigation while disconnected reconnects only the currently visible agent" 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 +664,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 +902,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 +945,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 +993,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 +1010,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); @@ -843,7 +1034,7 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5)); }); -test("disconnect clears hidden hot agents before reconnecting the visible set", async () => { +test("disconnect restores visible demand and revisiting a closed chat fetches again", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); @@ -874,78 +1065,17 @@ test("disconnect clears hidden hot agents before reconnecting the visible set", reopened.succeed(); const reopenedCatchUp = await world.nextFetch("agent-a"); reopenedCatchUp.respond({ hasNewer: false }); - expect(reopened.agentIds).toEqual(["agent-a", "agent-b"]); + expect(reopened.agentIds).toEqual(["agent-a"]); }); -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 d11084cdf2..8bc2ca83f7 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -308,10 +308,9 @@ 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, @@ -322,7 +321,6 @@ export interface ViewedTimelineSyncPorts { schedule(task: () => void, delayMs: number): () => void; } -export type TimelineDeliveryMode = "legacy" | "selective"; export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying"; export interface ViewedTimelineUiBridge { @@ -334,9 +332,9 @@ export interface ViewedTimelineUiBridge { } export interface ViewedTimelineSync extends ViewedTimelineUiBridge { + replaceOpenAgentIds(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; } @@ -359,8 +357,17 @@ 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, @@ -374,7 +381,8 @@ export function createViewedTimelineOwner(input: { }); return { ...sync, - applyTimelineResponse(payload) { + applyTimelineResponse(receivedPayload) { + const payload = consumeForcedTimelineTailReplacement(receivedPayload, forcedTailReplacements); const accepted = applyAuthoritativeTimelineResponse({ serverId: input.serverId, payload, @@ -399,7 +407,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"; @@ -465,7 +472,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(); @@ -477,46 +484,27 @@ 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[] = []; + let openAgentIds: 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 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(); @@ -564,6 +552,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; @@ -604,6 +593,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed setVisibilityCatchUpError([agentId], error); ports.reportError(error); } + } finally { + startAcknowledgedCatchUps(); } }; @@ -621,6 +612,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: { @@ -629,7 +633,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; } @@ -669,8 +677,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), @@ -678,78 +695,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(); @@ -761,7 +746,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; - membershipNeedsRetry = false; void reconcileMembership(); }; @@ -807,24 +791,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed ports.replaceDemandedAgentIds(desired); membershipGeneration += 1; 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([...openAgentIds, ...visibleAgentIds()])); }; return { @@ -841,61 +812,51 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed getAgentTimelineError(agentId) { return visibilityCatchUpErrors.get(agentId) ?? null; }, + replaceOpenAgentIds(agentIds) { + const next = normalizeAgentIds(agentIds); + if (sameAgentIds(next, openAgentIds)) return; + openAgentIds = next; + publishVisibleMembership(); + }, replaceVisibleAgentIds(sourceId, agentIds) { const normalized = normalizeAgentIds(agentIds); if (normalized.length === 0) sources.delete(sourceId); else sources.set(sourceId, 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; @@ -906,6 +867,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed }, dispose() { disposed = true; + void observation?.handle.release().catch(ports.reportError); + observation = null; cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipNeedsRetry = false; @@ -916,7 +879,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed desired = []; ports.replaceDemandedAgentIds([]); acknowledged = []; - recentlyViewedAgentIds = []; + openAgentIds = []; loadedCache.clear(); cacheLoads.clear(); visibilityCatchUpPending.clear(); 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.ts b/packages/app/src/tool-calls/presentation.ts index e43aacf771..3db13ccbbd 100644 --- a/packages/app/src/tool-calls/presentation.ts +++ b/packages/app/src/tool-calls/presentation.ts @@ -1,3 +1,4 @@ +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"; @@ -31,6 +32,7 @@ export interface ToolCallPresentation { canOpenDetails: boolean; openFilePath: string | null; isPlan: boolean; + planOutcome?: PlanOutcome; } export type ToolCallIconResolver = ( @@ -75,5 +77,14 @@ export function buildToolCallPresentation( canOpenDetails: hasDetails || isLoadingDetails, openFilePath: extractToolCallFilePath(input.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/stream.test.ts b/packages/app/src/types/stream.test.ts index cd05fd0826..f33a9c524b 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 = { @@ -838,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[] = []; @@ -881,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: [], @@ -904,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", () => { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 4ebd0972db..78789fa2d8 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -8,7 +8,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 @@ -711,6 +710,7 @@ export interface AssistantMessageItem { timelineCursor?: TimelinePosition; text: string; timestamp: Date; + /** Display-only fields, assigned after source-item plugin transforms. */ blockGroupId?: string; blockIndex?: number; } @@ -1399,10 +1399,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") @@ -1761,14 +1757,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") { @@ -1797,73 +1785,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. */ @@ -2107,13 +2028,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, @@ -2124,16 +2039,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/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index c8e2479894..0362aa56de 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -187,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 }); - new AgentStoreProjection(serverId).applyDelta({ 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"; 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..aa0dcbd23e 100644 --- a/packages/app/src/utils/assistant-markdown-parser.ts +++ b/packages/app/src/utils/assistant-markdown-parser.ts @@ -1,7 +1,9 @@ +import { trackMarkdownSource } from "@/word-stream"; 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 +12,10 @@ export function createAssistantMarkdownParser(): MarkdownIt { parser.validateLink = (url: string) => url.trim().toLowerCase().startsWith("file://") || defaultValidateLink(url); + if (streaming) { + enableStreamingMarkdown(parser); + } + + trackMarkdownSource(parser); return parser; } diff --git a/packages/app/src/utils/split-markdown-blocks.ts b/packages/app/src/utils/split-markdown-blocks.ts index 636642e12a..7426b88cb9 100644 --- a/packages/app/src/utils/split-markdown-blocks.ts +++ b/packages/app/src/utils/split-markdown-blocks.ts @@ -1,6 +1,8 @@ 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"); export function splitMarkdownBlocks(text: string): string[] { if (text.length === 0) { 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 && /(?(Reveal.begin("")); +const SurfaceContext = createContext(null); + +export function StreamingWords({ stream, children }: { stream: PacedText; children: ReactNode }) { + const [reveal, setReveal] = useState(() => Reveal.begin(stream.text)); + const current = reveal.receive(stream.text, Date.now(), stream.spanMs); + if (current !== reveal) setReveal(current); + return {children}; +} + +export function WordFadeScope({ source, children }: { source: SourceText; children: ReactNode }) { + return {children}; +} + +export function useWordFadeSurface() { + const reveal = useContext(StreamingWordsContext); + const source = useContext(SurfaceContext); + const plain = useMemo(() => source && { text: source.text, ranges: [] }, [source]); + return useMemo(() => { + const text = source?.text ?? reveal.text; + const offsets = source?.offsets ?? Array.from({ length: text.length }, (_, i) => i); + const ranges = reveal.surface(text, offsets, Date.now()); + return ranges.length === 0 && plain ? plain : { text, ranges }; + }, [reveal, source, plain]); +} + +export function useWordStream(text: string, phase: MarkdownPhase): PacedText { + const [model] = useState(() => new WordStream(text)); + const [visible, setVisible] = useState({ + text, + spanMs: model.spanMs, + }); + const lastFrame = useRef(null); + + useEffect(() => { + model.receive(text, phase === "streaming"); + const publish = () => { + setVisible((current) => + current.text === model.text && current.spanMs === model.spanMs + ? current + : { text: model.text, spanMs: model.spanMs }, + ); + }; + publish(); + if (!model.pending) { + lastFrame.current = null; + return; + } + let frame: number; + const tick = (now: number) => { + const elapsed = lastFrame.current === null ? 16 : now - lastFrame.current; + lastFrame.current = now; + model.advance(elapsed); + publish(); + if (model.pending) frame = requestAnimationFrame(tick); + else lastFrame.current = null; + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [model, text, phase]); + + return visible; +} + +export function useWordPacedText(text: string, phase: MarkdownPhase): string { + return useWordStream(text, phase).text; +} + +export { trackMarkdownSource } from "./internal/markdown-source"; diff --git a/packages/app/src/word-stream/internal/host-style.test.ts b/packages/app/src/word-stream/internal/host-style.test.ts new file mode 100644 index 0000000000..026acc432e --- /dev/null +++ b/packages/app/src/word-stream/internal/host-style.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { hostTextStyle } from "./host-style"; + +describe("native fade host layout", () => { + it("keeps paragraph, heading, list and cell geometry on the outer box", () => { + const style = { + marginTop: 8, + width: "100%" as const, + flexShrink: 1, + alignSelf: "flex-start" as const, + paddingHorizontal: 4, + fontSize: 20, + lineHeight: 26, + backgroundColor: "gray", + }; + expect(hostTextStyle(style)).toEqual({ + host: { marginTop: 8, width: "100%", flexShrink: 1, alignSelf: "flex-start" }, + text: { paddingHorizontal: 4, fontSize: 20, lineHeight: 26, backgroundColor: "gray" }, + }); + }); +}); diff --git a/packages/app/src/word-stream/internal/host-style.ts b/packages/app/src/word-stream/internal/host-style.ts new file mode 100644 index 0000000000..ee20326605 --- /dev/null +++ b/packages/app/src/word-stream/internal/host-style.ts @@ -0,0 +1,71 @@ +import type { TextStyle, ViewStyle } from "react-native"; + +/** Move parent-facing geometry to the host; keep the text's box and typography together. */ +export function hostTextStyle(style: TextStyle) { + const { + alignSelf, + aspectRatio, + bottom, + display, + end, + flex, + flexBasis, + flexGrow, + flexShrink, + height, + left, + margin, + marginBottom, + marginEnd, + marginHorizontal, + marginLeft, + marginRight, + marginStart, + marginTop, + marginVertical, + maxHeight, + maxWidth, + minHeight, + minWidth, + position, + right, + start, + top, + width, + zIndex, + ...text + } = style; + const host: ViewStyle = { + alignSelf, + aspectRatio, + bottom, + display, + end, + flex, + flexBasis, + flexGrow, + flexShrink, + height, + left, + margin, + marginBottom, + marginEnd, + marginHorizontal, + marginLeft, + marginRight, + marginStart, + marginTop, + marginVertical, + maxHeight, + maxWidth, + minHeight, + minWidth, + position, + right, + start, + top, + width, + zIndex, + }; + return { host, text }; +} diff --git a/packages/app/src/word-stream/internal/markdown-source.test.ts b/packages/app/src/word-stream/internal/markdown-source.test.ts new file mode 100644 index 0000000000..30de9e3e66 --- /dev/null +++ b/packages/app/src/word-stream/internal/markdown-source.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "vitest"; +import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser"; +const corpus = [ + "first words\r\n\r\nsecond words ", + "visit https://example.com/a%20b and www.example.com then words ", + "![alt words](image.png) following words ", + "same same **same same** same ", + "**bold words** and _italic words_ and ~~strike words~~ ", + "[same words](https://example.com/same) same words ", + "`code words` and `` code ` words `` then ", + "& 😀 café 👨‍👩‍👧‍👦 \\*escaped\\* words ", + "- first words\n\n- same words\n - same words\n\nlast words ", + "| same | same |\n| --- | --- |\n| same | a\\|b |\n", + "heading words\n=====\n\nparagraph words ", + "> same words\n> same words\n\nnext words ", + "before words \nafter words\n\nfence:\n```ts\nlet x = 1;\n```\n\nafter words ", +]; +it.each(corpus)("keeps rendered characters at their source positions: %s", (source) => { + const parser = createAssistantMarkdownParser({ streaming: true }); + + for (let end = 1; end <= source.length; end++) { + const prefix = source.slice(0, end); + for (const token of parser.parse(prefix, {}).flatMap((block) => block.children ?? [])) { + if (!["text", "code_inline"].includes(token.type) || !token.content) continue; + const offsets: number[] = token.meta.wordSource; + expect(offsets.length, `${prefix}: ${token.content}`).toBe(token.content.length); + expect( + offsets.every((offset) => Number.isInteger(offset) && offset >= 0 && offset < end), + `${prefix}: ${token.content} ${offsets}`, + ).toBe(true); + for (let i = 0; i < token.content.length; i++) { + if (/\w/u.test(token.content[i]!) && source[offsets[i]!] !== "&") + expect(source[offsets[i]!], `${prefix}: ${token.content}`).toBe(token.content[i]); + } + } + } +}); diff --git a/packages/app/src/word-stream/internal/markdown-source.ts b/packages/app/src/word-stream/internal/markdown-source.ts new file mode 100644 index 0000000000..e9ae7bd385 --- /dev/null +++ b/packages/app/src/word-stream/internal/markdown-source.ts @@ -0,0 +1,179 @@ +import type MarkdownIt from "markdown-it"; +import type Token from "markdown-it/lib/token.mjs"; + +export interface SourceText { + text: string; + offsets: number[]; +} +const positions = new WeakMap(); + +/** Preserve the parser's source positions before inline syntax is discarded. */ +export function trackMarkdownSource(parser: MarkdownIt): void { + const originals = new WeakMap(); + parser.core.ruler.before("normalize", "word_source_original", (state) => { + const offsets: number[] = []; + for (let i = 0; i < state.src.length; i++) { + offsets.push(i); + if (state.src[i] === "\r" && state.src[i + 1] === "\n") i++; + } + originals.set(state, offsets); + }); + const Base = parser.inline.State; + parser.inline.State = class extends Base { + constructor(...args: ConstructorParameters) { + super(...args); + let pending = this.pending; + let pendingPosition = this.pos; + let offsets: number[] = []; + Object.defineProperty(this, "pending", { + get: () => { + pendingPosition = this.pos; + return pending; + }, + set: (next: string) => { + if (next.startsWith(pending)) { + const addition = next.slice(pending.length); + let start = pendingPosition; + if (this.src[start] === "\\" && addition !== "\\") start++; + if (!this.src.startsWith(addition, start) && this.src.startsWith(addition, start - 1)) + start--; + const literal = this.src.startsWith(addition, start); + offsets.push( + ...Array.from({ length: addition.length }, (_, i) => start + (literal ? i : 0)), + ); + } else offsets = offsets.slice(0, next.length); + pending = next; + }, + }); + const pushPending = this.pushPending.bind(this); + this.pushPending = () => { + const saved = offsets; + const token = pushPending(); + positions.set(token, saved); + offsets = []; + return token; + }; + } + + override push(...args: Parameters["push"]>): Token { + const token = super.push(...args); + const start = this.pos; + let content = token.content; + Object.defineProperty(token, "content", { + enumerable: true, + get: () => content, + set: (next: string) => { + if (!positions.has(token) && next) { + let from = start; + if (token.type === "code_inline") { + from += token.markup.length; + if (this.src[from] === " " && this.src[from + 1] !== " " && next[0] !== " ") from++; + } + positions.set( + token, + Array.from({ length: next.length }, (_, i) => from + i), + ); + } + content = next; + }, + }); + return token; + } + }; + + // markdown-it merges text tokens by keeping the right-hand token. Preserve + // the concatenated mapping through that existing pass, without changing it. + parser.inline.ruler2.before("text_collapse", "word_source_join", (state) => { + for (let i = 0; i + 1 < state.tokens.length; i++) { + const left = state.tokens[i]!; + const right = state.tokens[i + 1]!; + if (left.type === "text" && right.type === "text") { + positions.set(right, [ + ...(positions.get(left) ?? []).slice(0, left.content.length), + ...(positions.get(right) ?? []).slice(0, right.content.length), + ]); + } + } + return true; + }); + + const mappedBlocks = new WeakMap(); + parser.core.ruler.after("inline", "word_source", (state) => { + let cursor = 0; + const lineStarts = [0]; + for (let i = 0; i < state.src.length; i++) if (state.src[i] === "\n") lineStarts.push(i + 1); + for (const block of state.tokens) { + if (block.map) cursor = Math.max(cursor, lineStarts[block.map[0]]!); + if (block.type !== "inline") { + if (block.map && block.nesting === 0) cursor = lineStarts[block.map[1]] ?? state.src.length; + continue; + } + if (block.map) cursor = Math.max(cursor, lineStarts[block.map[0]]!); + const sourceOffsets: number[] = []; + // Block parsing removes indentation, quote/list markers and table escapes. + // Inline contents occur in source order, including repeated table cells. + for (const char of block.content.split("")) { + const next = state.src.indexOf(char, cursor); + if (next < 0) throw new Error("Markdown inline content lost its source position"); + sourceOffsets.push(next); + cursor = next + 1; + } + const rendered: SourceText = { text: "", offsets: [] }; + for (const token of block.children ?? []) { + const local = positions.get(token) ?? []; + const offsets = local + .slice(0, token.content.length) + .map((offset) => originals.get(state)![sourceOffsets[offset]!]!); + token.meta = { ...token.meta, wordSource: offsets }; + if ( + token.type === "text" || + token.type === "streaming_link_text" || + token.type === "code_inline" + ) { + rendered.text += token.content; + rendered.offsets.push(...offsets); + } + } + mappedBlocks.set(block, rendered); + } + }); + // Linkify replaces text tokens. Transfer the same character positions to its + // split output; the link wrapper itself never owns a fade. + parser.core.ruler.after("linkify", "word_source_links", (state) => { + for (const block of state.tokens) { + const mapped = mappedBlocks.get(block); + if (!mapped) continue; + let cursor = 0; + for (const token of block.children ?? []) { + if (!["text", "streaming_link_text", "code_inline"].includes(token.type)) continue; + const mappedToken = mapLinkifiedText(mapped, token.content, cursor); + const offsets = mappedToken.offsets; + cursor = mappedToken.cursor; + token.meta = { ...token.meta, wordSource: offsets }; + } + } + }); +} + +function mapLinkifiedText(mapped: SourceText, content: string, cursor: number) { + const offsets: number[] = []; + for (let i = 0; i < content.length; i++) { + const encoded = mapped.text.slice(cursor).match(/^(?:%[0-9a-f]{2})+/i)?.[0]; + if (encoded && content[i] !== "%") { + let decoded: string; + try { + decoded = decodeURIComponent(encoded); + } catch { + decoded = encoded; + } + if (content.startsWith(decoded, i)) { + offsets.push(...Array.from({ length: decoded.length }, () => mapped.offsets[cursor]!)); + cursor += encoded.length; + i += decoded.length - 1; + continue; + } + } + offsets.push(mapped.offsets[cursor++]!); + } + return { offsets, cursor }; +} diff --git a/packages/app/src/word-stream/internal/model.test.ts b/packages/app/src/word-stream/internal/model.test.ts new file mode 100644 index 0000000000..5adf764361 --- /dev/null +++ b/packages/app/src/word-stream/internal/model.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { WordStream } from "./model"; + +describe("word streaming", () => { + it("buffers a network fragment until the word is complete", () => { + const stream = new WordStream(""); + stream.receive("hel", true); + expect(stream.advance(100)).toBe(""); + stream.receive("hello world ", true); + expect(stream.advance(16)).toBe("hello "); + expect(stream.advance(100)).toBe("hello world "); + }); + + it("paces a burst by words instead of releasing the network chunk", () => { + const stream = new WordStream(""); + stream.receive("one two three four five six ", true); + expect(stream.advance(16)).toBe("one "); + expect(stream.advance(16)).toBe("one "); + expect(stream.advance(17)).toBe("one two "); + expect(stream.advance(40)).toBe("one two three "); + }); + + it("does not accumulate idle time as credit for the next burst", () => { + const stream = new WordStream(""); + stream.advance(5000); + stream.receive("one two three ", true); + expect(stream.advance(16)).toBe("one "); + }); + + it("preserves history on first paint and drains the final word on completion", () => { + const stream = new WordStream("history "); + expect(stream.text).toBe("history "); + stream.receive("history 👨‍👩‍👧‍👦 café", true); + expect(stream.advance(16)).toBe("history 👨‍👩‍👧‍👦 "); + stream.receive("history 👨‍👩‍👧‍👦 café", false); + expect(stream.advance(100)).toBe("history 👨‍👩‍👧‍👦 café"); + expect(stream.pending).toBe(false); + }); + + it("preserves whitespace, Markdown, and Unicode exactly", () => { + const text = "**café**\n\n- 🇺🇸\t[link](https://paseo.sh) العربية "; + const stream = new WordStream(""); + for (let end = 1; end <= text.length; end++) { + stream.receive(text.slice(0, end), true); + const visible = stream.advance(16); + expect(text.startsWith(visible)).toBe(true); + expect(visible === "" || /\s$/u.test(visible)).toBe(true); + } + stream.receive(text, false); + stream.advance(250); + expect(stream.text).toBe(text); + }); + + it("replaces edited text without replaying the old buffer", () => { + const stream = new WordStream("old text"); + stream.receive("new", true); + expect(stream.text).toBe("new"); + expect(stream.pending).toBe(false); + }); + + it("keeps a large burst paced until every word is visible", () => { + const text = "word ".repeat(200); + const stream = new WordStream(""); + stream.receive(text, true); + let previous = ""; + for (let frame = 0; frame < 800; frame++) { + const visible = stream.advance(1000 / 60); + expect(visible.length - previous.length).toBeLessThanOrEqual(5); + previous = visible; + } + expect(stream.text).toBe(text); + expect(stream.pending).toBe(false); + }); + + it("paces queued words even when the provider finishes immediately after its last chunk", () => { + const stream = new WordStream(""); + stream.receive("one two three four", false); + expect(stream.text).toBe(""); + expect(stream.advance(16)).toBe("one "); + expect(stream.advance(250)).toBe("one two "); + expect(stream.advance(250)).toBe("one two three "); + expect(stream.advance(250)).toBe("one two three four"); + expect(stream.pending).toBe(false); + }); +}); + +it("reports how long each released word owns the front", () => { + const stream = new WordStream(""); + stream.receive("one two three four five six ", true); + stream.advance(16); + // Five words remain: the front moves faster, but never faster than a frame. + expect(stream.spanMs).toBe(33); + stream.receive("one two three four five six ", false); + while (stream.pending) stream.advance(250); + expect(stream.spanMs).toBe(60); +}); + +it("never turns a delayed frame into a simultaneous word burst", () => { + const stream = new WordStream(""); + stream.receive("one two three four five six seven eight nine ten ", true); + expect(stream.advance(250)).toBe("one "); + expect(stream.advance(250)).toBe("one two "); +}); diff --git a/packages/app/src/word-stream/internal/model.ts b/packages/app/src/word-stream/internal/model.ts new file mode 100644 index 0000000000..e3ccecaa51 --- /dev/null +++ b/packages/app/src/word-stream/internal/model.ts @@ -0,0 +1,78 @@ +/** Owns the received text and the word boundary the reader has reached. */ +export class WordStream { + private target: string; + private visibleEnd: number; + private scanFrom: number; + private readyEnds: number[] = []; + private nextWord = 0; + private nextInMs = 0; + private interval = 60; + + constructor(text: string) { + this.target = text; + this.visibleEnd = text.length; + this.scanFrom = text.length; + } + + get text(): string { + return this.target.slice(0, this.visibleEnd); + } + + get pending(): boolean { + return this.nextWord < this.readyEnds.length; + } + + /** + * How long the most recently released word owns the reveal front before the + * next word follows it. Its characters are spread across this interval so the + * front never restarts at a word boundary. + */ + get spanMs(): number { + return this.interval; + } + + receive(text: string, streaming: boolean): void { + if (!text.startsWith(this.target)) { + this.target = text; + this.visibleEnd = text.length; + this.scanFrom = text.length; + this.readyEnds = []; + this.nextWord = 0; + this.nextInMs = 0; + return; + } + this.target = text; + if (!this.pending) { + this.readyEnds = []; + this.nextWord = 0; + this.nextInMs = 0; + } + // Only rescan the unfinished suffix. Network boundaries never finish a word. + const suffix = text.slice(this.scanFrom); + const words = /\S+\s+|\s+/gu; + let end = this.scanFrom; + for (const word of suffix.matchAll(words)) { + end = this.scanFrom + word.index + word[0].length; + this.readyEnds.push(end); + } + this.scanFrom = end; + if (!streaming && end < text.length) { + this.readyEnds.push(text.length); + this.scanFrom = text.length; + } + } + + advance(elapsedMs: number): string { + if (!this.pending) return this.text; + this.nextInMs -= Math.min(250, Math.max(0, elapsedMs)); + if (this.pending && this.nextInMs <= 0) { + this.visibleEnd = this.readyEnds[this.nextWord++]!; + const backlog = this.readyEnds.length - this.nextWord; + // A late frame is not credit to reveal several words at once. + this.interval = Math.max(33, Math.min(60, 150 / Math.max(1, backlog))); + this.nextInMs = this.interval; + } + if (!this.pending) this.nextInMs = 0; + return this.text; + } +} diff --git a/packages/app/src/word-stream/internal/reveal.test.ts b/packages/app/src/word-stream/internal/reveal.test.ts new file mode 100644 index 0000000000..1efd869001 --- /dev/null +++ b/packages/app/src/word-stream/internal/reveal.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from "vitest"; +import { Reveal } from "./reveal"; + +it("keeps a word's fade time when its rendered position or surface changes", () => { + // Five whitespace-delimited words share the 60ms interval: 12ms each. + const reveal = Reveal.begin("").receive("| same | same |\n", 1000, 60); + // The two identical words become separate table cells after parsing. + expect(reveal.surface("same", [2, 3, 4, 5], 1080)).toEqual([ + { start: 0, end: 4, startedAt: 1012, spanMs: 12 }, + ]); + expect(reveal.surface("same", [9, 10, 11, 12], 1080)).toEqual([ + { start: 0, end: 4, startedAt: 1036, spanMs: 12 }, + ]); + expect(reveal.surface("same", [2, 3, 4, 5], 1230)).toEqual([]); +}); + +it("keeps previous paragraphs opaque while a new word arrives", () => { + let reveal = Reveal.begin("history ").receive("history same ", 1000, 60); + reveal = reveal.receive("history same same ", 1300, 60); + expect(reveal.surface("same", [8, 9, 10, 11], 1300)).toEqual([]); + expect(reveal.surface("same", [13, 14, 15, 16], 1300)).toEqual([ + { start: 0, end: 4, startedAt: 1300, spanMs: 60 }, + ]); +}); + +it("does not turn hydration or replacement into a fade", () => { + const reveal = Reveal.begin("history").receive("replacement", 1000, 60); + expect( + reveal.surface( + "replacement", + Array.from({ length: 11 }, (_, i) => i), + 1000, + ), + ).toEqual([]); +}); + +it("fades text next to synthetic Markdown line breaks", () => { + const reveal = Reveal.begin("").receive("word \n", 1000, 60); + expect(reveal.surface("word\n", [0, 1, 2, 3, -1], 1050)).toEqual([ + { start: 0, end: 4, startedAt: 1000, spanMs: 60 }, + ]); +}); + +it("continues the front across word boundaries instead of restarting it", () => { + // Word interval 60ms: "one" owns 1000–1060, "two" owns 1060–1120. + let reveal = Reveal.begin("").receive("one ", 1000, 60); + reveal = reveal.receive("one two ", 1060, 60); + const [one, two] = reveal.surface("one two", [0, 1, 2, 3, 4, 5, 6], 1070); + expect(one).toEqual({ start: 0, end: 3, startedAt: 1000, spanMs: 60 }); + expect(two).toEqual({ start: 4, end: 7, startedAt: 1060, spanMs: 60 }); + // The last character of "one" starts at 1040; the first of "two" at 1060. + expect(one!.startedAt + (one!.spanMs * 2) / 3).toBeLessThan(two!.startedAt); +}); + +it("never lets an early frame start a word before the previous word's run ends", () => { + let reveal = Reveal.begin("").receive("one ", 1000, 60); + reveal = reveal.receive("one two ", 1045, 60); + expect(reveal.ranges.map((range) => range.startedAt)).toEqual([1000, 1060]); +}); + +it("keeps a run's timeline when inline formatting splits it across surfaces", () => { + const reveal = Reveal.begin("").receive("ab**cd** ", 1000, 80); + // The source word is `ab**cd**` (8 chars, 10ms each); the bold piece starts at + // source offset 4 and is rendered separately from `ab`. + expect(reveal.surface("ab", [0, 1], 1010)).toEqual([ + { start: 0, end: 2, startedAt: 1000, spanMs: 20 }, + ]); + expect(reveal.surface("cd", [4, 5], 1010)).toEqual([ + { start: 0, end: 2, startedAt: 1040, spanMs: 20 }, + ]); +}); + +it("shares one interval between words that arrive together, in order", () => { + const reveal = Reveal.begin("").receive("one two ", 1000, 60); + expect(reveal.ranges).toEqual([ + { start: 0, end: 3, startedAt: 1000, spanMs: 30 }, + { start: 4, end: 7, startedAt: 1030, spanMs: 30 }, + ]); +}); diff --git a/packages/app/src/word-stream/internal/reveal.ts b/packages/app/src/word-stream/internal/reveal.ts new file mode 100644 index 0000000000..3b3c7c6676 --- /dev/null +++ b/packages/app/src/word-stream/internal/reveal.ts @@ -0,0 +1,96 @@ +/** + * A run of characters that is still fading in. + * + * Character j of n in the run starts fading at `startedAt + spanMs * j / n` + * and takes FADE_DURATION_MS to reach full opacity. Runs are ordered by source + * position and their start times never decrease along that order, so at any + * instant opacity is non-increasing in reading order: one front, not a fade + * per word. + */ +export interface FadeRange { + start: number; + end: number; + startedAt: number; + spanMs: number; +} +export const FADE_DURATION_MS = 150; + +function settled(range: FadeRange, now: number): boolean { + return now - (range.startedAt + range.spanMs) >= FADE_DURATION_MS; +} + +/** Message-owned reveal history survives changes in Markdown/view structure. */ +export class Reveal { + private constructor( + readonly text: string, + readonly ranges: readonly FadeRange[], + ) {} + static begin(text: string): Reveal { + return new Reveal(text, []); + } + /** + * `spanMs` is how long the newly arrived text owns the front before the next + * release. When several words arrive together they share it in order. + */ + receive(text: string, now: number, spanMs: number): Reveal { + if (text === this.text) return this; + if (!text.startsWith(this.text)) return Reveal.begin(text); + const ranges = this.ranges.filter((range) => !settled(range, now)); + const words = Array.from(text.slice(this.text.length).matchAll(/\S+/gu)); + if (words.length === 0) return new Reveal(text, ranges); + // A frame that arrives early must continue the front, never overtake it. + const previous = ranges.at(-1); + const base = Math.max(now, previous ? previous.startedAt + previous.spanMs : now); + const share = spanMs / words.length; + words.forEach((word, index) => { + const start = this.text.length + word.index; + ranges.push({ + start, + end: start + word[0].length, + startedAt: base + share * index, + spanMs: share, + }); + }); + return new Reveal(text, ranges); + } + surface(text: string, offsets: readonly number[], now: number): FadeRange[] { + const result: FadeRange[] = []; + let first = 0; + let last = offsets.length - 1; + while (first <= last && offsets[first]! < 0) first++; + while (last >= first && offsets[last]! < 0) last--; + for (const range of this.ranges) { + if ( + settled(range, now) || + first > last || + range.end <= offsets[first]! || + range.start > offsets[last]! + ) + continue; + const perSourceChar = range.spanMs / (range.end - range.start); + let start = -1; + for (let i = 0; i <= offsets.length; i++) { + const inside = + i < offsets.length && + offsets[i]! >= range.start && + offsets[i]! < range.end && + !/\s/u.test(text[i]!); + if (inside && start < 0) start = i; + if (!inside && start >= 0) { + // A rendered piece keeps the slice of the word's timeline it came from, + // so a word split by inline formatting still fades as one run. + const from = offsets[start]! - range.start; + const to = offsets[i - 1]! + 1 - range.start; + result.push({ + start, + end: i, + startedAt: range.startedAt + perSourceChar * from, + spanMs: perSourceChar * (to - from), + }); + start = -1; + } + } + } + return result; + } +} diff --git a/packages/app/src/word-stream/markdown/index.tsx b/packages/app/src/word-stream/markdown/index.tsx new file mode 100644 index 0000000000..a87c2495af --- /dev/null +++ b/packages/app/src/word-stream/markdown/index.tsx @@ -0,0 +1,51 @@ +import { useMemo } from "react"; +import { + renderRules, + type ASTNode, + type RenderRules, + type RenderFunction, +} from "react-native-markdown-display"; +import { WordFadeScope } from ".."; +import type { SourceText } from "../internal/markdown-source"; + +function sourceText(node: ASTNode, offset: number): SourceText { + if (node.children.length) { + const children = node.children.map((child) => sourceText(child, offset)); + return { + text: children.map((child) => child.text).join(""), + offsets: children.flatMap((child) => child.offsets), + }; + } + if (node.type === "softbreak" || node.type === "hardbreak") return { text: "\n", offsets: [-1] }; + const meta = Reflect.get(node, "sourceMeta") as { wordSource?: number[] } | undefined; + return { text: node.content, offsets: (meta?.wordSource ?? []).map((value) => value + offset) }; +} + +/** Keep mapping and animation ownership outside the presentation rules. */ +export function useWordFadeRules(rules: RenderRules, sourceOffset: number): RenderRules { + return useMemo( + () => + Object.fromEntries( + Object.entries({ ...renderRules, ...rules }).map(([name, rule]) => [ + name, + rule && + ((...args: Parameters>) => { + const node = args[0]; + return ( + + {(rule as RenderFunction)( + args[0], + args[1], + args[2], + args[3], + args[4], + ...args.slice(5), + )} + + ); + }), + ]), + ), + [rules, sourceOffset], + ); +} diff --git a/packages/app/src/word-stream/native-host.tsx b/packages/app/src/word-stream/native-host.tsx new file mode 100644 index 0000000000..7853a34997 --- /dev/null +++ b/packages/app/src/word-stream/native-host.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; +import type { ViewProps } from "react-native"; +import { requireNativeViewManager } from "expo-modules-core"; +import type { FadeRange } from "./internal/reveal"; + +interface WordFadeHostProps extends ViewProps { + ranges: readonly FadeRange[]; + children: ReactNode; +} + +export const WordFadeHost = requireNativeViewManager("PaseoWordStream"); diff --git a/packages/app/src/word-stream/text.android.tsx b/packages/app/src/word-stream/text.android.tsx new file mode 100644 index 0000000000..5146525a97 --- /dev/null +++ b/packages/app/src/word-stream/text.android.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; +import { StyleSheet, Text, type TextProps } from "react-native"; +import { useWordFadeSurface } from "."; +import { WordFadeHost } from "./native-host"; +import { hostTextStyle } from "./internal/host-style"; + +const TextAncestor = createContext(false); + +export function WordFadeText(props: TextProps) { + const nested = useContext(TextAncestor); + const surface = useWordFadeSurface(); + if (nested) return ; + const style = hostTextStyle(StyleSheet.flatten(props.style) ?? {}); + return ( + + + + + + ); +} diff --git a/packages/app/src/word-stream/text.browser.test.tsx b/packages/app/src/word-stream/text.browser.test.tsx new file mode 100644 index 0000000000..72630df79d --- /dev/null +++ b/packages/app/src/word-stream/text.browser.test.tsx @@ -0,0 +1,187 @@ +import { afterAll, afterEach, beforeAll, expect, it } from "vitest"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { StreamingWords } from "."; +let WordFadeText: typeof import("./text.web").WordFadeText; + +// The browser test runner uses classic JSX for RN source; provide the real runtime. +const previousReact = Reflect.get(globalThis, "React"); +beforeAll(async () => { + Reflect.set(globalThis, "React", React); + // Loading the web bundle must also work on runtimes without Intl.Segmenter. + const segmenter = Object.getOwnPropertyDescriptor(Intl, "Segmenter")!; + Reflect.deleteProperty(Intl, "Segmenter"); + try { + ({ WordFadeText } = await import("./text.web")); + } finally { + Object.defineProperty(Intl, "Segmenter", segmenter); + } +}); +afterAll(() => { + if (previousReact === undefined) Reflect.deleteProperty(globalThis, "React"); + else Reflect.set(globalThis, "React", previousReact); +}); + +const textStyle = { fontSize: 24, fontFamily: "Arial" }; +const paced = (text: string) => ({ text, spanMs: 60, streaming: true }); +let root: Root | undefined; +let host: HTMLDivElement | undefined; + +afterEach(async () => { + await act(async () => root?.unmount()); + host?.remove(); +}); + +async function reveal(text: string) { + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const content = (value: string) => ( + + {value} + + ); + await act(async () => root!.render(content(""))); + await act(async () => root!.render(content(text))); + return host; +} + +it.each(["abcdefgh", "שלום", "العربية"])("fades %s in visual left-to-right order", async (text) => { + const surface = await reveal(text); + const animations = surface.getAnimations({ subtree: true }); + expect(animations.length).toBeGreaterThan(0); + for (const animation of animations) { + animation.pause(); + animation.currentTime = 65; + } + const word = surface.querySelector("[data-word-fade]")!; + const letters = Array.from(word.children, (child) => ({ + left: child.getBoundingClientRect().left, + opacity: Number(getComputedStyle(child).opacity), + })).sort((a, b) => a.left - b.left); + expect(letters[0]!.opacity).toBeGreaterThan(letters.at(-1)!.opacity); + for (let index = 1; index < letters.length; index++) { + expect(letters[index]!.opacity).toBeLessThanOrEqual(letters[index - 1]!.opacity); + } +}); + +it.each(["office", "العربية", "👨‍👩‍👧‍👦", "café"])("preserves shaping and width for %s", async (text) => { + const surface = await reveal(text); + const word = surface.querySelector("[data-word-fade]")!; + const width = word.getBoundingClientRect().width; + const plain = document.createElement("span"); + plain.textContent = text; + word.parentElement!.append(plain); + expect(Math.abs(width - plain.getBoundingClientRect().width)).toBeLessThan(0.5); +}); + +it.each(["👨‍👩‍👧‍👦", "é", "🇺🇸", "가", "क्‍ष"])("fades the grapheme %s as one unit", async (text) => { + const surface = await reveal(text); + expect(surface.textContent).toBe(text); + expect(surface.getAnimations({ subtree: true })).toHaveLength(1); +}); + +it("does not fade settled text again when its Markdown surface remounts", async () => { + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const content = (value: string, key: string) => ( + + + {value} + + + ); + await act(async () => root!.render(content("", "paragraph"))); + await act(async () => root!.render(content("already visible ", "paragraph"))); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + await act(async () => root!.render(content("already visible next ", "table"))); + const fading = Array.from(host.querySelectorAll("[data-word-fade]"), (word) => word.textContent); + expect(fading).toEqual(["next"]); +}); + +it("does not re-fade repeated words when a paragraph becomes a table", async () => { + const { default: Markdown } = await import("react-native-markdown-display"); + const { useWordFadeRules } = await import("./markdown"); + const { createAssistantMarkdownParser } = await import("@/utils/assistant-markdown-parser"); + const parser = createAssistantMarkdownParser({ streaming: true }); + const rules = { + text: (node: { key: string; content: string }) => ( + {node.content} + ), + }; + function Body({ text }: { text: string }) { + const mapped = useWordFadeRules(rules, 0); + return ( + + {text} + + ); + } + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const render = async (text: string) => + act(async () => + root!.render( + + + , + ), + ); + await render(""); + await render("| same | same |\n"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + await render("| same | same |\n| --- | --- |\n| same | next |\n"); + const words = Array.from(host.querySelectorAll("[data-word-fade]"), (word) => word.textContent); + expect(words).toEqual(["same", "next"]); +}); + +it("continues one front across a word boundary instead of restarting it", async () => { + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + const content = (value: string) => ( + + {value} + + ); + await act(async () => root!.render(content(""))); + await act(async () => root!.render(content("abcdefgh "))); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + }); + await act(async () => root!.render(content("abcdefgh ijkl "))); + for (const animation of host.getAnimations({ subtree: true })) animation.pause(); + const letters = Array.from( + host.querySelectorAll("[data-word-fade] > span"), + (el) => ({ + text: el.textContent, + left: el.getBoundingClientRect().left, + start: Number(el.dataset.fadeStart), + opacity: Number(getComputedStyle(el).opacity), + }), + ).sort((a, b) => a.left - b.left); + expect(letters.map((l) => l.text).join("")).toBe("abcdefghijkl"); + for (let index = 1; index < letters.length; index++) { + const label = `${letters[index]!.text} after ${letters[index - 1]!.text}`; + expect(letters[index]!.start, label).toBeGreaterThanOrEqual(letters[index - 1]!.start); + expect(letters[index]!.opacity, label).toBeLessThanOrEqual(letters[index - 1]!.opacity + 0.01); + } + // "i" starts after "h", and the front keeps moving inside each word. + expect(letters[8]!.start).toBeGreaterThan(letters[7]!.start); + expect(letters[1]!.start).toBeGreaterThan(letters[0]!.start); +}); + +it("removes the final word's animation spans without another text update", async () => { + const surface = await reveal("final"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + expect(surface.querySelectorAll("[data-word-fade]")).toHaveLength(0); + expect(surface.textContent).toBe("final"); +}); diff --git a/packages/app/src/word-stream/text.d.ts b/packages/app/src/word-stream/text.d.ts new file mode 100644 index 0000000000..6c59a6d9d2 --- /dev/null +++ b/packages/app/src/word-stream/text.d.ts @@ -0,0 +1 @@ +export { WordFadeText } from "./text.android"; diff --git a/packages/app/src/word-stream/text.ios.tsx b/packages/app/src/word-stream/text.ios.tsx new file mode 100644 index 0000000000..a82f2c7f0d --- /dev/null +++ b/packages/app/src/word-stream/text.ios.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext } from "react"; +import { StyleSheet, type TextProps } from "react-native"; +import { UITextView } from "react-native-uitextview"; +import { useWordFadeSurface } from "."; +import { WordFadeHost } from "./native-host"; +import { hostTextStyle } from "./internal/host-style"; + +const TextAncestor = createContext(false); + +export function WordFadeText(props: TextProps) { + const nested = useContext(TextAncestor); + const surface = useWordFadeSurface(); + if (nested) return ; + const style = hostTextStyle(StyleSheet.flatten(props.style) ?? {}); + return ( + + + + + + ); +} diff --git a/packages/app/src/word-stream/text.web.tsx b/packages/app/src/word-stream/text.web.tsx new file mode 100644 index 0000000000..59b44e47fd --- /dev/null +++ b/packages/app/src/word-stream/text.web.tsx @@ -0,0 +1,183 @@ +import { + Children, + useCallback, + useInsertionEffect, + useLayoutEffect, + useRef, + useMemo, + memo, + useState, + type AnimationEvent, + type CSSProperties, +} from "react"; +import { Text, type TextProps } from "react-native"; +// Keep the same grapheme fades on browsers without Intl.Segmenter. +import { graphemeSegments } from "unicode-segmenter/grapheme"; +import { useWordFadeSurface } from "."; +import { FADE_DURATION_MS } from "./internal/reveal"; + +let stylesheet: HTMLStyleElement | null = null; +let stylesheetUsers = 0; + +// Use the browser's shaped glyph positions, including bidi text. Batch new words +// before paint so a burst performs all layout reads before any style writes. +const pendingWords = new Set(); +let positioningScheduled = false; +function positionWord(word: HTMLSpanElement) { + pendingWords.add(word); + if (positioningScheduled) return; + positioningScheduled = true; + queueMicrotask(() => { + positioningScheduled = false; + const positions = Array.from(pendingWords) + .filter((item) => item.isConnected) + .map((item) => + Array.from(item.children, (child) => ({ + element: child as HTMLSpanElement, + rect: child.getBoundingClientRect(), + })).sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left), + ); + pendingWords.clear(); + for (const letters of positions) { + letters.forEach(({ element }, index) => { + const host = element.parentElement!.dataset; + const startedAt = Number(host.fadeStarted); + const offset = (index / letters.length) * Number(host.fadeSpan); + element.style.animationDelay = `${offset}ms`; + // Pin the word to the document timeline. A CSS animation otherwise + // starts at whichever frame first resolves its style, which is a + // different skew for every word and enough to move the front backwards + // across a word boundary. + for (const animation of element.getAnimations()) { + animation.startTime = startedAt - performance.timeOrigin; + } + // Absolute start time in wall-clock ms; tests read it to check the front + // never moves backwards across word boundaries. + element.dataset.fadeStart = String(startedAt + offset); + element.dataset.fadeLast = index === letters.length - 1 ? "true" : "false"; + }); + } + }); +} + +function FadingString({ text }: { text: string }) { + const surface = useWordFadeSurface(); + const [finished, setFinished] = useState(0); + const onAnimationEnd = useCallback((event: AnimationEvent) => { + if (!(event.target instanceof HTMLElement) || event.target.dataset.fadeLast !== "true") return; + const word = event.currentTarget.dataset; + const deadline = Number(word.fadeStarted) + Number(word.fadeSpan) + FADE_DURATION_MS; + setFinished((current) => Math.max(current, deadline)); + }, []); + const parts = []; + let end = 0; + for (const word of surface.ranges) { + if (word.startedAt + word.spanMs + FADE_DURATION_MS <= finished) continue; + parts.push(text.slice(end, word.start)); + parts.push( + , + ); + end = word.end; + } + parts.push(text.slice(end)); + return parts; +} + +interface FadingWordProps { + start: number; + startedAt: number; + spanMs: number; + text: string; + onAnimationEnd: (event: AnimationEvent) => void; +} + +const FadingWord = memo(function FadingWord({ + start, + startedAt, + spanMs, + text, + onAnimationEnd, +}: FadingWordProps) { + const letters = useMemo(() => Array.from(graphemeSegments(text)), [text]); + const ref = useRef(null); + useLayoutEffect(() => { + const word = ref.current!; + word.dataset.fadeStarted = String(startedAt); + word.dataset.fadeSpan = String(spanMs); + positionWord(word); + return () => { + pendingWords.delete(word); + }; + }, [text, startedAt, spanMs]); + return ( + + {letters.map((letter, index) => ( + + ))} + + ); +}); + +const FadingLetter = memo(function FadingLetter({ + text, + delayMs, + last, +}: { + text: string; + delayMs: number; + last: boolean; +}) { + const style = useMemo( + () => ({ + animationName: "paseo-word-fade", + animationDuration: `${FADE_DURATION_MS}ms`, + animationDelay: `${delayMs}ms`, + animationTimingFunction: "linear", + animationFillMode: "both", + }), + [delayMs], + ); + return ( + + {text} + + ); +}); + +export function WordFadeText({ children, ...props }: TextProps) { + useInsertionEffect(() => { + stylesheetUsers++; + if (!stylesheet) { + stylesheet = document.createElement("style"); + stylesheet.textContent = + "@keyframes paseo-word-fade { from { opacity: 0 } to { opacity: 1 } }"; + document.head.appendChild(stylesheet); + } + return () => { + if (--stylesheetUsers === 0) { + stylesheet?.remove(); + stylesheet = null; + } + }; + }, []); + + return ( + + {Children.map(children, (child) => + typeof child === "string" ? : child, + )} + + ); +} diff --git a/packages/app/src/workspace-labels/index.ts b/packages/app/src/workspace-labels/index.ts index b2c46ab748..49a2e6b1a9 100644 --- a/packages/app/src/workspace-labels/index.ts +++ b/packages/app/src/workspace-labels/index.ts @@ -155,18 +155,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 +227,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-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 bece625a89..3b16c6b8d5 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -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/legacy-daemon-workspaces.test.ts b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts index 50dc1a8ca1..263596badc 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts @@ -1,26 +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, - 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; @@ -82,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" })], }); @@ -173,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"); diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 8303cfadec..08c0ddf3ee 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -1,113 +1,19 @@ -import type { - DaemonClient, - FetchAgentsEntry, - FetchAgentsOptions, -} from "@getpaseo/client/internal/daemon-client"; -import { - deriveAgentStateBucket, - getWorkspaceStateBucketPriority, -} from "@getpaseo/protocol/agent-state-bucket"; import { useSessionStore, type Agent, type DaemonServerInfo, type WorkspaceDescriptor, } from "@/stores/session-store"; -import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; -export interface LegacyDaemonWorkspaceSnapshot { - agents: Map; - workspaces: Map; -} - -interface LegacyDaemonWorkspaceDirectoryReadResult { - entries: FetchAgentsEntry[]; - subscriptionId: string | null; -} - -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 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; @@ -139,144 +45,6 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { }; } -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: ReadonlyMap | undefined, @@ -294,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/vitest.config.ts b/packages/app/vitest.config.ts index 3f96ad3bc0..ad77e0d20f 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -56,17 +56,27 @@ 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", + ], }, }, }, - // 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"], }, // The globals a React Native bundler defines, which esbuild is no longer there to supply for // the package excluded above. @@ -101,6 +111,14 @@ export default defineConfig({ replacement: path.resolve(__dirname, "../relay/src/index.ts"), }, { find: "@", replacement: path.resolve(__dirname, "src") }, + // 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. diff --git a/packages/cli/package.json b/packages/cli/package.json index f0e7c43313..b1f3c17be9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.8.0-beta.1", + "version": "0.8.0", "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.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", - "@getpaseo/server": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/protocol": "0.8.0", + "@getpaseo/server": "0.8.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 275d8f6170..5e965601d9 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,3 +1,4 @@ +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"; @@ -15,9 +16,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 +36,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 +119,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()); @@ -207,5 +160,20 @@ export function createCli(): Command { // Added in v0.2.0; remove after 2027-01-17. program.addCommand(createWorktreeCommand(), { hidden: true }); + 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 e73bb0a2f3..522905c033 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -5,17 +5,17 @@ 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 { 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 } from "@getpaseo/protocol/messages"; export interface AgentAttachOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; [key: string]: unknown; } @@ -105,23 +105,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 }); @@ -155,6 +145,10 @@ export async function runAttachCommand( 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); } diff --git a/packages/cli/src/commands/agent/delete.test.ts b/packages/cli/src/commands/agent/delete.test.ts index 56aa83dc65..60d5cec50f 100644 --- a/packages/cli/src/commands/agent/delete.test.ts +++ b/packages/cli/src/commands/agent/delete.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + import { runDeleteCommand } from "./delete.js"; const agent = { @@ -27,7 +29,7 @@ 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); + const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never); expect(cancelAgent).toHaveBeenCalledWith(agent.id); expect(deleteAgent).toHaveBeenCalledWith(agent.id); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index c8502c9a19..f1499b51f4 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -1,6 +1,5 @@ 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"; export function addDeleteOptions(cmd: Command): Command { @@ -39,8 +38,6 @@ export async function runDeleteCommand( options: AgentDeleteOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!id && !options.all && !options.cwd) { const error: CommandError = { code: "MISSING_ARGUMENT", @@ -50,18 +47,7 @@ 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 } }); @@ -97,6 +83,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.ts b/packages/cli/src/commands/agent/logs.ts index e29c9f6c69..0226ed2b06 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.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 } from "../../output/index.js"; import { fetchProjectedTimelineItems, @@ -91,23 +91,13 @@ 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 client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: id }); @@ -157,6 +147,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(() => {}); @@ -208,6 +199,14 @@ async function runFollowMode( return; } + if (message.type === "agent.timeline.error") { + console.error(`Timeline observation stopped: ${message.payload.error}`); + return; + } + if (message.type === "agent.timeline.subscription_restored") { + 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 diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index 37978d9011..bb2189c7c4 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"; @@ -104,21 +104,6 @@ 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; @@ -185,14 +170,7 @@ 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..2119bd130b 100644 --- a/packages/cli/src/commands/agent/open.ts +++ b/packages/cli/src/commands/agent/open.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { openDesktopWithAgent } from "../open.js"; import type { CommandError, @@ -36,12 +36,7 @@ async function resolveServerId(options: CommandOptions): Promise { return explicitServerId; } - 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) { 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..8f707c5e0c 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -6,6 +6,8 @@ import { type AgentRunOptions, } from "./run"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + describe("managed agent caller context", () => { it("propagates a trimmed PASEO_AGENT_ID", () => { expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent"); @@ -65,8 +67,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 +91,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..c72049347d 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -1,7 +1,7 @@ import { Command, Option } from "commander"; import { getStructuredAgentResponse, StructuredAgentResponseError } from "@getpaseo/server"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -140,6 +140,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 +182,7 @@ function loadOutputSchema(value: string): Record { 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 +197,7 @@ function loadOutputSchema(value: string): Record { 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", @@ -406,6 +409,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 +436,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 +487,6 @@ function parseKeyValueFlags( return labels; } -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; - } -} - // 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. @@ -587,7 +576,6 @@ export async function runRunCommand( options: AgentRunOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined; validateRunOptions(prompt, options, outputSchema); @@ -596,7 +584,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 diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index 06249289b7..aa9b5a24cc 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> { - 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 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 { - 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 { - 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> { - 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..c07354c5d6 --- /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, + readPersistedConfig, + getPersistedConfigValue, + editPersistedConfig, +} from "@getpaseo/server"; +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) { + 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 ")) + .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 ")).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 ", "Paseo home directory (default: ~/.paseo)") - .action(withOutput(runStatusCommand)); - - addJsonOption(daemon.command("stop").description("Stop the local daemon")) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .option("--timeout ", "Wait timeout before failing (default: 15)") - .option("--force", "Send SIGKILL if graceful stop times out") - .option("--kill-timeout ", "Wait after SIGKILL before failing (default: 3)") - .action(withOutput(runStopCommand)); - - addJsonOption(daemon.command("restart").description("Restart the local daemon")) - .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("--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 ", - '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 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 ", "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..6c2da1a350 --- /dev/null +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -0,0 +1,610 @@ +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 { startDaemonInstance, readDaemonInstance } from "@getpaseo/server"; +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((resolve) => server.listen(0, "127.0.0.1", resolve)); + const value = (server.address() as net.AddressInfo).port; + await new Promise((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(); + 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((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, path.join(f.root, ".paseo")]) + expect((await stat(home)).mode & 0o777).toBe(0o700); + } + + 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("Agent not found: 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("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 | 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)); + await expect + .poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 }) + .toBe(true); + const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8")); + await f.ok(["status", "--home", home]); + child.kill("SIGINT"); + await exited; + expect(() => process.kill(lock.pid, 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("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 | 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("Agent not found"); + 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("Agent not found"); + } 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 | 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 | 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("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[2]; - } - | { - mode: "foreground"; - command: string; - args: string[]; - options: Parameters[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[2], - ): DetachedDaemonProcess { - this.recordedLaunches.push({ mode: "detached", command, args, options }); - return this.daemonProcess; - } - - spawnForeground( - command: string, - args: string[], - options: Parameters[2], - ) { - this.recordedLaunches.push({ mode: "foreground", command, args, options }); - return { status: this.foregroundStatus, error: undefined }; - } -} - -const tempRoots: string[] = []; - -async function createPaseoHome(config: unknown): Promise { - 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..049cfd0bd9 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 {} - -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[2], - ): DetachedDaemonProcess; - spawnForeground( - command: string, - args: string[], - options: Parameters[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"; 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; - 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 { - 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 { - 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 { - try { - return await signalProcessTreeSafely(pid, signal); - } catch { - return signalProcessSafely(pid, signal); - } -} - -async function waitForPidExit(pid: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - async function poll(): Promise { - 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 { - const host = resolveTcpHostFromListen(state.listen); - if (!host) { - return true; - } - - const reachableHost = host; - const deadline = Date.now() + timeoutMs; - async function poll(): Promise { - 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 { - 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 { - 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((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 { - 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 { - 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 = { + "--port ": "daemon.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 ": "daemon.hostnames", + "--allowed-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} --home , then start or restart.` : "Use paseo daemon run --home 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>() - .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..1d9e090ee8 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -3,29 +3,23 @@ import { Command } from "commander"; import chalk from "chalk"; import { generateLocalPairingOffer, - getOrCreateServerId, - loadConfig, - resolvePaseoHome, + readDaemonInstance, + readPersistedConfig, + editPersistedConfig, + resolveConfigFromPersisted, } from "@getpaseo/server"; -import { tryConnectToDaemon } from "../../utils/client.js"; -import { resolveLocalDaemonState } from "./local-daemon.js"; -import { addJsonOption } from "../../utils/command-options.js"; +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"; 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 +56,32 @@ function createProcessOutput(): PairCommandOutput { } export function pairCommand(): Command { - return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link")) - .option("--home ", "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 { - 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 +96,16 @@ export async function resolveLocalPairingOffer(options: { } async function resolveDaemonPairingOffer( - listen: string, - expectedServerId: string, + target: DaemonTarget, enableRelay: boolean | undefined, -): Promise { - const client = await tryConnectToDaemon({ - host: listen, +): Promise { + 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 +118,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 +155,22 @@ export function printDirectConnectionGuidance(): void { console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`); } -export async function runPairCommand( - options: PairOptions, - dependencyOverrides: Partial = {}, -): Promise { - 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 { + 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( 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 = { "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> { - 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..5394fc4b53 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -1,131 +1,122 @@ -import type { 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"; +import { Command } from "commander"; +import { readDaemonInstance, isSameDaemonInstance, DaemonInstanceError } from "@getpaseo/server"; +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 ", "Replacement readiness deadline (default: 600)") + .action(withOutput(runRestartCommand)); } -const restartResultSchema: OutputSchema = { - 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; - -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 { - 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>; + 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"); + const status = await replacement.getDaemonStatus({ timeout: Math.min(1_000, 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/set-password.ts b/packages/cli/src/commands/daemon/set-password.ts index 3fd407f587..cf190327e0 100644 --- a/packages/cli/src/commands/daemon/set-password.ts +++ b/packages/cli/src/commands/daemon/set-password.ts @@ -3,7 +3,8 @@ import type { Command } from "commander"; import { isCancel, password as passwordPrompt } from "@clack/prompts"; import { hashDaemonPassword, - loadPersistedConfig, + readPersistedConfig, + resolvePaseoHome, savePersistedConfig, type PersistedConfig, } from "@getpaseo/server"; @@ -14,7 +15,6 @@ import type { OutputSchema, SingleResult, } from "../../output/index.js"; -import { resolveLocalPaseoHome } from "./local-daemon.js"; const CONFIG_FILENAME = "config.json"; @@ -81,9 +81,9 @@ export async function setDaemonPasswordInConfig( newPassword: string, options: SetPasswordOptions = {}, ): Promise { - 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 +100,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)}`, }; } @@ -115,7 +115,7 @@ export async function runSetPasswordCommand( : (message: string) => passwordPrompt({ message }); 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..ae36e0eed0 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"; +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 target (host:port, port, or unix socket path)") - .option("--port ", "Port to listen on (default: 6767)") - .option("--home ", "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 ", - 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', - ) - .addOption(new Option("--allowed-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 ", "Readiness deadline (default: 600)") + .action(withOutput(runStart)); } -export async function runStart(options: StartOptions): Promise { - 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..c44e01524e 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, + readPersistedConfig, + resolveConfigFromPersisted, + daemonLogPath, + isSameDaemonInstance, + DaemonInstanceError, +} from "@getpaseo/server"; +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 { +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 = { ...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 { - 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; -}): Promise { - const { host, state } = args; - let client: Awaited>; - 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 { - 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, -): Promise { - 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, -): 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; - -export async function runStatusCommand( - options: CommandOptions, - _command: Command, -): Promise { - 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>, + local: Record, +) { + 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 = {}; + let client: Awaited> | 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>) { + 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, + 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..a94eab24d4 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"; +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 ", "Graceful exit deadline (default: 15)") + .option("--force", "Permit forced local process cleanup") + .option("--kill-timeout ", "Forced exit deadline (default: 3)") + .action(withOutput(runStopCommand)); } -const stopResultSchema: OutputSchema = { - 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; - -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 { - 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> { 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/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; + connect(target: DaemonTarget): Promise; } export const productionHubDaemonConnection: HubDaemonConnection = { - connect: (host) => connectToDaemon({ host }), + connect: (target) => connectToDaemon({ target }), }; export async function withHubDaemon( connection: HubDaemonConnection, - host: string | undefined, + target: DaemonTarget, action: (client: HubDaemonClient) => Promise, ): Promise { - 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/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/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 = {}) 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 = {}) }); 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.ts b/packages/cli/src/commands/hub/init.ts index 6a97558b34..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, @@ -49,6 +52,7 @@ const DAEMON_READY_POLL_MS = 250; const PROVIDER_READY_TIMEOUT_MS = 60_000; export interface HubGuidedSetupEnvironment { + daemonTarget: DaemonTarget; env: Readonly>; credentials: HubCredentialStore; hub: HubHttpClient; @@ -80,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, +): void { parent .command("init") .description("Create and optionally deploy a safe starter Hub trigger") - .action(async () => { - try { - await runHubInit(environment); - } catch (error) { - if (error instanceof HubInitCancelledError) { - cancel(error.message); - return; + .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 { @@ -175,8 +184,10 @@ export async function continueHubGuidedSetup( origin: string, environment: HubGuidedSetupEnvironment, ): Promise { - 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") { @@ -187,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, @@ -273,7 +284,7 @@ async function ensureDaemonConnection( confirmed = false, permissions: readonly string[] = ["hub.execute"], ): Promise { - 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); @@ -287,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( @@ -315,7 +326,7 @@ async function connectDaemon( ): Promise { await runHubConnect( origin, - { permissions }, + { permissions, daemonTarget: environment.daemonTarget }, { env: environment.env, credentials: environment.credentials, @@ -324,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 { 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; @@ -436,7 +447,7 @@ async function waitForStarterAgentProviders( cwd: string, ): Promise { 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 }); @@ -750,7 +761,9 @@ async function requiredSelect( return answer; } -function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void { +function requireInteractiveTerminal( + environment: Omit, +): 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; reporter: HubReporter; isInteractive?(): boolean; - continueGuidedSetup?(origin: string): Promise; + continueGuidedSetup?(origin: string, daemonTarget: DaemonTarget): Promise; } export async function runHubLogin( originInput: string | undefined, - options: { json?: boolean }, + options: Pick, dependencies: HubLoginDependencies, ): Promise> { 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> { - 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/onboard.ts b/packages/cli/src/commands/onboard.ts index 1ce0ac4283..4719e2823f 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,17 +1,18 @@ -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, + readDaemonInstance, + waitForDaemonReady, + type PersistedConfig, +} from "@getpaseo/server"; +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 +20,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 +45,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 { - 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 +112,6 @@ async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise= 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 { - 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 +121,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 +150,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 target (host:port, port, or unix socket path)") .option("--port ", "Port to listen on (default: 6767)") - .option("--home ", "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 +164,15 @@ export function onboardCommand(): Command { .addOption(new Option("--allowed-hosts ").hideHelp()) .option("--timeout ", "Max time to wait for daemon readiness (default: 600)") .option("--voice ", "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 +205,30 @@ async function resolveAndPersistVoice( return voiceEnabled; } -async function ensureDaemonStarted(options: OnboardOptions, richUi: boolean): Promise { - 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 { @@ -431,16 +242,12 @@ export async function runOnboard(options: OnboardOptions): Promise { 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 +259,19 @@ export async function runOnboard(options: OnboardOptions): Promise { : "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..51656436e9 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -95,6 +95,7 @@ export async function openDesktopWithProject(projectPath: string): Promise 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..d2338cffca 100644 --- a/packages/cli/src/commands/permit/allow.ts +++ b/packages/cli/src/commands/permit/allow.ts @@ -1,6 +1,6 @@ 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, OutputSchema, CommandError } from "../../output/index.js"; /** Permission response item for display */ @@ -46,8 +46,6 @@ export async function runAllowCommand( options: PermitAllowOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // No validation needed - if no reqId provided, allow all by default // Parse input JSON if provided @@ -65,18 +63,7 @@ export async function runAllowCommand( } } - 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: agentIdOrPrefix }); diff --git a/packages/cli/src/commands/permit/deny.ts b/packages/cli/src/commands/permit/deny.ts index 94ffe9d571..c0d4707f35 100644 --- a/packages/cli/src/commands/permit/deny.ts +++ b/packages/cli/src/commands/permit/deny.ts @@ -1,6 +1,6 @@ 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"; @@ -19,8 +19,6 @@ export async function runDenyCommand( options: PermitDenyOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!options.all && !reqId) { const error: CommandError = { @@ -31,18 +29,7 @@ export async function runDenyCommand( 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: agentIdOrPrefix }); diff --git a/packages/cli/src/commands/permit/ls.ts b/packages/cli/src/commands/permit/ls.ts index adeaa70ff1..b04c8d9f58 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 */ @@ -48,20 +48,7 @@ export async function runLsCommand( options: PermitLsOptions, _command: Command, ): Promise { - 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 } }); diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index 02c6c846ce..957a0072ae 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + const listPlugins = vi.fn(async () => [ { id: "git-plugin", @@ -68,7 +70,9 @@ describe("plugin management commands", () => { }); it("requires host support before attempting a management RPC", async () => { - await expect(runPluginListCommand(undefined, {}, {} 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.", }); @@ -89,7 +93,7 @@ describe("plugin management commands", () => { it("lists runtime state and the installed commit without an upstream commit", async () => { features.pluginManagement = true; - const result = await runPluginListCommand(undefined, {}, {} as never); + const result = await runPluginListCommand(undefined, { daemonTarget }, {} as never); const output = render(result, { noColor: true }); expect(output).toContain("SOURCE"); @@ -102,13 +106,15 @@ describe("plugin management commands", () => { it("filters the shared ls and status command by plugin ID", async () => { features.pluginManagement = true; - const result = await runPluginListCommand("legacy-plugin", {}, {} as never); + 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.", }); @@ -117,7 +123,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"); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 608a57e5d1..e067d4aa39 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -90,7 +90,9 @@ export async function runPluginListCommand( options: PluginOptions, _command: Command, ): Promise> { - const plugins = 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 }; @@ -101,7 +103,9 @@ export async function runPluginLogsCommand( options: PluginOptions, _command: Command, ): Promise> { - 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 }; } @@ -126,10 +130,10 @@ async function install( isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path; const sourceReference = formatPluginSourceReference(source, options.path); const data = canUseLegacyDirectoryInstall - ? await withPluginManagementClient(options.host, (client) => + ? await withPluginManagementClient(options.daemonTarget, (client) => client.installDirectoryPlugin(source, options.id), ) - : await withPluginSourceClient(options.host, (client) => + : await withPluginSourceClient(options.daemonTarget, (client) => client.installPluginSource({ source: sourceReference, ...(options.id ? { id: options.id } : {}), @@ -147,7 +151,7 @@ async function update( if ((pluginId === undefined) === (options.all !== true)) { throw new Error("Choose one plugin ID or pass --all"); } - const data = await withPluginSourceClient(options.host, (client) => + const data = await withPluginSourceClient(options.daemonTarget, (client) => client.updatePluginSources(pluginId), ); return { type: "list", data, schema: pluginUpdateSchema }; @@ -158,7 +162,7 @@ async function act( pluginId: string, options: PluginOptions, ): Promise> { - const data = await withPluginManagementClient(options.host, (client) => + const data = await withPluginManagementClient(options.daemonTarget, (client) => client[`${action}Plugin`](pluginId), ); return { type: "single", data, schema: pluginSchema }; @@ -169,7 +173,7 @@ async function remove( options: PluginOptions, _command: Command, ): Promise> { - 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); diff --git a/packages/cli/src/commands/plugin/shared.ts b/packages/cli/src/commands/plugin/shared.ts index c85a8d7e32..80c3dcfafa 100644 --- a/packages/cli/src/commands/plugin/shared.ts +++ b/packages/cli/src/commands/plugin/shared.ts @@ -1,3 +1,4 @@ +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"; @@ -5,12 +6,12 @@ import { connectToDaemon } from "../../utils/client.js"; type PluginFeature = "pluginManagement" | "pluginLogs" | "pluginGitManagement"; async function withPluginClient( - host: string | undefined, + target: DaemonTarget, feature: PluginFeature, updateMessage: string, run: (client: DaemonClient) => Promise, ): Promise { - const client = await connectToDaemon({ host }); + const client = await connectToDaemon({ target }); if (client.getLastServerInfoMessage()?.features?.[feature] !== true) { await client.close().catch(() => undefined); throw { @@ -26,12 +27,12 @@ async function withPluginClient( } export async function withPluginManagementClient( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise, ): Promise { // 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,20 +40,20 @@ export async function withPluginManagementClient( } export async function withPluginLogsClient( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise, ): Promise { // 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( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise, ): Promise { // COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26. return withPluginClient( - host, + target, "pluginGitManagement", "Update the host to install and update Git plugins.", run, 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> { - 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> { - 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> { 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> { - 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 = { idField: "provider", @@ -59,15 +44,7 @@ export async function runLsCommand( options: ProviderLsOptions, _command: Command, ): Promise { - 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 { 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> { - 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> { - 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> { - 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> { - 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> { - 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> { - 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> { - 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..81f3762962 100644 --- a/packages/cli/src/commands/schedule/update.ts +++ b/packages/cli/src/commands/schedule/update.ts @@ -50,7 +50,7 @@ export async function runUpdateCommand( clearMaxRuns: options.noMaxRuns, clearExpires: options.noExpiresIn, }); - 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> { - 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 { - const daemonHost = getDaemonHost({ host }); +export async function connectWorkspaceScriptClient(target: DaemonTarget): Promise { + 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> { - 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> { - 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 98b9f2ca2e..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 { - 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, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await resolveTerminalId(client, terminalId); diff --git a/packages/cli/src/commands/terminal/create.ts b/packages/cli/src/commands/terminal/create.ts index 9524be0223..be41d877e0 100644 --- a/packages/cli/src/commands/terminal/create.ts +++ b/packages/cli/src/commands/terminal/create.ts @@ -17,7 +17,7 @@ export async function runCreateCommand( options: TerminalCreateOptions, _command: Command, ): Promise> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const cwd = options.cwd ?? (options.workspace ? undefined : process.cwd()); const workspaceId = diff --git a/packages/cli/src/commands/terminal/kill.ts b/packages/cli/src/commands/terminal/kill.ts index 30990d9c11..685c8ecaf3 100644 --- a/packages/cli/src/commands/terminal/kill.ts +++ b/packages/cli/src/commands/terminal/kill.ts @@ -13,7 +13,7 @@ export async function runKillCommand( options: TerminalCommandOptions, _command: Command, ): Promise> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await requireTerminalId(client, terminalId); diff --git a/packages/cli/src/commands/terminal/ls.ts b/packages/cli/src/commands/terminal/ls.ts index bec0b0047b..23b1a182a6 100644 --- a/packages/cli/src/commands/terminal/ls.ts +++ b/packages/cli/src/commands/terminal/ls.ts @@ -17,7 +17,7 @@ export async function runLsCommand( options: TerminalLsOptions, _command: Command, ): Promise> { - const { client, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); const cwd = options.all || options.workspace ? undefined : (options.cwd ?? process.cwd()); try { diff --git a/packages/cli/src/commands/terminal/send-keys.ts b/packages/cli/src/commands/terminal/send-keys.ts index 5097ea3908..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 { - 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, close } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await resolveTerminalId(client, terminalId); diff --git a/packages/cli/src/commands/terminal/shared.ts b/packages/cli/src/commands/terminal/shared.ts index 101caf0879..0fcb9ef64d 100644 --- a/packages/cli/src/commands/terminal/shared.ts +++ b/packages/cli/src/commands/terminal/shared.ts @@ -1,3 +1,4 @@ +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"; @@ -11,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 }); + 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", 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 = { export async function runArchiveCommand( workspaceId: string, - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise> { - 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..5ce49278ef 100644 --- a/packages/cli/src/commands/workspace/create.ts +++ b/packages/cli/src/commands/workspace/create.ts @@ -134,8 +134,8 @@ export async function runCreateCommand( options: WorkspaceCreateOptions, _command: Command, ): Promise> { - 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", @@ -153,6 +153,7 @@ export async function runCreateCommand( } 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/ls.ts b/packages/cli/src/commands/workspace/ls.ts index 7ea9e8f56d..b9821e061a 100644 --- a/packages/cli/src/commands/workspace/ls.ts +++ b/packages/cli/src/commands/workspace/ls.ts @@ -4,11 +4,11 @@ import type { CommandError, ListResult } from "../../output/index.js"; import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; export async function runLsCommand( - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise> { - 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", 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 = { export interface WorkspaceRenameOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; reset?: boolean; } @@ -55,8 +56,8 @@ export async function runRenameCommand( ): Promise> { 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 = { export async function runSetupCommand( workspaceId: string, - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise> { - 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/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 @@ -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..eaf3857322 100644 --- a/packages/cli/src/commands/worktree/archive.ts +++ b/packages/cli/src/commands/worktree/archive.ts @@ -48,7 +48,7 @@ export async function runArchiveCommandWithDeps( options: WorktreeArchiveOptions, deps: { connectToDaemon: typeof connectToDaemon }, ): Promise { - const host = getDaemonHost({ host: options.host }); + const host = getDaemonHost({ target: options.daemonTarget }); // Validate arguments if (!nameArg || nameArg.trim().length === 0) { @@ -62,8 +62,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", 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/ls.ts b/packages/cli/src/commands/worktree/ls.ts index 10f1344e17..cd388b2b08 100644 --- a/packages/cli/src/commands/worktree/ls.ts +++ b/packages/cli/src/commands/worktree/ls.ts @@ -1,8 +1,6 @@ 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 type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Worktree list item for display */ @@ -27,19 +25,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 = { idField: "name", @@ -61,20 +46,7 @@ export async function runLsCommand( options: WorktreeLsOptions, _command: Command, ): Promise { - 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 client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); @@ -96,9 +68,7 @@ export async function runLsCommand( // Build a map of worktree paths to agent IDs const worktreeAgentMap = new Map(); 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/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 { + 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.ts b/packages/cli/src/run.ts index 4b679c5298..b95dd9b419 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"; @@ -48,6 +49,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/client-id.ts b/packages/cli/src/utils/client-id.ts index 13863e0945..145b698884 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"; +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 { - if (cachedClientId) { - return cachedClientId; - } +export async function getOrCreateCliClientId(home: string): Promise { + 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 { } 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..f2eae4b6a2 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,5 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { loadConfig, resolvePaseoHome } from "@getpaseo/server"; +import { waitForDaemonReady, resolvePaseoHome, type DaemonInstance } from "@getpaseo/server"; +import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js"; +export type { DaemonTarget } from "./daemon-target.js"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, @@ -13,57 +14,44 @@ 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; -} - -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: + options.target.kind === "instance" + ? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}` + : "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.", }; } @@ -118,100 +106,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}`); @@ -363,12 +264,21 @@ function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null } } -export async function connectToDaemon(options?: ConnectOptions): Promise { - const timeout = options?.timeout ?? DEFAULT_TIMEOUT; - const clientId = await getOrCreateCliClientId(); +async function connectSelectedDaemon(options: ConnectOptions): Promise { + 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 clientId = await getOrCreateCliClientId(resolvePaseoHome({})); const nodeWebSocketFactory = createNodeWebSocketFactory(); - const explicitHost = getExplicitDaemonHost(options?.host); if (explicitHost?.trim().startsWith("ssh://")) { const target = parseSshTransportUri(explicitHost.trim()); const tunnel = await createSshTunnel(target); @@ -377,10 +287,20 @@ export async function connectToDaemon(options?: ConnectOptions): Promise { + try { + await close(); + } finally { + tunnel.close(); + } + }; + return result.client; + } const failure = tunnel.failureDetail(); tunnel.close(); @@ -389,32 +309,37 @@ export async function connectToDaemon(options?: ConnectOptions): Promise { - 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 { + 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 { +export async function tryConnectToDaemon(options: ConnectOptions): Promise { 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..f491c6db03 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 { - const program = new Command().exitOverride().option("--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,10 @@ 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" }); }); 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..02fcd924a0 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(); + +export function addLocalDaemonOptions(command: T): T { + localCommands.add(command); + command.option("--home ", "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(command: T): T { export function addDaemonHostOption(command: T): T { command.option("--host ", DAEMON_HOST_OPTION_DESCRIPTION); + if (!command.options.some((option) => option.long === "--home")) + command.option("--home ", "Select a local daemon home (default: ~/.paseo)"); return command; } @@ -28,7 +39,19 @@ export function withGlobalOptions( 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(), host: new Set() }; + 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); + } + } + if (selectors.home.size > 1 || selectors.host.size > 1) + throw { code: "TARGET_AMBIGUOUS", message: "Conflicting duplicate daemon 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..f42b9e8704 --- /dev/null +++ b/packages/cli/src/utils/daemon-target.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "vitest"; +import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js"; + +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("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"); +}); diff --git a/packages/cli/src/utils/daemon-target.ts b/packages/cli/src/utils/daemon-target.ts new file mode 100644 index 0000000000..2705168159 --- /dev/null +++ b/packages/cli/src/utils/daemon-target.ts @@ -0,0 +1,51 @@ +import { resolvePaseoHome } from "@getpaseo/server"; + +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/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 .*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..5c56cadfbf 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( @@ -72,7 +73,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 +89,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 +105,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 +116,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 +127,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 +138,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 +149,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 +160,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,7 +171,7 @@ 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"); @@ -181,7 +182,7 @@ try { { console.log("Test 10: run --output-schema cannot be used with --background"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --background --output-schema ${schemaPath} "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --background --output-schema ${schemaPath} "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema"); const output = result.stdout + result.stderr; assert( @@ -195,7 +196,7 @@ try { { 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 +207,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 +218,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 +241,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 +252,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 +263,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..81b0cba66c 100644 --- a/packages/cli/tests/07-agent-stop.test.ts +++ b/packages/cli/tests/07-agent-stop.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("=== 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 +49,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 +65,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 +81,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 +92,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 +103,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,7 +123,7 @@ 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"); 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..e0c4d7f9b5 100644 --- a/packages/cli/tests/14-worktree.test.ts +++ b/packages/cli/tests/14-worktree.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("=== 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 { @@ -56,7 +57,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 +73,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"); @@ -93,7 +94,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 +109,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 +125,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 +136,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 +147,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"); 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..d02371f722 100644 --- a/packages/cli/tests/25-daemon-restart-supervisor.test.ts +++ b/packages/cli/tests/25-daemon-restart-supervisor.test.ts @@ -6,16 +6,20 @@ */ 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"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { getAvailablePort } from "./helpers/network.ts"; -$.verbose = false; - const pollIntervalMs = 100; const testEnv = { PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0", @@ -40,60 +44,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 { - 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 { const durableLogs = await readFile(join(paseoHome, "daemon.log"), "utf8").catch(() => ""); return `${recentLogs}\n${durableLogs}`; @@ -125,8 +75,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 +107,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 +129,90 @@ 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", + "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); - } + const restartAck = await client.restartServer("settings_update"); + assert.strictEqual( + restartAck.status, + "restart_requested", + "restart request should be acknowledged", + ); + const deadline = Date.now() + 20000; + let statusAfterRestart = statusBeforeRestart; await waitFor( - () => { - const workerPid = readWorkerPid(supervisorPid); - return ( - workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid) - ); + 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; + } }, 20000, "worker pid did not change after restart request", ); - - const workerPidAfterRestart = readWorkerPid(supervisorPid); - assert(workerPidAfterRestart !== null, "worker process should exist after restart"); 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 +229,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 +241,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..bec523f9ca 100644 --- a/packages/cli/tests/27-agent-delete.test.ts +++ b/packages/cli/tests/27-agent-delete.test.ts @@ -11,27 +11,23 @@ */ import assert from "node:assert"; -import { $ } from "zx"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; +import { getAvailablePort } from "./helpers/network.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,8 +106,7 @@ 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"); 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/32-daemon-set-password.test.ts b/packages/cli/tests/32-daemon-set-password.test.ts index 2dab1d9561..5756cfe0a0 100644 --- a/packages/cli/tests/32-daemon-set-password.test.ts +++ b/packages/cli/tests/32-daemon-set-password.test.ts @@ -51,7 +51,10 @@ 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"); @@ -68,6 +71,7 @@ try { const result = await runSetPasswordCommand( { home: paseoHome, + daemonTarget: { kind: "instance", home: paseoHome }, promptPassword: promptSequence(["new-secret", "new-secret"]), }, {} as Command, @@ -88,6 +92,7 @@ try { runSetPasswordCommand( { home: paseoHome, + daemonTarget: { kind: "instance", home: paseoHome }, promptPassword: promptSequence(["first-secret", "second-secret"]), }, {} as Command, 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..ebbad314a2 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"; @@ -26,7 +29,7 @@ try { assert.strictEqual(status.connectedDaemon, "auth_required"); 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.match(status.note, /Password required/i); assert.doesNotMatch(status.note, /not reachable/i); console.log("✓ missing password reports auth_required\n"); } @@ -44,7 +47,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 +73,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> | 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 { - 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/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index 4cb407a787..02c5fab136 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -44,7 +44,9 @@ async function main(): Promise { 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(); 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/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 8607b1cfb5..f8be7e6ead 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 { +async function probeDaemonReady( + port: number, + paseoHome: string, + env?: NodeJS.ProcessEnv, +): Promise { 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 { const deadline = Date.now() + timeout; async function poll(): Promise { - 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 @@ -358,11 +363,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"], 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 { 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((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 { }); } 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 { +async function probeDaemon(port: number, paseoHome: string): Promise { 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 { +export async function waitForDaemon( + port: number, + paseoHome: string, + timeout = 30000, +): Promise { const deadline = Date.now() + timeout; async function poll(): Promise { - 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 { $.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 { // 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 { export async function createTestContextWithDaemon(): Promise { 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/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> { 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 8d1a6169a3..7cba1623a8 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.8.0-beta.1", - "@getpaseo/relay": "0.8.0-beta.1", + "@getpaseo/protocol": "0.8.0", + "@getpaseo/relay": "0.8.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/client/src/connection.test.ts b/packages/client/src/connection.test.ts index 6807c4bf63..a3d12e48f6 100644 --- a/packages/client/src/connection.test.ts +++ b/packages/client/src/connection.test.ts @@ -2,11 +2,27 @@ 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 } = {}) { +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; - message?: { type: string; requestId?: string; agentIds?: string[]; events?: string[] }; + message?: { + type: string; + requestId?: string; + agentIds?: string[]; + events?: string[]; + subscriptionId?: string; + }; }> = []; let receive = (_data: unknown) => {}; let open = () => {}; @@ -26,7 +42,12 @@ function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { serverId: "test", hostname: null, version: null, - features: { selectiveAgentTimeline: true, explicitEventSubscriptions: true }, + features: { + ...(options.ownedSubscriptions === false ? {} : { ownedSubscriptions: true }), + workspaceMultiplicity: options.workspaceMultiplicity ?? true, + selectiveAgentTimeline: !options.broadcasts, + explicitEventSubscriptions: !options.broadcasts, + }, }, }, }), @@ -41,7 +62,36 @@ function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { type: "session", message: { type: frame.message.type.replace(".request", ".response"), - payload: { requestId: frame.message.requestId, agentIds: frame.message.agentIds }, + 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, + }, }, }), ); @@ -49,7 +99,7 @@ function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { }, close() {}, onMessage(handler) { - receive = handler; + receive = (data) => handler(data, typeof data !== "string"); return () => {}; }, onOpen(handler) { @@ -67,6 +117,12 @@ function connection(options: { acknowledgeSubscriptions?: boolean } = {}) { 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 }, }); @@ -96,35 +152,36 @@ test("a plain client advertises every protocol capability and no browser host", } }); -test("SDK timeline listeners own their union across unsubscribe and reconnect", async () => { +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 ready = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await ready; - const first = api.agents.ref("a").timeline.subscribe(() => {}); + await connecting; + const a = api.agents.ref("a").timeline.subscribe(() => {}); const same = api.agents.ref("a").timeline.subscribe(() => {}); - const second = api.agents.ref("b").timeline.subscribe(() => {}); - await Promise.resolve(); + 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((f) => f.message?.type === "agent.timeline.set_subscription.request") - .map((f) => f.message?.agentIds); - expect(memberships()).toEqual([["a"], ["a"], ["a", "b"]]); - first(); - expect(memberships()).toEqual([["a"], ["a"], ["a", "b"]]); - same(); - expect(memberships().at(-1)).toEqual(["b"]); + .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 reconnect = h.client.connect(); + const reconnecting = h.client.connect(); h.open(); - await reconnect; - expect(memberships().at(-1)).toEqual(["b"]); - second(); - expect(memberships().at(-1)).toEqual([]); + 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(); } }); @@ -140,7 +197,11 @@ test("SDK subscribers receive timeline replacement instead of silently losing hi const off = createPaseoApi(h.client) .agents.ref("agent") .timeline.subscribe((event) => received.push(event)); - h.receive({ type: "agent.timeline.replacement", payload: { agentId: "agent", epoch: "next" } }); + 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 { @@ -148,190 +209,110 @@ test("SDK subscribers receive timeline replacement instead of silently losing hi } }); -test("event listeners release demand and restore only remaining interests", async () => { +test("local listeners create no demand before or after reconnect", async () => { const h = connection(); try { - const ready = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await ready; - const projects = h.client.on("project.update", () => {}); - const providers = h.client.on("providers_snapshot_update", () => {}); - const subscriptions = () => - h.sent - .filter((f) => f.message?.type === "session.events.set_subscription.request") - .map((f) => f.message?.events); - expect(subscriptions()).toEqual([ - ["project.update"], - ["project.update", "providers_snapshot_update"], - ]); - projects(); + await connecting; + const off = h.client.on("project.update", () => {}); h.disconnect(); - const reconnect = h.client.connect(); + const reconnecting = h.client.connect(); h.open(); - await reconnect; - expect(subscriptions().at(-1)).toEqual(["providers_snapshot_update"]); - providers(); - expect(subscriptions().at(-1)).toEqual([]); + await reconnecting; + expect(h.sent.filter((frame) => frame.type === "session")).toEqual([]); + off(); } finally { await h.client.close(); } }); -test("provider references fetch a body only for interested SDK listeners", async () => { +test("provider reference hydration preserves independent owners and drops released work", async () => { const h = connection(); - const received: unknown[] = []; - const payload = { - entries: [], - snapshotHash: "catalog", - fetchedAt: { test: "2026-09-08T00:00:00.000Z" }, - generatedAt: "2026-09-08T00:00:00.000Z", - }; + const updates: string[] = []; try { - const ready = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await ready; - h.receive({ type: "providers_snapshot_update", payload }); - expect(h.sent).toHaveLength(1); - const off = h.client.on("providers_snapshot_update", (m) => received.push(m.payload.entries)); - h.receive({ type: "providers_snapshot_update", payload }); - h.receive({ type: "providers_snapshot_update", payload }); - const requests = h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request"); - expect(requests).toHaveLength(1); - h.receive({ - type: "get_providers_snapshot_response", - payload: { - ...payload, - requestId: requests[0].message?.requestId, - compactSnapshot: { - entries: [ - { - provider: "test", - enabled: true, - status: "ready", - models: [{ id: "one", label: "One" }], - }, - ], - thinkingSets: [], + 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!); }, - }, - }); - await expect - .poll(() => received) - .toEqual([ - [ - { - provider: "test", - enabled: true, - status: "ready", - fetchedAt: "2026-09-08T00:00:00.000Z", - models: [{ provider: "test", id: "one", label: "One" }], - }, - ], - ]); - off(); - h.receive({ type: "providers_snapshot_update", payload: { ...payload, snapshotHash: "next" } }); - expect(h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request")).toHaveLength( - 1, + }); + 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("SDK listeners preserve the app's explicit timeline membership", async () => { - const h = connection(); - try { - const ready = h.client.connect(); - h.open(); - await ready; - await h.client.setAgentTimelineSubscription(["visible"]); - const off = h.client.subscribeAgentTimeline("plugin", () => {}); - await h.client.setAgentTimelineSubscription(["next"]); - expect(h.sent.at(-1)?.message?.agentIds).toEqual(["next", "plugin"]); - off(); - expect(h.sent.at(-1)?.message?.agentIds).toEqual(["next"]); - } finally { - await h.client.close(); - } -}); - -test("timeline readiness waits for daemon acknowledgement, including shared listeners", async () => { +test("timeline readiness and updates belong to the acknowledged handle", async () => { const h = connection({ acknowledgeSubscriptions: false }); try { - const connect = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await connect; - const first = h.client.subscribeAgentTimeline("agent", () => {}); - const second = h.client.subscribeAgentTimeline("agent", () => {}); - expect(first.ready).toBeInstanceOf(Promise); - let ready = false; - void second.ready.then(() => { - return (ready = true); - }); - await Promise.resolve(); - expect(ready).toBe(false); - const request = h.sent.at(-1)!.message!; - h.receive({ - type: "agent.timeline.set_subscription.response", - payload: { requestId: request.requestId, agentIds: ["agent"] }, - }); - await Promise.all([first.ready, second.ready]); - expect(ready).toBe(true); - h.disconnect(); - const third = h.client.subscribeAgentTimeline("agent", () => {}); - let reconnected = false; - void third.ready.then(() => { - return (reconnected = true); - }); - const reconnect = h.client.connect(); - h.open(); - await reconnect; - expect(reconnected).toBe(false); - h.receive({ - type: "agent.timeline.set_subscription.response", - payload: { requestId: h.sent.at(-1)!.message!.requestId, agentIds: ["agent"] }, - }); - await third.ready; - expect(reconnected).toBe(true); - first(); - second(); - third(); - } finally { - await h.client.close(); - } -}); - -test("an unresolved provider reference is fetched again after reconnect", async () => { - const h = connection(); - const received: unknown[] = []; - const payload = { entries: [], snapshotHash: "updated", generatedAt: "2026-09-08T00:00:00.000Z" }; - const requests = () => h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request"); - try { - const connect = h.client.connect(); - h.open(); - await connect; - const off = h.client.on("providers_snapshot_update", (m) => - received.push(m.payload.snapshotHash), + 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", ); - h.receive({ type: "providers_snapshot_update", payload }); - expect(requests()).toHaveLength(1); - h.disconnect(); - await Promise.resolve(); - await Promise.resolve(); - const reconnect = h.client.connect(); - h.open(); - await reconnect; - expect(requests()).toHaveLength(2); + let bReady = false; + void b.ready.then(() => { + bReady = true; + return undefined; + }); h.receive({ - type: "get_providers_snapshot_response", + type: "agent.timeline.set_subscription.response", payload: { - ...payload, - requestId: requests()[1].message?.requestId, - compactSnapshot: { entries: [], thinkingSets: [] }, + requestId: requests[0].message!.requestId, + agentIds: ["agent"], + subscriptionId: "a", }, }); - await expect.poll(() => received).toEqual(["updated"]); - off(); + 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(); } @@ -362,30 +343,687 @@ test("timeline readiness remains pending before connect and rejects when release } }); -test("unsubscribing while disconnected discards unresolved provider demand", async () => { +test("passive provider listeners never hydrate unowned references", async () => { const h = connection(); try { - const connect = h.client.connect(); + const connecting = h.client.connect(); h.open(); - await connect; + 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", - }, + payload: { entries: [], snapshotHash: "updated", generatedAt: "2026-09-08T00:00:00.000Z" }, }); h.disconnect(); off(); - const reconnect = h.client.connect(); + const reconnecting = h.client.connect(); h.open(); - await reconnect; - expect(h.sent.filter((f) => f.message?.type === "get_providers_snapshot_request")).toHaveLength( + 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 | 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 index fe8553588d..e1d1761774 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -1,15 +1,138 @@ -import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +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; + }, + ) { + 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>, + ): 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( + query: ObservationRequest, + request: (query: ObservationRequest, accept: (snapshot: T) => void) => Promise, + signal?: AbortSignal, + failed?: () => void, + ): OwnedSubscription { + 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.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, @@ -20,100 +143,9 @@ export const DEFAULT_CLIENT_CAPABILITIES = { [CLIENT_CAPS.explicitEventSubscriptions]: true, } satisfies Record, true>; -/** Calling releases demand; ready acknowledges the initial daemon membership. */ -export type TimelineSubscription = (() => void) & { readonly ready: Promise }; - -class TimelineInterest { - resolve!: () => void; - reject!: (error: unknown) => void; - readonly ready = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - - constructor() { - // Readiness is optional for fire-and-forget listeners. - void this.ready.catch(() => {}); - } -} - -/** Owns connection demand, independently of individual facades and React lifetimes. */ -export class ConnectionSubscriptions { - private viewed = new Set(); - private timelines = new Map>(); - private events: SessionEventSubscription[] = []; - - constructor( - private readonly send: { - timelines(agentIds: string[]): Promise | null; - events(events: SessionEventSubscription[]): Promise; - failed(error: unknown): void; - }, - ) {} - - private agentIds(): string[] { - return [...new Set([...this.viewed, ...this.timelines.keys()])].sort(); - } - - setViewed(agentIds: string[]): Promise { - this.viewed = new Set(agentIds); - return this.syncTimelines(); - } - - observeTimeline(agentId: string): TimelineSubscription { - const interests = this.timelines.get(agentId) ?? new Set(); - const interest = new TimelineInterest(); - interests.add(interest); - this.timelines.set(agentId, interests); - // Each caller gets acknowledgement for its own registration, even when an - // existing listener first subscribed on an earlier connection. - void this.syncTimelines().catch(this.send.failed); - let active = true; - return Object.assign( - () => { - if (!active) return; - active = false; - interest.reject(new Error("Timeline subscription released before it was ready")); - interests.delete(interest); - if (interests.size === 0) { - this.timelines.delete(agentId); - void this.syncTimelines().catch(this.send.failed); - } - }, - { ready: interest.ready }, - ); - } - - private async syncTimelines(): Promise { - const interests: TimelineInterest[] = []; - for (const listeners of this.timelines.values()) { - for (const interest of listeners) interests.push(interest); - } - try { - const sent = this.send.timelines(this.agentIds()); - if (!sent) return; - await sent; - for (const interest of interests) interest.resolve(); - } catch (error) { - for (const interest of interests) interest.reject(error); - throw error; - } - } - - setEvents(events: SessionEventSubscription[]): void { - if (JSON.stringify(this.events) === JSON.stringify(events)) return; - this.events = events; - void this.send.events(events).catch(this.send.failed); - } - - restore(): void { - if (this.agentIds().length) void this.syncTimelines().catch(this.send.failed); - if (this.events.length) void this.send.events(this.events).catch(this.send.failed); - } - - close(): void { - for (const interests of this.timelines.values()) { - for (const interest of interests) interest.reject(new Error("Daemon client closed")); - } - } -} +/** Calling releases demand; ready waits for membership, or local attachment on broadcast hosts. */ +export type TimelineSubscription = (() => void) & { + readonly ready: Promise; + readonly subscriptionId: string | null; + release(): Promise; +}; 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; + +// 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(); + + 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 { + const workspaces = new Map(); + 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; +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(); + + private readonly workspaces: LegacyWorkspaces | null; + constructor( + private readonly info: ServerInfoStatusPayload, + private readonly send: (message: ObservationRequest) => Promise, + 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; + } { + 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(); + 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( + (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 & { subscriptionId: string }; + +export interface SubscriptionObserver { + 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 { + readonly subscriptionId: string | null; + readonly ready: Promise>; + subscribe(observer: SubscriptionObserver>): () => void; + release(): Promise; +} + +interface Registration { + start(): void; + disconnected(): void; + receive(message: SessionOutboundMessage): void; + release(): Promise; +} + +export interface OwnedSubscriptionsHost { + release(subscriptionId: string): Promise; + 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(); + private readonly routes = new Map(); + private generation = 0; + private connected = false; + + constructor(private readonly host: OwnedSubscriptionsHost) {} + + observe( + request: (accept: (snapshot: T) => void) => Promise, + signal?: AbortSignal, + requestFailed?: () => void, + ): OwnedSubscription { + let id: string | null = null; + let snapshot: SubscriptionSnapshot | undefined; + let pending: Promise | null = null; + let teardown: Promise | null = null; + let disposed = false; + let overflow = false; + const buffered: SessionOutboundMessage[] = []; + const observers = new Set>>(); + let resolve!: (snapshot: SubscriptionSnapshot) => void; + let reject!: (error: unknown) => void; + const ready = new Promise>((accept, fail) => { + resolve = accept; + reject = fail; + }); + void ready.catch(() => {}); + + const deliver = ( + observer: SubscriptionObserver>, + 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 = { + 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 { + 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..40828d4bfb --- /dev/null +++ b/packages/client/src/creation/index.test.ts @@ -0,0 +1,234 @@ +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 }> = []; + const legacy: Array<{ kind: string; input: unknown }> = []; + let resolve!: (result: CreationResult) => void; + const result = new Promise((done) => { + resolve = done; + }); + const client = new CreationClient({ + supports: () => modern, + 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; + }, + sendMessage: async (id, text, options) => { + legacy.push({ kind: "message", input: { id, text, ...options } }); + }, + }); + 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, + idempotencyKey: "intent-one:agent", + clientMessageId: "message-one", + }, + }, + { + kind: "message", + input: { + id: agent.id, + text: "Start once", + messageId: "message-one", + images: undefined, + attachments: undefined, + }, + }, + ]); + 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 } }, + ]); + 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..4ae64c34ff --- /dev/null +++ b/packages/client/src/creation/index.ts @@ -0,0 +1,276 @@ +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: () => boolean; + requestId: () => string; + request: (kind: Kind, input: Record) => Promise; + observe: ( + kind: Kind, + key: string, + next: (snapshot: CreationSnapshot | null) => void, + error: (error: unknown) => void, + ) => () => void; + legacyAgent: (input: CreateAgentRequestOptions) => Promise; + legacyWorkspace: ( + input: CreateWorkspaceRequestOptions, + ) => Promise; + sendMessage: ( + id: string, + text: string, + options: { + messageId: string; + images?: CreateAgentRequestOptions["images"]; + attachments?: CreateAgentRequestOptions["attachments"]; + }, + ) => Promise; +} +interface Operation { + kind: Kind; + key: string; + fingerprint: string; + input: Record; + observers: Set; + snapshot?: CreationSnapshot; + result: Promise; + 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(); + constructor(private readonly deps: Dependencies) {} + + createAgent(input: CreateAgentRequestOptions): Promise { + const { onEvent, ...request } = input; + return this.start("agent", request, onEvent, () => this.legacyAgent(request)); + } + createWorkspace(input: CreateWorkspaceRequestOptions): Promise { + 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 { + if (input.agentId) throw new Error("Update the host to use caller-selected creation IDs."); + // COMPAT(creationLifecycle): added in v0.8.0, remove after 2027-03-11 once the daemon floor supports creationLifecycle. + // Old keyed creates reject initialPrompt. Keep this adaptation private to the SDK. + if (input.idempotencyKey) { + const { initialPrompt = "", images, attachments, ...creation } = input; + const hasPrompt = Boolean(initialPrompt.trim() || images?.length || attachments?.length); + if (hasPrompt && input.outputSchema) + throw new Error("Update the host to create a keyed agent with structured initial output."); + const agent = await this.deps.legacyAgent(creation); + if (hasPrompt) + await this.deps.sendMessage(agent.id, initialPrompt, { + messageId: input.clientMessageId ?? `${input.idempotencyKey}:initial-message`, + images, + attachments, + }); + return { agent, error: null }; + } + return { agent: await this.deps.legacyAgent(input), error: null }; + } + + private start( + kind: Kind, + input: Record, + observer: Observer | undefined, + legacy: (operation: Operation) => Promise, + ): Promise { + 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((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(), + 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 { + 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-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.test.ts b/packages/client/src/daemon-client.test.ts index d03fd7c754..72a649d664 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -14,9 +14,6 @@ import { FileTransferOpcode, } from "@getpaseo/protocol/binary-frames/index"; import { - asUint8Array, - decodeTerminalResizePayload, - decodeTerminalStreamFrame, encodeTerminalSnapshotPayload, encodeTerminalStreamFrame, TerminalStreamOpcode, @@ -79,7 +76,7 @@ function createMockTransport() { }, close: () => {}, onMessage: (handler) => { - onMessage = handler; + onMessage = (data) => handler(data, typeof data !== "string"); return () => {}; }, onOpen: (handler) => { @@ -115,7 +112,7 @@ function createMockTransport() { serverId: `srv_test_${serverInfoOrdinal++}`, hostname: null, version: null, - ...(options?.features ? { features: options.features } : {}), + features: options?.features ?? { ownedSubscriptions: true }, }, }, }), @@ -255,7 +252,7 @@ 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(); @@ -285,7 +282,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({ @@ -331,57 +328,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 () => { @@ -444,8 +419,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()); @@ -456,19 +436,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) => { @@ -760,10 +746,12 @@ test("advertises client capabilities in hello", async () => { 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, @@ -797,7 +785,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); @@ -1012,6 +1000,49 @@ test("ensureConnected reconnects immediately without leaving the scheduled retry } }); +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 { @@ -1469,7 +1500,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({ @@ -1740,6 +1771,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(); @@ -3642,16 +3745,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" }); @@ -3680,7 +3781,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(); @@ -3701,7 +3802,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", @@ -3712,9 +3813,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, @@ -3731,10 +3831,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 () => { @@ -4279,7 +4376,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"; @@ -4381,29 +4478,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 () => { @@ -4430,10 +4550,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([ @@ -4441,7 +4561,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({ @@ -4465,6 +4585,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, @@ -5310,11 +5434,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, @@ -5362,11 +5490,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, @@ -5394,7 +5526,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(); @@ -5411,11 +5543,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, @@ -5437,19 +5573,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 () => { @@ -5477,11 +5612,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, @@ -5491,11 +5630,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, @@ -5531,14 +5674,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 () => { @@ -5566,11 +5714,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, @@ -5593,6 +5745,7 @@ test("ignores terminal stream frames after terminal_stream_exit", async () => { wrapSessionMessage({ type: "terminal_stream_exit", payload: { + subscriptionId: "server-sub-4", terminalId: "term-1", }, }), @@ -5607,6 +5760,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(); }); @@ -5952,24 +6120,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 () => { @@ -6264,3 +6449,169 @@ test("wire snapshot callers own expansion and receive hash references unchanged" ); 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"]); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 14341f7605..653ceb28ea 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,12 +1,16 @@ +import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; -import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; 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 { @@ -133,7 +137,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, @@ -369,6 +372,8 @@ export interface AgentAttentionRequiredNotification { type AgentConfigOverrides = Partial>; export interface CreateAgentRequestOptions extends AgentConfigOverrides { + agentId?: string; + onEvent?: (snapshot: CreationSnapshot) => void; config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; @@ -391,6 +396,20 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { labels?: Record; } +export interface CreateWorkspaceRequestOptions { + source: WorkspaceCreateRequest["source"]; + title?: 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" @@ -663,6 +682,7 @@ type FetchAgentsPayload = Extract< >["payload"]; type FetchAgentsRequest = Extract; export type FetchAgentsOptions = Omit & { + signal?: AbortSignal; requestId?: string; timeout?: number; }; @@ -702,6 +722,7 @@ type FetchWorkspacesPayload = Extract< >["payload"]; type FetchWorkspacesRequest = Extract; export type FetchWorkspacesOptions = Omit & { + signal?: AbortSignal; requestId?: string; }; export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number]; @@ -882,13 +903,25 @@ type SetDaemonConfigResponse = Extract< >; type CorrelatedResponseMessage = | Extract + | Extract | GetDaemonConfigResponse | SetDaemonConfigResponse; type CorrelatedResponseType = CorrelatedResponseMessage["type"]; -type CorrelatedResponsePayload = Extract< - CorrelatedResponseMessage, - { type: TType } ->["payload"]; +type CorrelatedResponsePayloads = { + [Message in CorrelatedResponseMessage as Message["type"]]: Message["payload"]; +}; +type CorrelatedResponsePayload = + 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; @@ -1085,15 +1118,30 @@ interface PingProbe { 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 subscriptions = new ConnectionSubscriptions({ - timelines: (agentIds) => (this.isConnected ? this.sendTimelineSubscription(agentIds) : null), - events: (events) => this.sendEventSubscription(events), - failed: (error) => - this.logger.error({ err: error }, "Failed to update connection subscriptions"), + 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 transportCleanup: Array<() => void> = []; @@ -1116,18 +1164,6 @@ export class DaemonClient { private connectReject: ((error: Error) => void) | null = null; private lastErrorValue: string | 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(); - private fileSubscriptions = new Map< - string, - { cwd: string; path: string; onUpdate: (version: FileVersion) => void } - >(); private readonly terminalStreams = new TerminalStreamRouter(); private pendingBinaryFileReads = new Map(); private activeBinaryFileTransfers = new Map(); @@ -1142,6 +1178,7 @@ export class DaemonClient { private runtimeMetricsInterval: ReturnType | null = null; private runtimeMetrics: DaemonClientRuntimeMetrics | null = null; private pingProbe: PingProbe | null = null; + private connectionVerification: DaemonTransport | null = null; private livenessHeartbeatTimer: ReturnType | null = null; private lastLivenessRttMs: number | null = null; private consecutiveLivenessFailures = 0; @@ -1230,6 +1267,12 @@ export class DaemonClient { if (this.connectionState.status === "connecting") { return; } + // 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 headers: Record = {}; const password = normalizePassword(this.config.password); @@ -1357,7 +1400,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(); @@ -1395,7 +1440,6 @@ export class DaemonClient { return; } this.shouldReconnect = false; - this.subscriptions.close(); this.connectPromise = null; this.connectResolve = null; this.connectReject = null; @@ -1407,10 +1451,11 @@ export class DaemonClient { 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); @@ -1424,23 +1469,18 @@ 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.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } + if (this.connectionState.status === "connecting") return; if (this.connectPromise) { this.attemptConnect(); return; @@ -1448,6 +1488,28 @@ export class DaemonClient { 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; } @@ -1482,10 +1544,8 @@ export class DaemonClient { subscribe(handler: DaemonEventHandler): () => void { this.eventListeners.add(handler); - this.updateEventSubscriptions(); return () => { this.eventListeners.delete(handler); - this.updateEventSubscriptions(); }; } @@ -1516,7 +1576,6 @@ export class DaemonClient { this.messageHandlers.set(type, new Set()); } this.messageHandlers.get(type)!.add(handler); - this.updateEventSubscriptions(); return () => { const handlers = this.messageHandlers.get(type); @@ -1527,7 +1586,6 @@ export class DaemonClient { if (handlers.size === 0) { this.messageHandlers.delete(type); } - this.updateEventSubscriptions(); }; } @@ -1590,7 +1648,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", @@ -1672,7 +1730,12 @@ 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 }); @@ -1680,7 +1743,7 @@ export class DaemonClient { } // 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})`)); } /** @@ -1698,7 +1761,7 @@ export class DaemonClient { this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); 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))); @@ -1726,6 +1789,7 @@ export class DaemonClient { select: (msg: SessionOutboundMessage) => T | null; options?: { skipQueue?: boolean }; }): Promise { + const wire = this.owned.prepareRequest(params.message); const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS; const { promise, cancel } = this.waitForWithCancel>( (msg) => { @@ -1740,7 +1804,7 @@ export class DaemonClient { }), }; } - const value = params.select(msg); + const value = params.select(wire.receive(msg)); if (value === null) { return null; } @@ -1751,7 +1815,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); @@ -1759,11 +1823,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< @@ -1845,7 +1911,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 { @@ -2093,7 +2159,117 @@ export class DaemonClient { // Agent RPCs (requestId-correlated) // ============================================================================ - async fetchAgents(options?: FetchAgentsOptions): Promise { + private observe( + responseType: T, + message: { type: SessionInboundMessage["type"] } & Record, + options?: { requestId?: string; timeout?: number; signal?: AbortSignal }, + ): OwnedSubscription> { + let resetSource = false; + return this.owned.observeRequest>( + message, + async (query, accept) => { + resetSource = false; + const requestId = this.createRequestId(options?.requestId); + const request = SessionInboundMessageSchema.parse({ ...query, requestId }); + try { + return await this.sendCorrelatedRequest>({ + message: request, + responseType, + requestId, + timeout: options?.timeout, + options: { skipQueue: true }, + selectPayload: (payload): CorrelatedResponsePayload | 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, + "type" | "requestId" + >, + options?: { signal?: AbortSignal }, + ): OwnedSubscription> { + return this.observe( + "browser.host.register.response", + { type: "browser.host.register.request", ...registration }, + options, + ); + } + + observeEvents( + events: SessionEventSubscription[], + options?: { signal?: AbortSignal; notifications?: boolean }, + ): OwnedSubscription> { + return this.observe( + "session.events.set_subscription.response", + { + type: "session.events.set_subscription.request", + events, + notifications: options?.notifications, + }, + options, + ); + } + + observeAgents( + options: Omit = {}, + ): OwnedSubscription { + const { signal, requestId, timeout, ...query } = options; + return this.observe( + "fetch_agents_response", + { ...query, type: "fetch_agents_request", subscribe: {} }, + { signal, requestId, timeout }, + ); + } + + observeWorkspaces( + options: Omit = {}, + ): OwnedSubscription { + const { signal, requestId, ...query } = options; + return this.observe( + "fetch_workspaces_response", + { ...query, type: "fetch_workspaces_request", subscribe: {} }, + { signal, requestId }, + ); + } + + fetchAgents( + options: FetchAgentsOptions & { subscribe: {} }, + ): Promise }>; + fetchAgents(options?: FetchAgentsOptions): Promise; + async fetchAgents( + options?: FetchAgentsOptions, + ): Promise }> { + 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", @@ -2177,7 +2353,21 @@ export class DaemonClient { }); } - async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise { + fetchWorkspaces( + options: FetchWorkspacesOptions & { subscribe: {} }, + ): Promise }>; + fetchWorkspaces(options?: FetchWorkspacesOptions): Promise; + async fetchWorkspaces( + options?: FetchWorkspacesOptions, + ): Promise< + FetchWorkspacesPayload & { subscription?: OwnedSubscription } + > { + 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", @@ -2204,21 +2394,31 @@ export class DaemonClient { }); } - listWorkspaceLabels(options: { - subscriptionId: string; - sync?: { generation: string; afterSeq: number }; - requestId?: string; - }): Promise { + listWorkspaceLabels( + options: { + sync?: { generation: string; afterSeq: number }; + requestId?: string; + } = {}, + ): Promise { 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 { + return this.observe( + "workspace.label.list.response", + { type: "workspace.label.list.request", subscribe: {} }, + options, + ); + } + setWorkspaceLabel(options: { workspaceId: string; label: Extract< @@ -2506,58 +2706,62 @@ 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: () => this.lastServerInfoMessage?.features?.creationLifecycle === 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), + sendMessage: (id, text, options) => this.sendMessage(id, text, options), + }); + async createAgent(options: CreateAgentRequestOptions): Promise { + 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 { if (options.idempotencyKey !== undefined) this.requireAgentRequestReceipts(); const requestId = this.createRequestId(options.requestId); const config = resolveAgentConfig(options); @@ -3053,6 +3257,10 @@ export class DaemonClient { subagentId: string, options: FetchProviderSubagentTimelineOptions = {}, ): Promise { + // 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", @@ -3080,98 +3288,26 @@ export class DaemonClient { return payload; } - setAgentTimelineSubscription(agentIds: string[]): Promise { - return this.subscriptions.setViewed(agentIds); + observeTimeline( + agentIds: string[], + options?: { signal?: AbortSignal }, + ): OwnedSubscription> { + return this.observe( + "agent.timeline.set_subscription.response", + { type: "agent.timeline.set_subscription.request", agentIds }, + options, + ); } subscribeAgentTimeline( agentId: string, - handler: ( - message: Extract< - SessionOutboundMessage, - { type: "agent_stream" | "agent.timeline.replacement" } - >, - ) => void, + handler: (message: TimelineMessage) => void, ): TimelineSubscription { - const stream = this.on("agent_stream", (message) => { - if (message.payload.agentId === agentId) handler(message); - }); - const replacement = this.on("agent.timeline.replacement", (message) => { - if (message.payload.agentId === agentId) handler(message); - }); - const release = this.subscriptions.observeTimeline(agentId); - return Object.assign( - () => { - stream(); - replacement(); - release(); - }, - { ready: release.ready }, + return subscribeTimeline(agentId, this.observeTimeline([agentId]), handler, (error) => + this.logger.error({ err: error }, "Timeline observation failed"), ); } - private updateEventSubscriptions(): void { - const events: SessionEventSubscription[] = [ - "project.update", - "providers_snapshot_update", - "agent_attention_required", - "agent_permission_request", - "agent_permission_resolved", - ]; - if (this.eventListeners.size === 0 && !this.messageHandlers.has("providers_snapshot_update")) { - this.providerSnapshotUpdates.clear(); - } - this.subscriptions.setEvents( - events.filter( - (type) => this.eventListeners.size > 0 || (this.messageHandlers.get(type)?.size ?? 0) > 0, - ), - ); - } - - private async sendEventSubscription(events: SessionEventSubscription[]): Promise { - if (this.connectionState.status !== "connected") return; - // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcast handling after 2027-03-08. - if ( - !this.lastServerInfoMessage?.features?.explicitEventSubscriptions || - this.config.capabilities?.[CLIENT_CAPS.explicitEventSubscriptions] === false - ) - return; - await this.sendCorrelatedSessionRequest({ - message: { type: "session.events.set_subscription.request", events }, - responseType: "session.events.set_subscription.response", - }); - } - - private async sendTimelineSubscription(agentIds: string[]): Promise { - if (this.connectionState.status !== "connected") return; - // 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; - } - - 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; - }, - }); - } - async buildAgentForkContext( agentId: string, options: AgentForkContextOptions = {}, @@ -3463,7 +3599,11 @@ export class DaemonClient { return payload.notice ?? null; } - async restartServer(reason?: string, requestId?: string): Promise { + async restartServer( + reason?: string, + requestId?: string, + options?: { timeout?: number }, + ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "restart_server_request", @@ -3473,6 +3613,7 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, + timeout: options?.timeout, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { @@ -3876,79 +4017,31 @@ export class DaemonClient { compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, requestId?: string, ): Promise { - 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 { - 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 { + return this.observe( + "subscribe_checkout_diff_response", + { + type: "subscribe_checkout_diff_request", + cwd, + compare: this.normalizeCheckoutDiffCompare(compare), + }, + options, + ); } async checkoutCommit( @@ -4361,18 +4454,43 @@ export class DaemonClient { } async createWorkspace( - input: { - source: WorkspaceCreateRequest["source"]; - title?: string; - firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; - }, + input: CreateWorkspaceRequestOptions, requestId?: string, ): Promise { + const resolvedRequestId = this.createRequestId(requestId ?? input.requestId); + 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 { + // COMPAT(workspaceRequestReceipts): added in v0.8.0; remove gate after 2027-03-07. + if ( + input.idempotencyKey !== undefined && + !this.lastServerInfoMessage?.features?.workspaceRequestReceipts + ) { + throw new Error("Update the host to use retry-safe workspace creation."); + } return this.sendCorrelatedSessionRequest({ requestId, message: { type: "workspace.create.request", source: input.source, + ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}), ...(input.title !== undefined ? { title: input.title } : {}), ...(input.firstAgentContext !== undefined ? { firstAgentContext: input.firstAgentContext } @@ -4552,36 +4670,40 @@ export class DaemonClient { } } + observeFile(input: { + cwd: string; + path: string; + signal?: AbortSignal; + }): OwnedSubscription> { + 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>; + unsubscribe: () => Promise; + }> { + 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: { @@ -4845,6 +4967,10 @@ export class DaemonClient { } async getDaemonStatus(options?: DaemonStatusOptions): Promise { + 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: { @@ -5428,33 +5554,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> { + 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( @@ -5521,53 +5631,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 { - 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 { + 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 } + > { + 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 { @@ -5825,7 +5974,8 @@ export class DaemonClient { } private disposeTransport(code = 1001, reason = "Reconnecting"): void { - this.providerSnapshotUpdates.pause(); + this.owned.disconnected(); + this.providerSnapshotUpdates.clear(); this.stopLivenessHeartbeat(); this.cleanupTransport(); if (this.transport) { @@ -5871,10 +6021,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(() => { @@ -6137,13 +6288,14 @@ export class DaemonClient { this.lastErrorValue = reason.trim(); } - this.providerSnapshotUpdates.pause(); + 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; @@ -6237,15 +6389,14 @@ export class DaemonClient { } private handleSessionMessage(msg: SessionOutboundMessage): void { + msg = this.owned.normalize(msg); if ( msg.type === "providers_snapshot_update" && this.config.providerSnapshots !== "wire" && msg.payload.snapshotHash && !msg.payload.compactSnapshot ) { - if (this.messageHandlers.has(msg.type) || this.eventListeners.size > 0) { - this.providerSnapshotUpdates.receive(msg); - } + if (this.owned.owns(msg)) this.providerSnapshotUpdates.receive(msg); return; } this.deliverSessionMessage(msg); @@ -6266,27 +6417,17 @@ export class DaemonClient { this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.startLivenessHeartbeat(); - this.subscriptions.restore(); - this.providerSnapshotUpdates.resume(); - 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 { @@ -6315,18 +6456,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))); } } } @@ -6418,7 +6569,10 @@ export class DaemonClient { options?: WaitOptions, ): WaitHandle { // 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 | null = null; let settled = false; @@ -6491,6 +6645,15 @@ 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, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 7cf75b5806..9aed42febc 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 { @@ -86,11 +99,12 @@ async function connectClient( features: Record = { 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 }, }); @@ -121,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 { + const ready = client.agents.list({ subscribe: {} }); + acknowledgeObservation(ws, "agents-sdk"); + return (await ready).subscriptionId!; +} + function createWorkspace(input: Partial = {}): PaseoWorkspace { return { id: "workspace_sdk", @@ -231,6 +266,8 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own expect(Object.keys(paseo).sort()).toEqual([ "agents", "config", + "dispose", + "observeEvents", "projects", "providers", "terminals", @@ -344,11 +381,17 @@ test("project actions subscribe to existing project updates", async () => { 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", @@ -370,6 +413,7 @@ test("project actions subscribe to existing project updates", async () => { sessionMessage({ type: "project.update", payload: { + subscriptionId: "projects-sdk", kind: "remove", projectId: "project_removed", generation: "daemon-generation", @@ -385,6 +429,7 @@ test("project actions subscribe to existing project updates", async () => { sessionMessage({ type: "project.update", payload: { + subscriptionId: "projects-sdk", kind: "remove", projectId: "project_after_unsubscribe", }, @@ -522,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") { @@ -532,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"]); @@ -567,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" }), }, @@ -681,6 +727,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") { @@ -691,11 +738,7 @@ 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"]); @@ -959,11 +1002,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, @@ -1138,6 +1183,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", @@ -1158,6 +1205,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", @@ -1168,6 +1216,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", @@ -1184,11 +1233,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", @@ -1323,10 +1375,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: [ { @@ -1354,15 +1412,25 @@ 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(); }); @@ -1546,3 +1614,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 7068cdc0bf..e113d48667 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,7 +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, @@ -26,7 +29,7 @@ import type { WorkspaceDescriptorPayload, WorkspaceCreateRequest, } from "@getpaseo/protocol/messages"; -import { DaemonClient } from "./daemon-client.js"; +import { DaemonClient, type CreateAgentRequestOptions } from "./daemon-client.js"; import { createTerminalActions, type PaseoTerminalActions, @@ -114,6 +117,7 @@ export type PaseoProjectUpdate = Extract< export type PaseoProjectUpdateHandler = (update: PaseoProjectUpdate) => void; export interface PaseoAgentListResult { + subscription?: OwnedSubscription; requestId: string; subscriptionId?: string | null; entries: FetchAgentsEntry[]; @@ -127,6 +131,7 @@ export type PaseoWorkspaceListOptions = Omit< }; export interface PaseoWorkspaceListResult { + subscription?: OwnedSubscription; requestId: string; subscriptionId?: string | null; entries: PaseoWorkspace[]; @@ -138,8 +143,16 @@ export interface PaseoWorkspaceOpenOptions { requestId?: string; } -export type PaseoWorkspaceCreateOptions = Omit & { +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 { @@ -185,6 +198,12 @@ export interface PaseoProjectActions { } export interface PaseoWorkspaceActions { + list(options: PaseoWorkspaceListOptions & { subscribe: {} }): Promise< + PaseoWorkspaceListResult & { + subscriptionId: string; + subscription: OwnedSubscription; + } + >; list(options?: PaseoWorkspaceListOptions): Promise; ref(workspace: string | PaseoWorkspace): PaseoWorkspaceHandle; open( @@ -222,6 +241,9 @@ export interface PaseoAgentConfig { } export interface PaseoAgentCreateOptions { + idempotencyKey?: string; + agentId?: string; + onEvent?: (snapshot: CreationSnapshot) => void; config: PaseoAgentConfig; cwd: string; parent?: string | PaseoAgentHandle; @@ -289,7 +311,13 @@ export type PaseoAgentTimelineEvent = | { 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; @@ -302,8 +330,10 @@ export interface PaseoAgentTimelineHandle { */ refetch(options?: PaseoAgentTimelineRefetchOptions): Promise; /** - * Subscribe to this agent and restore demand after reconnect. A replacement - * event invalidates previously fetched history; refetch the page you need. + * 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. */ @@ -354,6 +384,12 @@ export interface PaseoAgentHandle { } export interface PaseoAgentActions { + list(options: PaseoAgentListOptions & { subscribe: {} }): Promise< + PaseoAgentListResult & { + subscriptionId: string; + subscription: OwnedSubscription; + } + >; list(options?: PaseoAgentListOptions): Promise; ref(agent: string | PaseoAgent): PaseoAgentHandle; create(options: PaseoAgentCreateOptions): Promise; @@ -450,6 +486,8 @@ export interface PaseoConfigActions { } export interface PaseoApi { + dispose(): Promise; + observeEvents: DaemonClient["observeEvents"]; readonly terminals: PaseoTerminalActions; readonly workspaces: PaseoWorkspaceActions; readonly projects: PaseoProjectActions; @@ -471,39 +509,88 @@ 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 }>(); + const agentListeners = new Set(); + const workspaceListeners = new Set(); + const lifetime = new AbortController(); + const own = }>(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 terminals = createTerminalActions(daemonClient, async (workspaceId) => { @@ -513,24 +600,123 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { } return workspace.workspaceDirectory; }); - const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent, terminals); + const createWorkspaceHandle = createWorkspaceHandleFactory( + daemonClient, + createAgent, + terminals, + listenWorkspaces, + ); + + let disposal: Promise | null = null; + const dispose = (): Promise => { + 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; + } + >; + function listWorkspaces(options?: PaseoWorkspaceListOptions): Promise; + async function listWorkspaces( + options?: PaseoWorkspaceListOptions, + ): Promise { + 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; + } + >; + function listAgents(options?: PaseoAgentListOptions): Promise; + async function listAgents(options?: PaseoAgentListOptions): Promise { + 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) => - daemonClient.on("project.update", (message) => { - handler(message.payload); - }), + 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"); } @@ -538,19 +724,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), @@ -561,14 +741,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), listUsage: (options) => listProviderUsage(daemonClient, options), - subscribe: (handler) => - daemonClient.on("providers_snapshot_update", (message) => { - handler(message.payload); - }), + subscribe: (handler) => { + return subscribeEvent("providers_snapshot_update", (message) => { + if (message.type === "providers_snapshot_update") handler(message.payload); + }); + }, }, config: { get: (requestId) => daemonClient.getDaemonConfig(requestId), @@ -588,6 +775,7 @@ function createWorkspaceHandleFactory( daemonClient: DaemonClient, createAgent: CreateAgent, terminals: PaseoTerminalActions, + listen: (handler: PaseoWorkspaceUpdateHandler) => () => void, ): WorkspaceHandleFactory { return (workspace) => { const id = typeof workspace === "string" ? workspace : workspace.id; @@ -654,14 +842,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); } }), @@ -669,7 +855,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; @@ -686,15 +876,27 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor return result; }, subscribe: (handler) => - daemonClient.subscribeAgentTimeline(id, (message) => { - handler( - message.type === "agent_stream" - ? message.payload - : { - agentId: message.payload.agentId, - event: { type: "replacement", epoch: message.payload.epoch }, - }, - ); + 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 }, + }); + } }), }, get workspaceId() { @@ -779,14 +981,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); } }), @@ -840,86 +1040,96 @@ function listProviderUsage( return daemonClient.listProviderUsage(options); } -function waitForProvidersReady( +async function waitForProvidersReady( daemonClient: DaemonClient, + observation: ReturnType, + signal: AbortSignal, options: PaseoProviderWaitOptions = {}, ): Promise { - // 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(); - let latestEntries: PaseoProviderSnapshotResult["entries"] = []; + try { + await observation.ready; + signal.throwIfAborted(); + return await new Promise((resolve, reject) => { + let settled = false; + let requestId: string | null = null; + let snapshotCwd: string | undefined; + const pendingUpdates = new Map(); + 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/provider-snapshots/index.ts b/packages/client/src/provider-snapshots/index.ts index 7450a61639..b73a88e944 100644 --- a/packages/client/src/provider-snapshots/index.ts +++ b/packages/client/src/provider-snapshots/index.ts @@ -13,11 +13,11 @@ interface PendingSnapshot { /** Resolve announcements only while observed, coalescing changes during a fetch. */ export class ProviderSnapshotUpdates { - private pending = new Map(); - private paused = false; + 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; @@ -28,35 +28,25 @@ export class ProviderSnapshotUpdates { this.pending.clear(); } - pause(): void { - this.paused = true; - // Invalidate old responses while retaining the announcements they would resolve. - for (const [cwd, pending] of this.pending) { - this.pending.set(cwd, { latest: pending.latest, running: false }); - } - } - - resume(): void { - this.paused = false; - for (const pending of this.pending.values()) this.receive(pending.latest); - } - receive(update: Update): void { - const cwd = update.payload.cwd; - const pending = this.pending.get(cwd) ?? { latest: update, running: false }; + 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(cwd, pending); - if (this.paused || pending.running) return; + this.pending.set(key, pending); + if (pending.running) return; pending.running = true; - void this.resolve(cwd, pending); + void this.resolve(key, pending); } - private async resolve(cwd: string | undefined, pending: PendingSnapshot): Promise { + private async resolve(key: string, pending: PendingSnapshot): Promise { try { - while (this.pending.get(cwd) === pending) { + while (this.pending.get(key) === pending) { const announced = pending.latest; - const snapshot = await this.host.fetch(cwd); - if (this.pending.get(cwd) !== pending) return; + 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 ( @@ -64,14 +54,17 @@ export class ProviderSnapshotUpdates { pending.latest.payload.snapshotHash !== snapshot.snapshotHash ) continue; - this.pending.delete(cwd); - this.host.emit({ type: "providers_snapshot_update", payload: snapshot }); + this.pending.delete(key); + this.host.emit({ + type: "providers_snapshot_update", + payload: { ...snapshot, subscriptionId: announced.payload.subscriptionId }, + }); return; } } catch (error) { - if (this.pending.get(cwd) === pending) this.host.failed(error); + if (this.pending.get(key) === pending) this.host.failed(error); } finally { - if (this.pending.get(cwd) === pending) this.pending.delete(cwd); + 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 index 23cadbe6a9..2e9a51a93f 100644 --- a/packages/client/src/terminals/index.test.ts +++ b/packages/client/src/terminals/index.test.ts @@ -11,6 +11,8 @@ test("an older host rejects terminal operations before sending requests", async getLastServerInfoMessage: () => ({ status: "server_info", serverId: "older-host", + hostname: null, + version: null, features: {}, }), createTerminal: unexpectedRequest, 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..4beed26643 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 = {}) { @@ -2115,7 +2120,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"), { diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index f0c34060c9..40de9de6e2 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -198,7 +198,14 @@ async function startTargetPage() { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end(` - Desktop browser target + Desktop browser target + + + @@ -233,20 +240,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) { @@ -366,7 +359,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); } @@ -383,17 +377,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) { @@ -464,7 +453,122 @@ 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 runRegression({ + page, + client, + serverId, + targetUrl, + callerAgentId, + artifactDir, + inspectorPort, +}) { const failures = []; const originalWorkspaceId = workspaceIds[0]; const originalWorkspaceRow = page.getByTestId( @@ -492,7 +596,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 }, @@ -519,10 +626,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 }, ); @@ -531,6 +639,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( @@ -606,7 +720,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)}`); } @@ -719,6 +833,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), @@ -886,6 +1002,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, guestFocus: "passed", overlayPlane: "passed", inactiveCapture: "passed", + hiddenWindowCapture: "passed", list: "passed", snapshot: "passed", click: "passed", @@ -904,7 +1021,8 @@ 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] = await Promise.all([ + reservePort(), reservePort(), reservePort(), reservePort(), @@ -942,6 +1060,7 @@ async function main() { 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(); @@ -996,6 +1115,7 @@ async function main() { client, serverId: status.serverId, targetUrl: target.url, + inspectorPort, callerAgentId, artifactDir, }); 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..82f94d5d2e --- /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"; + +// 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..459877351c --- /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, + startDaemonInstance, + stopDaemonInstance, + readDaemonInstance, +} from "@getpaseo/server"; + +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..ac40c7a3f6 100644 --- a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts +++ b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts @@ -110,6 +110,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); @@ -123,46 +146,3 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page } await page.keyboard.press("Escape"); 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); - - 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); -}); 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 b07b75df4d..385c99fa09 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -5,6 +5,7 @@ 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 EXECUTABLE_NAME = "Paseo"; const SMOKE_TIMEOUT_MS = 60_000; @@ -67,52 +68,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 +123,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, @@ -315,7 +277,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; @@ -323,13 +285,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")); @@ -656,24 +620,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", }); @@ -805,10 +760,88 @@ 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") { + 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-"); @@ -832,7 +865,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", @@ -884,12 +917,17 @@ async function smokePackagedDesktopApp({ appPath }) { console.log("Packaged desktop smoke: renderer-started desktop daemon reported running"); 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/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/electron-builder.yml b/packages/desktop/electron-builder.yml index 5fa62ef91a..fb01c7b194 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -66,7 +66,13 @@ linux: - 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 84d71dfafa..1816bd9cb2 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.8.0-beta.1", + "version": "0.8.0", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", @@ -17,14 +17,15 @@ "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" }, "dependencies": { "@getpaseo/cli": "*", @@ -35,6 +36,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@electron/asar": "3.4.1", "@types/node": "24.6.0", "@types/ws": "^8.5.14", "electron": "44.2.0", @@ -43,5 +45,6 @@ "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/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 8ccd2d1062..1bd3beed33 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 { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { 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, }, @@ -67,7 +67,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 +77,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 +94,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", () => { diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 4d3e5531eb..c54a252d81 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -1,9 +1,16 @@ -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, + type DaemonInstance, +} from "@getpaseo/server"; import { copyAttachmentFileToManagedStorage, deleteManagedAttachmentFile, @@ -37,6 +44,7 @@ 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 { @@ -46,9 +54,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 = [ @@ -74,6 +80,8 @@ export interface DesktopDaemonStatus { home: string; version: string | null; desktopManaged: boolean; + ownedByDesktop: boolean; + startedAt: string | null; error: string | null; } @@ -123,73 +131,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 { @@ -204,12 +158,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, @@ -222,25 +170,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, }; } @@ -273,23 +223,28 @@ export async function resolveDesktopDaemonStatus(): Promise const home = getPaseoHome(); try { - const payload = (await runExternalCliJsonCommand(["daemon", "status", "--json"])) as Record< - string, - unknown - >; + 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 }); return { serverId: "", - status: "stopped", + status: "errored", listen: null, hostname: null, pid: null, home, version: null, desktopManaged: false, + ownedByDesktop: false, + startedAt: null, error: errorMessage, }; } @@ -302,7 +257,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); @@ -314,40 +269,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()); @@ -360,7 +281,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()), @@ -372,118 +293,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 { @@ -495,7 +367,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 }> { @@ -528,9 +400,20 @@ export function createDaemonCommandHandlers(): Record resolveDesktopDaemonStatus(), 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, diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index c58c3f05e4..f38e679d3e 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -177,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/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/features/browser-automation/ipc.test.ts b/packages/desktop/src/features/browser-automation/ipc.test.ts index dba7581964..ee7143adc6 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 { @@ -190,7 +197,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 +220,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 +253,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 +758,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..42c38d2e2c 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,8 +105,11 @@ 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; } @@ -128,8 +131,17 @@ 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), getConsoleMessages: () => consoleMessagesByContentsId.get(contentsId) ?? [], captureDialogs: (task) => dialogMonitor.capture(task), @@ -143,6 +155,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..dee624a3f7 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -33,10 +33,13 @@ 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 inputEvents: IsolatedKeyboardInputEvent[] = []; private readonly captureStartWaiters: Array<() => void> = []; @@ -64,10 +67,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 +118,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 +167,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); } @@ -1557,7 +1559,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 +1586,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 +1631,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..ca6247e543 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -35,8 +35,9 @@ 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; getConsoleMessages?(): BrowserAutomationConsoleLogEntry[]; captureDialogs?( @@ -50,10 +51,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 +94,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 +165,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 +193,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( @@ -1199,6 +1235,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-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/main.ts b/packages/desktop/src/main.ts index 64cf4a9519..63ab83281a 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -330,13 +330,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(). @@ -349,6 +342,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, diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 8d25b8a1f9..cc5546b80f 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.8.0-beta.1", + "version": "0.8.0", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index eabc12f4b0..11a51ce138 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.8.0-beta.1", + "version": "0.8.0", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 4f054cf37b..5529abe19f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -65,8 +65,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/protocol": "0.8.0", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -76,8 +76,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/protocol": "0.8.0", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/plugin/src/server/contracts.ts b/packages/plugin/src/server/contracts.ts index a296c28b1e..c1cf10fc15 100644 --- a/packages/plugin/src/server/contracts.ts +++ b/packages/plugin/src/server/contracts.ts @@ -9,10 +9,27 @@ 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, - ): void; + ): PluginSettings; handle( contract: PluginRpcContract, handler: ( diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index c673f25303..9e1d78b8bb 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -2,6 +2,8 @@ export type { PluginHandlerContext, PluginServerContext, PluginServerContribution, + PluginSettings, + PluginSettingsState, } from "./contracts.js"; export type { PluginHookContext, diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 63210e1212..11431461cf 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 83932d3506..ea412c16be 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -102,6 +102,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; 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 f60bf40a34..68488f4d66 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,4 +1,6 @@ export const CLIENT_CAPS = { + // 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", @@ -22,6 +24,8 @@ 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 diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 35258d8d71..517bec615b 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -56,6 +56,7 @@ import { LoopStopResponseSchema, } from "./loop/rpc-schemas.js"; import { + BROWSER_AUTOMATION_COMMAND_NAMES, BrowserAutomationExecuteRequestSchema, BrowserAutomationExecuteResponseSchema, } from "./browser-automation/rpc-schemas.js"; @@ -1036,7 +1037,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({ @@ -1680,7 +1681,7 @@ export type CreateAgentWorktreeTarget = 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() }), + 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, @@ -3174,6 +3253,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, CheckoutStatusRequestSchema, + CheckoutDiffGetRequestSchema, SubscribeCheckoutDiffRequestSchema, UnsubscribeCheckoutDiffRequestSchema, CheckoutCommitRequestSchema, @@ -3217,6 +3297,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProjectGithubCloneRequestSchema, ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, + AgentCreateRequestSchema, + CreationSubscribeRequestSchema, WorkspaceClearAttentionRequestSchema, WorkspaceMarkUnreadRequestSchema, FileExplorerRequestSchema, @@ -3281,6 +3363,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"]), @@ -3442,6 +3525,9 @@ export const ServerInfoStatusPayloadSchema = 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(), providersSnapshot: z.boolean().optional(), @@ -3541,6 +3627,8 @@ 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. @@ -3575,6 +3663,7 @@ export const ServerInfoStatusPayloadSchema = z // 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(), // COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31. @@ -3943,6 +4032,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(), @@ -3950,6 +4040,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(), @@ -3961,6 +4052,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(), @@ -4107,12 +4199,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. @@ -4141,6 +4235,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, }), @@ -4149,6 +4244,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(), @@ -4156,6 +4252,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(), @@ -4198,12 +4295,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(), @@ -4224,6 +4323,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), }), @@ -4232,6 +4332,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, @@ -4493,6 +4594,7 @@ 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(), }), @@ -4570,6 +4672,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(), @@ -4585,6 +4690,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(), @@ -4595,10 +4710,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(), @@ -4609,6 +4726,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(), @@ -4619,6 +4737,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(), }), @@ -4627,6 +4746,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(), @@ -4678,9 +4798,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(), @@ -4910,6 +5084,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, }), @@ -4918,6 +5093,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, @@ -5158,6 +5334,7 @@ const CheckoutPrStatusPayloadSchema = z.object({ }); const CheckoutStatusUpdateMetadataSchema = z.object({ + subscriptionId: z.string().optional(), prStatus: CheckoutPrStatusPayloadSchema.optional(), }); @@ -5188,6 +5365,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, @@ -5952,6 +6136,7 @@ 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(), @@ -6117,6 +6302,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 })), }), @@ -6146,6 +6334,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(), }), @@ -6179,13 +6368,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(), @@ -6264,6 +6457,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, @@ -6273,6 +6467,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, @@ -6482,6 +6677,8 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({ }); export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ + BrowserHostRegisterResponseSchema, + SubscriptionReleaseResponseSchema, SessionEventsSetSubscriptionResponseSchema, HubExecutionAgentCreateResponseSchema, HubExecutionAgentValidateResponseSchema, @@ -6569,6 +6766,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, + AgentCreateResponseSchema, + WorkspaceCreationUpdateSchema, + AgentCreationUpdateSchema, + CreationSubscribeResponseSchema, WorkspaceClearAttentionResponseSchema, WorkspaceMarkUnreadResponseSchema, SendAgentMessageResponseMessageSchema, @@ -6608,6 +6809,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CloseItemsResponseSchema, CheckoutStatusResponseSchema, CheckoutStatusUpdateSchema, + CheckoutDiffGetResponseSchema, SubscribeCheckoutDiffResponseSchema, CheckoutDiffUpdateSchema, CheckoutCommitResponseSchema, diff --git a/packages/relay/package.json b/packages/relay/package.json index 4805058da4..df9d3b974d 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", 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 88b0631a62..3f03281b72 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.8.0-beta.1", + "version": "0.8.0", "description": "Paseo backend server", "files": [ "dist/server", @@ -71,11 +71,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.8.0-beta.1", - "@getpaseo/highlight": "0.8.0-beta.1", - "@getpaseo/plugin": "0.8.0-beta.1", - "@getpaseo/protocol": "0.8.0-beta.1", - "@getpaseo/relay": "0.8.0-beta.1", + "@getpaseo/client": "0.8.0", + "@getpaseo/highlight": "0.8.0", + "@getpaseo/plugin": "0.8.0", + "@getpaseo/protocol": "0.8.0", + "@getpaseo/relay": "0.8.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", 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/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index b379e6179f..c929281525 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -18,13 +18,11 @@ 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 +31,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 { @@ -116,7 +115,6 @@ async function main(): Promise { try { await acquirePidLock(paseoHome, null, { ownerPid: process.pid, - reclaimStaleDesktopLock: config.reclaimStalePidLock, }); } catch (error) { if (error instanceof PidLockError) { @@ -177,6 +175,7 @@ async function main(): Promise { onWorkerReady: async ({ listen }) => { await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid }); }, + onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }), onSupervisorExit: releaseLock, }); requestSupervisorShutdown = supervisor.requestShutdown; diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 9b4c622860..3eb5af9b32 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -36,7 +36,10 @@ async function runSupervisorFixture(options: { const workerPath = path.join(tempDir, "worker.mjs"); const runnerPath = path.join(tempDir, "runner.mjs"); - await writeFile(workerPath, options.workerSource); + await writeFile( + workerPath, + `process.send?.({ type: "paseo:ready", listen: "fixture" });\n${options.workerSource}`, + ); await writeFile( runnerPath, ` diff --git a/packages/server/scripts/supervisor.readiness.test.ts b/packages/server/scripts/supervisor.readiness.test.ts new file mode 100644 index 0000000000..297299d810 --- /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" }); + 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" }, () => 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..5d48491f2a 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -51,6 +51,7 @@ interface SupervisorOptions { env?: NodeJS.ProcessEnv; } | null; onWorkerReady?: (message: { listen: string }) => Promise | void; + onWorkerExit?: () => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; logFile?: SupervisorLogFileOptions; @@ -129,6 +130,8 @@ 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); @@ -244,6 +247,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 +281,21 @@ 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 })) + .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 +311,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 +419,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); 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/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 7256a0669d..15ad9db90e 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -354,46 +354,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", () => { diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index da3318c1a3..3bcab550e7 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -230,12 +230,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 +248,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 +259,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), diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts index e4943a079d..0f58d94c62 100644 --- a/packages/server/src/server/agent/agent-loading.test.ts +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -69,7 +69,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), 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 1e18ba9de4..2abdf65040 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -19,6 +19,7 @@ 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 { 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 +37,7 @@ import type { AgentProvider, AgentPersistenceHandle, AgentRunOptions, + AgentResumeSessionOptions, AgentRunResult, AgentSession, AgentSessionConfig, @@ -68,6 +70,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 +527,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, @@ -3677,6 +3711,201 @@ 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); + 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("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"); @@ -4867,14 +5096,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(); @@ -5157,14 +5386,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", }); @@ -5238,21 +5470,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" }, }, ]); }); @@ -5314,7 +5539,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); @@ -5371,8 +5596,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", ); @@ -5395,18 +5620,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( [ @@ -5442,28 +5664,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, @@ -5500,14 +5728,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", }); @@ -5605,8 +5836,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", @@ -9388,7 +9618,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(); @@ -9412,16 +9642,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 { @@ -9431,7 +9662,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(); @@ -9466,8 +9697,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}`); @@ -9705,7 +9937,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 { @@ -9725,12 +9957,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( @@ -10076,7 +10306,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), @@ -10745,3 +10975,54 @@ 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 }); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index d5dc1ab52b..5232b33a17 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,3 +1,4 @@ +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"; @@ -77,6 +78,7 @@ import { } from "./agent-run-state.js"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { isSystemInjectedEnvelope } from "./agent-prompt.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"; @@ -775,6 +777,7 @@ export class AgentManager { updateProviderRegistry(input: { providerDefinitions: ProviderEnabledMap; clients: ProviderClientMap; + retiredProviders?: readonly AgentProvider[]; }): void { this.providerEnabled.clear(); this.providerDefinitions.clear(); @@ -791,6 +794,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[] { @@ -1140,7 +1155,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); } @@ -1268,8 +1286,20 @@ export class AgentManager { }, 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, + ), + ), ); } @@ -1303,6 +1333,12 @@ export class AgentManager { resolvedAgentId, ); + // Decide residency from durable state inside the lifecycle lane. A loader may + // have read the record before a queued archive or restore completed. + const record = this.registry ? await this.registry.get(resolvedAgentId) : null; + const currentResumeOptions = record + ? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) } + : resumeOptions; const client = this.requireClient(handle.provider); const available = await client.isAvailable(); if (!available) { @@ -1319,7 +1355,7 @@ export class AgentManager { undefined, { reason: "resume", - purpose: resumeOptions?.purpose ?? "interactive", + purpose: currentResumeOptions?.purpose ?? "interactive", workspaceId: options?.workspaceId ?? null, }, ); @@ -1328,7 +1364,7 @@ export class AgentManager { handle, providerLaunchConfig, launchContext, - resumeOptions, + currentResumeOptions, ); await this.requireExternalMcpSupport(session, storedConfig); return this.registerSession(session, storedConfig, resolvedAgentId, { @@ -1635,14 +1671,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 { @@ -1660,9 +1693,6 @@ export class AgentManager { "agent.manager.close.complete", ); - if (closeError !== undefined) { - throw closeError; - } if (persistError !== undefined) { throw persistError; } @@ -1686,7 +1716,10 @@ 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 (!this.registry) { throw new Error("Agent storage is not configured"); @@ -1700,7 +1733,7 @@ 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.closeAgentRuntime(agentId); await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive"); @@ -1750,14 +1783,16 @@ 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()); } }); } } - private async markRecordArchived(record: StoredAgentRecord): Promise { - const archivedAt = new Date().toISOString(); + private async markRecordArchived( + record: StoredAgentRecord, + archivedAt = new Date().toISOString(), + ): Promise { const archivedRecord = await this.persistArchivedRecord(record, { archivedAt, updatedAt: archivedAt, @@ -2095,12 +2130,23 @@ export class AgentManager { } 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(); - const liveAgent = this.getAgent(agentId); - if (liveAgent) { - await this.persistSnapshot(liveAgent, { - internal: liveAgent.internal, - }); + // 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); @@ -2112,14 +2158,8 @@ export class AgentManager { await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); - if (this.agents.has(agentId)) { - this.notifyAgentState(agentId); - } else { - this.discardRetainedAgentState(agentId); - if (!nextRecord.internal) { - this.dispatchStoredAgentState(nextRecord); - } - } + this.discardRetainedAgentState(agentId); + if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord); await this.fireAgentArchived(agentId); await this.cascadeArchiveChildren(agentId); @@ -2130,6 +2170,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); @@ -2137,8 +2186,9 @@ export class AgentManager { return false; } - // Archived history may have loaded a runtime that still owns the native writer. - await this.closeAgent(agentId); + // 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({ @@ -2360,6 +2410,13 @@ 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 }; @@ -2885,6 +2942,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 { @@ -3079,8 +3139,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", @@ -4231,14 +4294,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: { @@ -4443,6 +4510,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"); } @@ -4457,6 +4525,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; diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 96f5ce5ee3..8e222304e9 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -8,6 +8,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,7 +22,9 @@ export type AgentRunController = Pick< | "replaceAgentRun" | "steerOrReplaceActiveTurn" | "streamAgent" ->; +> & { + reloadAgentSession(agentId: string): Promise; +}; export interface StartAgentRunOptions { replaceRunning?: boolean; @@ -78,6 +81,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, @@ -104,6 +115,26 @@ export async function startAgentRun( if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { 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") { return steered; @@ -122,8 +153,17 @@ export async function startAgentRun( ); 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( { @@ -229,6 +269,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,7 +284,9 @@ 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); } 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/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index bd4dcb16d8..814a8f3aec 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -55,6 +55,7 @@ export type EnsureWorkspaceForCreate = ( export interface CreateAgentFromSessionInput { kind: "session"; + onAgentReady?: (agent: ManagedAgent) => Promise; agentId?: string; config: AgentSessionConfig; workspaceId: string; @@ -190,6 +191,8 @@ export async function createAgentCommand( agentId: snapshot.id, }); + if (input.kind === "session") await input.onAgentReady?.(snapshot); + let liveSnapshot = snapshot; let initialPromptStarted = false; let initialPromptError: unknown | null = null; diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 75046396be..99d156473d 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -8,6 +8,10 @@ import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "./agent-sdk-types.js"; import { PluginAgentClientRegistry } from "./plugin-provider.js"; +import { + isStaleProviderSessionError, + StaleProviderSessionError, +} from "./stale-provider-session-error.js"; const CAPABILITIES = [ "prompt.message", @@ -25,6 +29,10 @@ interface ProviderHarnessOptions { 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; @@ -180,6 +188,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) { }, async close() { closeCount += 1; + resolveClosed(); }, }; @@ -191,7 +200,12 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) { }, }; - return { registration, inputs, closeCount: () => closeCount }; + return { + registration, + inputs, + closeCount: () => closeCount, + waitForClose: () => closed, + }; } function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]) { @@ -258,6 +272,84 @@ describe("PluginAgentClientRegistry", () => { expect(eventsOfType(events, "turn_failed")).toHaveLength(1); }); + 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()); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index b5f5ff7b92..0ab3ddecd7 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -58,6 +58,7 @@ import { } from "./create-agent-mode.js"; import type { ProviderDefinition } from "./provider-registry.js"; import { runProviderTurn } from "./providers/provider-runner.js"; +import { StaleProviderSessionError } from "./stale-provider-session-error.js"; interface Deferred { promise: Promise; @@ -130,6 +131,10 @@ class ProviderRuntime { return this.connection?.capabilities ?? []; } + get isClosed(): boolean { + return this.closed; + } + async isAvailable(): Promise { await this.getConnection(); return true; @@ -559,6 +564,7 @@ class ProviderRuntimeSession { sessionId: this.providerSessionId, prompt, }); + if (this.terminal) throw new StaleProviderSessionError(this.id); const pending = deferred>(); this.prompts.set(prompt.clientMessageId, pending); try { @@ -568,6 +574,9 @@ class ProviderRuntimeSession { 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); } @@ -623,6 +632,8 @@ class ProviderRuntimeSession { requestId: randomUUID(), sessionId: this.providerSessionId, }); + } catch (error) { + if (!this.runtime.isClosed) throw error; } finally { this.runtime.removeSession(this.id, this.providerSessionId); } 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-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 7ca0510f51..3f81e5e9c7 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -138,6 +138,79 @@ async function runTestCatalogActivities( } describe("ProviderSnapshotManager public surface", () => { + 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 = { @@ -2281,6 +2354,7 @@ describe("ProviderSnapshotManager cwd routing", () => { 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(); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index abf88d4488..0307dc14be 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -1,3 +1,4 @@ +import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types"; import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import { homedir } from "node:os"; @@ -191,6 +192,7 @@ export interface AgentManagerProviderState { > >; clients: Partial>; + retiredProviders?: readonly AgentProvider[]; } interface ProviderLoadOptions { @@ -402,6 +404,9 @@ export class ProviderSnapshotManager { 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], + ); const definitions = { ...this.generation.definitions }; const changed = new Set(); for (const provider of new Set([...Object.keys(previousPlugins), ...Object.keys(plugins)])) { @@ -415,7 +420,7 @@ export class ProviderSnapshotManager { const generation = this.createGeneration(definitions, this.providerOverrides); const state = this.createAgentManagerState(definitions, clients); this.installGeneration(generation, clients, changed); - return state; + return { ...state, retiredProviders }; } private ensureClient( @@ -1017,13 +1022,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 ? base.defaultModeId : catalog.defaultModeId, status: "ready", enabled: true, - models: catalog.models, + models, modes: catalog.modes, fetchedAt: new Date().toISOString(), }); 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 d9a298e790..b04246054a 100644 --- a/packages/server/src/server/agent/provider-subagents/store.ts +++ b/packages/server/src/server/agent/provider-subagents/store.ts @@ -6,7 +6,6 @@ import type { AgentTimelineFetchResult, AgentTimelineRow, } from "../agent-timeline-store-types.js"; -import { selectTimelineWindowByProjectedLimit } from "../timeline-projection.js"; export type ProviderSubagentStatus = "running" | "completed" | "failed" | "canceled"; @@ -151,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 1c77a6a25d..237e98892c 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1794,10 +1794,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 { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 0adb620136..4b14f924ef 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -393,7 +393,7 @@ export type ACPExtensionCommandsParser = ( * the catalog plumbing. */ export interface ACPCatalogModelResolverContext { - connection: ClientSideConnection; + connection: Pick; sessionId: string; models: AgentModelDefinition[]; configOptions: SessionConfigOption[] | null | undefined; @@ -404,10 +404,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, 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..25d4729dc6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -24,6 +24,12 @@ interface TestClaudeSession { 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 { @@ -1981,6 +1987,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[] = []; @@ -2857,6 +2872,110 @@ 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("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 () => { + // Trailing output after the foreground result opens an autonomous turn, which starts + // compacting and is then ended by the next foreground turn, never reaching a boundary. + const session = await createSessionForTurns([ + [createSuccessResult(), 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 a7469a75f3..496dfe2687 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2095,6 +2095,7 @@ 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; @@ -2514,9 +2515,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, @@ -2537,28 +2537,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, @@ -2609,8 +2605,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, @@ -3572,6 +3568,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundInput = null; this.cancelCurrentTurn = null; this.activeTurnHasAssistantText = false; + this.compactionMarkerOpen = false; this.syncTurnState("foreground turn terminal"); } @@ -3583,6 +3580,7 @@ class ClaudeAgentSession implements AgentSession { } if (terminalSeen) { + this.compactionMarkerOpen = false; if (this.activeForegroundTurnId) { this.activeForegroundTurnId = null; this.activeForegroundQuery = null; @@ -3624,6 +3622,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundQuery = null; this.activeForegroundInput = null; this.activeTurnHasAssistantText = false; + this.compactionMarkerOpen = false; this.syncTurnState("autonomous turn completed"); } @@ -4260,15 +4259,22 @@ class ClaudeAgentSession implements AgentSession { 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", 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..e5d4596e77 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 @@ -97,6 +97,88 @@ async function createConnectedSession( } describe("Codex app-server provider features", () => { + test.each([ + "gpt-6-astra", + "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: "fast_mode", + value: false, + }), + ); + await session.setFeature?.("fast_mode", true); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + model, + serviceTier: "fast", + }); + } 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.not.toMatchObject({ + serviceTier: expect.anything(), + }); + } 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: "fast_mode", + value: true, + }), + ); + 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: "fast", + }); + } finally { + await session.close(); + } + }); + test("features returns fast and plan toggles when supported", async () => { const { session } = await createConnectedSession(); @@ -105,7 +187,7 @@ describe("Codex app-server provider features", () => { type: "toggle", id: "fast_mode", label: "Fast", - description: "Priority inference at 2x usage", + description: "Priority inference at increased usage", tooltip: "Toggle fast mode", icon: "zap", value: false, @@ -129,7 +211,7 @@ describe("Codex app-server provider features", () => { type: "toggle", id: "fast_mode", label: "Fast", - description: "Priority inference at 2x usage", + description: "Priority inference at increased usage", tooltip: "Toggle fast mode", icon: "zap", value: true, @@ -250,7 +332,7 @@ describe("Codex app-server provider features", () => { type: "toggle", id: "fast_mode", label: "Fast", - description: "Priority inference at 2x usage", + description: "Priority inference at increased usage", tooltip: "Toggle fast mode", icon: "zap", value: true, 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.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 15e7cfdf54..1b1ff3738d 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 @@ -111,7 +111,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 +124,7 @@ interface CodexClientLike { } type CodexTestSession = AgentSession & { - connected: boolean; + connectionState: "disconnected" | "history-ready" | "connected"; currentThreadId: string | null; activeForegroundTurnId: string | null; client: CodexClientLike | null; @@ -165,7 +165,7 @@ 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; @@ -1435,7 +1435,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(); }); @@ -3931,7 +3931,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()) { @@ -3985,7 +3985,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, @@ -4054,7 +4054,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()) { @@ -4216,7 +4216,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()) { @@ -4281,7 +4281,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()) { @@ -4455,7 +4455,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()) { @@ -4516,7 +4516,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()) { @@ -5023,14 +5023,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", @@ -5065,7 +5073,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 }, }); @@ -5091,6 +5099,7 @@ describe("Codex app-server provider", () => { type: "timeline", item: expect.objectContaining({ type: "tool_call", + status: "completed", detail: expect.objectContaining({ type: "plan" }), }), }), @@ -6073,7 +6082,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 adff09dd9c..8bf9ede447 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 @@ -52,6 +52,7 @@ 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, @@ -1845,6 +1846,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 }, @@ -1871,14 +1883,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": @@ -3280,7 +3286,7 @@ 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 { @@ -3293,6 +3299,7 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly logger: Logger; private readonly config: AgentSessionConfig; + private readonly asyncQuestions: CodexAsyncQuestions; private currentMode: string; private hasWorkflowModeOverride: boolean; private readonly providerOptions: CodexProviderOptions; @@ -3364,7 +3371,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<{ @@ -3405,6 +3412,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID; this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {}); this.config = config; + this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) { this.serviceTier = "fast"; @@ -3436,7 +3444,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) { @@ -3460,6 +3468,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) { @@ -3482,16 +3495,14 @@ export class CodexAppServerAgentSession implements AgentSession { await this.loadSkills(); if (this.currentThreadId) { - await this.ensureThreadLoaded({ - allowArchivedHistory: this.initialResumePurpose === "history", - }); - await this.loadPersistedHistory(); + await this.ensureThreadLoaded(); + await this.loadPersistedHistory(this.client); } if (this.closed) { throw this.createClosedError(); } - this.connected = true; + this.connectionState = "connected"; } catch (error) { try { if (this.client === client) { @@ -3542,7 +3553,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) { @@ -3734,7 +3745,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 }); } @@ -3774,9 +3798,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({ @@ -3799,6 +3834,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); } @@ -3854,9 +3892,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( @@ -3881,16 +3917,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 }); @@ -3964,7 +3990,7 @@ export class CodexAppServerAgentSession implements AgentSession { return expandCodexCustomPrompt(parsed.body, args); } - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } else { await this.loadSkills(); @@ -4386,7 +4412,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) { @@ -4464,13 +4490,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}'`); @@ -4564,6 +4593,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; @@ -4574,7 +4630,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, }); } @@ -4606,29 +4662,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); @@ -4640,6 +4674,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; @@ -4700,6 +4757,7 @@ export class CodexAppServerAgentSession implements AgentSession { toolPolicy: this.config.toolPolicy, systemPrompt: this.config.systemPrompt, mcpServers: this.config.mcpServers, + asyncQuestions: this.asyncQuestions.serialize(), }, }; } @@ -4728,7 +4786,8 @@ export class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; this.persistedHistory = []; this.historyPending = false; - await this.loadPersistedHistory(); + await this.loadPersistedHistory(this.client); + this.reconcileAsyncQuestionsAfterRewind(); }, }); } @@ -4806,6 +4865,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); @@ -4816,7 +4878,7 @@ export class CodexAppServerAgentSession implements AgentSession { private async disposeClient(): Promise { const client = this.client; - this.connected = false; + this.connectionState = "disconnected"; this.currentTurnId = null; if (client) { await client.dispose(); @@ -4826,7 +4888,7 @@ export class CodexAppServerAgentSession implements AgentSession { async listCommands(): Promise { const prompts = await listCodexCustomPrompts(); - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } else { await this.loadSkills(); @@ -5127,7 +5189,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( { @@ -5903,6 +5965,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) { @@ -6255,6 +6318,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 { @@ -6265,6 +6365,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; 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-feature-definitions.ts b/packages/server/src/server/agent/providers/codex-feature-definitions.ts index a850317e56..7bd38d7efc 100644 --- a/packages/server/src/server/agent/providers/codex-feature-definitions.ts +++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts @@ -1,12 +1,22 @@ 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; +// Codex Fast is distinct from API Priority processing. Keep model support aligned with +// https://developers.openai.com/codex/speed and https://developers.openai.com/codex/models. +const CODEX_FAST_MODE_SUPPORTED_MODELS = new Set([ + "gpt-6-astra", + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", +]); export const CODEX_FAST_MODE_FEATURE: Omit = { type: "toggle", id: "fast_mode", label: "Fast", - description: "Priority inference at 2x usage", + description: "Priority inference at increased usage", tooltip: "Toggle fast mode", icon: "zap", }; @@ -30,9 +40,7 @@ export function codexModelSupportsFastMode(modelId: string | null | undefined): if (!normalizedModelId) { return false; } - return CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES.some( - (prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix), - ); + return CODEX_FAST_MODE_SUPPORTED_MODELS.has(normalizedModelId); } export function buildCodexFeatures(input: { 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/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/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 3f4a7af01c..31e231cdb1 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 @@ -618,7 +618,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/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index a38fb77128..fbe4c762e1 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 @@ -33,6 +33,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; 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..0a7ce428b3 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 { @@ -26,6 +33,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({ @@ -40,6 +99,7 @@ export class CursorACPAgentClient extends GenericACPAgentClient { 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/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..50effdf33a 100644 --- a/packages/server/src/server/agent/providers/kimi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.ts @@ -18,23 +18,8 @@ interface KimiACPAgentClientOptions { 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 +34,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 +57,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; 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 8b2acae1bd..b1c9df8a03 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -612,6 +612,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(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 19d5f95939..084ca70d7c 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -806,11 +806,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 })), ] : []; 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..55875e3523 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"; @@ -367,7 +366,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,27 +375,21 @@ 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"); @@ -404,15 +397,13 @@ describe.runIf(process.platform === "win32")( path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(), ); 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 +601,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/requests/index.test.ts b/packages/server/src/server/agent/requests/index.test.ts deleted file mode 100644 index de3b010148..0000000000 --- a/packages/server/src/server/agent/requests/index.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, expect, test } from "vitest"; -import { AgentRequests } 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 AgentRequests(directory) }; -} - -test("concurrent and reconstructed creates return the same durable agent", async () => { - const { requests, directory } = await fixture(); - const agents = new Set(); - let creations = 0; - const input = { - key: "create-1", - request: { provider: "test", cwd: "/project" }, - findAgent: async (id: string) => agents.has(id), - create: async (id: string) => { - creations++; - agents.add(id); - }, - }; - const ids = await Promise.all([requests.create(input), requests.create(input)]); - expect(ids[0]).toBe(ids[1]); - expect(await new AgentRequests(directory).create(input)).toBe(ids[0]); - expect(creations).toBe(1); -}); - -test("recovers creation when the agent was persisted before acknowledgement failed", async () => { - const { requests, directory } = await fixture(); - const agents = new Set(); - const input = { - key: "lost-create", - request: {}, - findAgent: async (id: string) => agents.has(id), - create: async (id: string) => { - agents.add(id); - throw new Error("acknowledgement lost"); - }, - }; - await expect(requests.create(input)).rejects.toThrow("acknowledgement lost"); - expect(await new AgentRequests(directory).create(input)).toBe([...agents][0]); - expect(agents.size).toBe(1); -}); - -test("reusing a create key with different configuration is a conflict", async () => { - const { requests } = await fixture(); - const input = { - key: "key", - request: { model: "a" }, - findAgent: async () => true, - create: async () => {}, - }; - await requests.create(input); - await expect(requests.create({ ...input, request: { model: "b" } })).rejects.toThrow( - "agent_request_key_conflict", - ); -}); - -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 AgentRequests(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 AgentRequests(directory).send(input)).rejects.toThrow( - "agent_request_outcome_unknown", - ); - expect(deliveries).toBe(1); -}); - -test("a creation failure with no stored agent can be retried", async () => { - const { requests } = await fixture(); - let available = false; - const input = { - key: "unavailable", - request: {}, - findAgent: async () => false, - create: async () => { - if (!available) throw new Error("provider unavailable"); - }, - }; - await expect(requests.create(input)).rejects.toThrow("provider unavailable"); - available = true; - await expect(requests.create(input)).resolves.toEqual(expect.any(String)); -}); - -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 AgentRequests(directory).send(input); - available = false; - await requests.send(input); - expect(sends).toBe(1); -}); diff --git a/packages/server/src/server/agent/requests/index.ts b/packages/server/src/server/agent/requests/index.ts deleted file mode 100644 index ad64331c35..0000000000 --- a/packages/server/src/server/agent/requests/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import { readFile, rm } 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(), - agentId: z.string(), - state: z.enum(["pending", "completed"]), -}); -type Receipt = z.infer; - -/** One daemon-owned request journal, shared by all of its socket sessions. */ -export class AgentRequests { - private readonly pending = new Map>(); - - constructor(private readonly directory: string) {} - - create(input: { - key: string; - request: unknown; - findAgent: (agentId: string) => Promise; - create: (agentId: string) => Promise; - }): Promise { - return this.execute(["create", input.key], input.request, { - agentId: randomUUID(), - recover: input.findAgent, - run: input.create, - retrySafe: async (agentId) => !(await input.findAgent(agentId)), - }); - } - - async send(input: { - agentId: string; - messageId: string; - request: unknown; - send: () => Promise; - prepare?: () => Promise; - }): Promise { - await this.execute(["send", input.agentId, input.messageId], input.request, { - agentId: input.agentId, - // A provider call can take effect before the daemon records its outcome. - // Never repeat that call merely because a process died in this window. - recover: async () => false, - run: input.send, - prepare: input.prepare, - }); - } - - private execute( - identity: string[], - request: unknown, - operation: { - agentId: string; - recover: (agentId: string) => Promise; - run: (agentId: string) => Promise; - prepare?: (() => Promise) | undefined; - retrySafe?: (agentId: string) => Promise; - }, - ): Promise { - const key = digest(identity); - const fingerprint = digest(request); - const previous = this.pending.get(key); - // Serialize even conflicting requests: each caller validates its own fingerprint. - const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => - this.executeOnce(key, fingerprint, operation), - ); - this.pending.set(key, result); - void result - .finally(() => { - if (this.pending.get(key) === result) this.pending.delete(key); - }) - .catch(() => undefined); - return result; - } - - private async executeOnce( - key: string, - fingerprint: string, - operation: { - agentId: string; - recover: (agentId: string) => Promise; - run: (agentId: string) => Promise; - prepare?: (() => Promise) | undefined; - retrySafe?: (agentId: string) => Promise; - }, - ): Promise { - const file = path.join(this.directory, `${key}.json`); - const existing = await readReceipt(file); - if (existing) { - if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict"); - if (existing.state === "completed") return existing.agentId; - if (!(await operation.recover(existing.agentId))) { - throw new Error("agent_request_outcome_unknown"); - } - await writeJsonFileAtomic(file, { ...existing, state: "completed" }); - return existing.agentId; - } - await operation.prepare?.(); - const receipt: Receipt = { fingerprint, agentId: operation.agentId, state: "pending" }; - await writeJsonFileAtomic(file, receipt); - try { - await operation.run(receipt.agentId); - } catch (error) { - // Keyed creation has no initial prompt. Once its normal cleanup finished, - // absence of an agent confirms that retrying cannot duplicate one. - if (await operation.retrySafe?.(receipt.agentId)) await rm(file, { force: true }); - throw error; - } - await writeJsonFileAtomic(file, { ...receipt, state: "completed" }); - return receipt.agentId; - } -} - -async function readReceipt(file: string): Promise { - 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/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..d8128a6a2b 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; } @@ -114,15 +116,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 +291,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 +451,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 +478,6 @@ interface ProjectedEntryCandidate { function selectProjectedEntriesAfter(input: { entries: readonly TimelineProjectionEntry[]; - rows: readonly AgentTimelineRow[]; startSeq: number; maxSeq: number; limit: number; @@ -445,16 +502,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 +535,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 +607,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 +615,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/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index f04ef72fef..4dfb5f8b00 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -21,6 +21,8 @@ const INBOUND_PERMISSION = { "agent.timeline.search.request": "workspace.read", "agent.timeline.list_prompts.request": "workspace.read", "agent.timeline.append.request": "workspace.write", + "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", @@ -61,6 +63,8 @@ const INBOUND_PERMISSION = { client_heartbeat: "workspace.read", close_items_request: "workspace.manage", 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", @@ -173,6 +177,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", @@ -207,6 +212,10 @@ const INBOUND_PERMISSION = { } 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", "hub.execute"], "agent.detach.response": "workspace.write", @@ -224,6 +233,8 @@ const OUTBOUND_PERMISSION = { "agent.timeline.list_prompts.response": "workspace.read", "agent.timeline.append.response": "workspace.write", "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"], @@ -387,6 +398,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", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 81aee8aa7f..380e3618e0 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -80,12 +80,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; } 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-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/config-environment.ts b/packages/server/src/server/config-environment.ts new file mode 100644 index 0000000000..92bf9183ee --- /dev/null +++ b/packages/server/src/server/config-environment.ts @@ -0,0 +1,90 @@ +// 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_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.ts b/packages/server/src/server/config.ts index 7ed5f16519..8a8eb39a67 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"; @@ -554,7 +555,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; 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..f0d21be18f --- /dev/null +++ b/packages/server/src/server/creation/creation.e2e.test.ts @@ -0,0 +1,286 @@ +import { execFileSync } from "node:child_process"; +import type { z } from "zod"; +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 { 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 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 createTestPaseoDaemon({ + 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 () => { + agents++; + await provider.promise; + }, + onStartTurn: () => { + 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 }); + } +}, 60000); + +async function connectCreationPeer(port: number) { + 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")) + .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).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 createTestPaseoDaemon(); + 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 }); + } + }, + 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 createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async () => { + creations++; + }, + }), + }); + 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 }); + } + }, + 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 createTestPaseoDaemon({ 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 }); + } +}, 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..1eaf4a7fd9 --- /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; + }, + }; + 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; + }; + 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 created; + }; + 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; + }; + 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..3d7a42efdb --- /dev/null +++ b/packages/server/src/server/creation/index.ts @@ -0,0 +1,373 @@ +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; +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 = 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 (input.hasPrompt) + 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 934125af11..f429cb672c 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({ @@ -1325,7 +1325,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) => { 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 index 17dce841cf..c3e58f8b94 100644 --- 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 @@ -41,7 +41,7 @@ async function expectContextAfterWorkspaceRestore(legacyNativeArchive: boolean): }); client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "archive-qa" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ config: { provider: "codex", 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 index ac9d2f83cb..cbbacde969 100644 --- 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 @@ -61,7 +61,7 @@ async function withNativeConversation( }); client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "native-reload-qa" } }); + 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; 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-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-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-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/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-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/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/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index a0634e2bd7..f336f325bf 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,5 +1,4 @@ import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -69,7 +68,7 @@ async function createHarness(): Promise { }); await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `omp-real-${randomUUID()}` }, + subscribe: {}, }); return { daemon, client, cwd, paseoHomeRoot, staticDir }; } 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..51efc705cf 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 @@ -122,7 +122,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); 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/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..2471ff8c5a 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 @@ -345,7 +345,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", @@ -518,7 +518,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 +587,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, 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.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 41b40fed57..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 @@ -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.ts b/packages/server/src/server/daemon-instance.ts new file mode 100644 index 0000000000..f3101f3b28 --- /dev/null +++ b/packages/server/src/server/daemon-instance.ts @@ -0,0 +1,305 @@ +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, + isPidRunning, + 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(); + }), + ); + +export { daemonLaunchEnvironment } from "./config-environment.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 && isPidRunning(lock.pid) ? 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 || !isPidRunning(instance.pid)) { + 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 (isPidRunning(instance.pid)) { + 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..2adc4ab982 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"; @@ -323,10 +323,7 @@ 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"); } 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/exports.ts b/packages/server/src/server/exports.ts index 0961e08b11..72efe5d572 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -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-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 0427cd823b..2169628bba 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); @@ -360,3 +368,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/internal/native-recursive.test.ts b/packages/server/src/server/file-observer/internal/native-recursive.test.ts index b37e12657d..5133f2f6c9 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.test.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.test.ts @@ -9,66 +9,75 @@ 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("shallow parent scans retain nested change scopes", async () => { - 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 }); - 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(); +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; }, - on: (event, onError) => notifications.on(event, onError), - }; - }, - ); - try { - await backend.start(); - 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 }); - } -}); + }, + 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-")); diff --git a/packages/server/src/server/file-upload/index.test.ts b/packages/server/src/server/file-upload/index.test.ts index b338d7dd1c..7eb97a07c3 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 { 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, @@ -70,8 +70,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 +106,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 +137,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 +182,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, @@ -252,3 +250,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..8b93e31f2f 100644 --- a/packages/server/src/server/file-upload/index.ts +++ b/packages/server/src/server/file-upload/index.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -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,15 +220,6 @@ 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}`; -} - function sanitizeFileName(value: string): string { const name = basename(value) .replace(/[^a-zA-Z0-9._ -]/g, "_") 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 6e9f4036ed..11e973c136 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -66,11 +66,34 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts hub.requestOrdinary({ ...create, requestId: "create-duplicate" }), ]); const first = responses[0]; - expect(first).toMatchObject({ type: "status", payload: { status: "agent_created" } }); + 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(responses[1]).toMatchObject({ type: "status", 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", @@ -92,11 +115,13 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts text: "hello", activeTurnBehavior: "steer", }; - expect(await hub.requestOrdinary({ ...message, requestId: "message-first" })).toMatchObject({ - payload: { accepted: true }, + 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" })).toMatchObject({ - payload: { accepted: true }, + 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 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..f632971cc9 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -297,7 +297,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 +419,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 +483,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 +811,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 +848,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 +864,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 +880,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 +899,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 3113b543ad..84726d0b02 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -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 { @@ -1372,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)), ); @@ -1433,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> { @@ -1458,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; } @@ -1582,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/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/owned-subscriptions-client.e2e.test.ts b/packages/server/src/server/owned-subscriptions-client.e2e.test.ts new file mode 100644 index 0000000000..623fa23ac6 --- /dev/null +++ b/packages/server/src/server/owned-subscriptions-client.e2e.test.ts @@ -0,0 +1,171 @@ +import { WebSocket } from "ws"; +import { + DaemonClient as PublicDaemonClient, + type WebSocketLike, +} from "@getpaseo/client/internal/daemon-client"; +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("two SDK facades own combined agent lists and disposal preserves the other facade", 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 }, + }); + 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.ts b/packages/server/src/server/paseo-worktree-service.ts index dea17e1bb9..dc93e30298 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -31,6 +31,7 @@ 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; } @@ -93,6 +94,7 @@ 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, 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.test.ts b/packages/server/src/server/persisted-config.test.ts index 8ea1c5c25d..2ab987eebf 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -8,7 +8,7 @@ import { PersistedConfigSchema, 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; @@ -764,7 +764,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 +775,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..678e72370d 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -463,6 +463,84 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per return result.data as PersistedConfig; } +/** Observe the file without initializing a home, identity, or default configuration. */ +export function readPersistedConfig( + paseoHome: string, + options: { defaultsIfMissing?: boolean } = {}, +): PersistedConfig { + let raw: string; + try { + raw = readFileSync(getConfigPath(paseoHome), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {}; + throw error; + } + return PersistedConfigSchema.parse(stripRemovedConfigFields(JSON.parse(raw))) as PersistedConfig; +} + +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/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index 1dc6c260bb..457e1e7edc 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, 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 }); } }); @@ -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; @@ -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 }); } @@ -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"); @@ -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 }); } diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 9202f49dfd..f7ad90bcd1 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,12 +1,12 @@ -import { open, readFile, stat, unlink, mkdir, utimes } from "node:fs/promises"; +import { open, readFile, 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 { 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(), @@ -36,18 +36,16 @@ 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; -function isPidRunning(pid: number): boolean { +export function isPidRunning(pid: number): boolean { try { process.kill(pid, 0); return true; - } catch { - return false; + } catch (error) { + return isErrnoException(error) && error.code === "EPERM"; } } @@ -55,27 +53,29 @@ 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; + for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt++) { + try { + const content = await readFile(pidPath, "utf-8"); + 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; + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS)); } + 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 +87,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,7 +104,6 @@ async function clearExistingPidLock( pidPath: string, existingLock: PidLockInfo, lockOwnerPid: number, - options: AcquirePidLockOptions | undefined, ): Promise<"already_owned" | "cleared"> { const lockOwnerRunning = isPidRunning(existingLock.pid); if (existingLock.pid === lockOwnerPid && lockOwnerRunning) { @@ -122,21 +111,14 @@ async function clearExistingPidLock( 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) || + isPidRunning(confirmedLock.pid) + ) { + throw new PidLockError("PID lock changed while checking whether it was abandoned"); } await unlink(pidPath).catch(() => {}); @@ -173,10 +155,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,7 +163,7 @@ 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; } @@ -298,7 +277,7 @@ export function startPidLockHeartbeat( export async function updatePidLock( paseoHome: string, - patch: { listen: string }, + patch: { listen: string | null }, options?: { ownerPid?: number }, ): Promise { const pidPath = getPidFilePath(paseoHome); @@ -329,7 +308,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 +316,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 { diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts index adb0c6d2da..3228306297 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -17,7 +17,7 @@ test("the configuration example adds MCP servers and overrides Codex options whi 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: { subscriptionId: "configuration-example" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin( fileURLToPath(new URL("../../../../../plugin-examples/agent-configuration", import.meta.url)), diff --git a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts index 06ced0f347..8457c94711 100644 --- a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -12,7 +12,7 @@ test("workspace archive publishes agent archive hooks for both live and closed a 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: { subscriptionId: "archive-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin( fileURLToPath(new URL("../../../../../plugin-examples/lifecycle-logger", import.meta.url)), diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index f21d1b86e5..a6a27ba5bc 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -5,7 +5,7 @@ import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -test("a plugin transforms workspace creation and observes its committed lifecycle", async () => { +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" }); @@ -19,6 +19,7 @@ test("a plugin transforms workspace creation and observes its committed lifecycl ` 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) => { @@ -32,14 +33,21 @@ export default function contribute(server) { `, ); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "lifecycle" } }); + 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 () => { @@ -53,6 +61,7 @@ export default function contribute(server) { }); }) .toMatchObject([ + { hook: "workspace.before" }, { hook: "workspace.created", event: { workspace: { id: workspaceId, cwd: directory } } }, { hook: "workspace.archived", event: { workspace: { id: workspaceId } } }, ]); @@ -100,7 +109,7 @@ export default function contribute(server) { `, ); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "turn-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const agent = await client.createAgent({ @@ -183,7 +192,7 @@ export default function contribute(server) { `, ); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "agent-hooks" } }); + await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(directory); const agent = await client.createAgent({ diff --git a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts index 7dad32752e..bd71f0ce7e 100644 --- a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -130,7 +130,7 @@ test.skipIf(process.platform !== "linux")( }; try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "real-hooks" } }); + 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")); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index d08bd6f669..29523a7c00 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -10,6 +10,7 @@ import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider"; import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp"; import type { SettingsDefinition, PluginRpcContract } from "@getpaseo/plugin"; import type { PluginHandlerContext } from "@getpaseo/plugin/server"; +import type { ZodType } from "zod"; import { ProviderEventSchema, type ProviderConnection, @@ -23,7 +24,7 @@ import { createPluginClientId } from "./plugin-session-identity.js"; import { PluginSettingsStore } from "./settings/index.js"; let settingsStore: PluginSettingsStore | null = null; -function registerSettings(definition: SettingsDefinition) { +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); @@ -33,6 +34,7 @@ function registerSettings(definition: SettingsDefinition) { register(handlers.reset.contract, (input) => handlers.reset.handle(handlers.reset.contract.input.parse(input)), ); + return handlers.settings; } type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise; @@ -282,6 +284,9 @@ async function initialize(message: Extract { 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; @@ -292,6 +297,7 @@ async function shutdown(): Promise { 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; @@ -321,6 +327,9 @@ process.on("message", (rawMessage: unknown) => { 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; diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts index 2823cd79ab..a6ccf3f2cc 100644 --- a/packages/server/src/server/plugins/settings.e2e.test.ts +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -1,19 +1,102 @@ import { resolveDaemonVersion } from "../daemon-version.js"; -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 { expect, test } from "vitest"; +import { expect, onTestFinished, test } from "vitest"; import { z } from "zod"; -import { settingsRpc } from "@getpaseo/plugin"; +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[] = []; @@ -27,12 +110,22 @@ test("two clients share settings, observe changes, and preserve values through p ); await writeFile( path.join(directory, "index.server.ts"), - `import { defineSettings } from "@getpaseo/plugin"; + `import { defineRpc, defineSettings } from "@getpaseo/plugin"; import { z } from "zod"; -export default function(server) { server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); return () => {}; }`, +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)); @@ -41,6 +134,16 @@ export default function(server) { server.registerSettings(defineSettings({ id: " 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, @@ -49,6 +152,16 @@ export default function(server) { server.registerSettings(defineSettings({ id: " ).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, diff --git a/packages/server/src/server/plugins/settings/index.test.ts b/packages/server/src/server/plugins/settings/index.test.ts index c367c93be3..de81919eae 100644 --- a/packages/server/src/server/plugins/settings/index.test.ts +++ b/packages/server/src/server/plugins/settings/index.test.ts @@ -3,6 +3,7 @@ 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"; @@ -19,6 +20,10 @@ const definition = defineSettings({ 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); @@ -47,6 +52,47 @@ test("defaults, atomic saves, concurrent revisions, and restart persistence", as 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( diff --git a/packages/server/src/server/plugins/settings/index.ts b/packages/server/src/server/plugins/settings/index.ts index ccdd3e5dd6..775a47f6a4 100644 --- a/packages/server/src/server/plugins/settings/index.ts +++ b/packages/server/src/server/plugins/settings/index.ts @@ -1,9 +1,9 @@ import { createHash, randomUUID } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { z } from "zod"; -import { defineSettings, type SettingsDefinition } from "@getpaseo/plugin"; -import { settingsRpc } from "@getpaseo/plugin"; +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 { @@ -12,6 +12,25 @@ function message(error: unknown): string { } 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(); @@ -24,24 +43,43 @@ export class PluginSettingsStore { this.changed = changed; } - register(definition: SettingsDefinition) { + 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 { - read: { contract: rpc.read, handle: () => this.serial(() => this.read(definition)) }, - write: { - contract: rpc.write, - handle: (input: z.output) => - this.serial(() => this.write(definition, input.revision, input.values, "save")), - }, - reset: { - contract: rpc.reset, - handle: (input: z.output) => - this.serial(() => this.write(definition, input.revision, {}, "reset")), - }, + settings, + read: { contract: rpc.read, handle: read }, + write: { contract: rpc.write, handle: write }, + reset: { contract: rpc.reset, handle: reset }, }; } @@ -62,9 +100,10 @@ export class PluginSettingsStore { } } - private async read( - definition: SettingsDefinition, - ): Promise["read"]["output"]>> { + 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)); @@ -76,58 +115,71 @@ export class PluginSettingsStore { throw new Error(`Settings version ${envelope.version} requires a migration`); values = await definition.migrate(values, envelope.version); } - const parsed = z.json().parse(await definition.schema.parseAsync(values)); - const revision = - envelope && envelope.version !== definition.version - ? await this.persist(definition, parsed) - : stored.revision; - return { status: "ready", values: parsed, revision }; + 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, + private async write( + definition: SettingsDefinition, revision: string, values: unknown, intent: "save" | "reset", - ): Promise["write"]["output"]>> { + 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>; + 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 = z.json().parse(await definition.schema.parseAsync(values)); + parsed = await definition.schema.parseAsync(values); + z.json().parse(parsed); } catch (error) { return { status: "invalid", error: message(error) }; } - return { status: "saved", values: parsed, revision: await this.persist(definition, parsed) }; + 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>, + 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 }); + 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 }); } - this.changed(definition.id); return revisionOf(raw); } } 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..a27de8825c 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); @@ -256,7 +256,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 +293,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.ts b/packages/server/src/server/schedule/service.ts index e3e9504e46..192b424337 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -202,6 +202,7 @@ function buildRunOutput(params: { type ScheduleAgentManager = Pick< AgentRunController, | "getAgent" + | "reloadAgentSession" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" 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 a06b7a32c8..f47b30ce85 100644 --- a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts +++ b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts @@ -6,7 +6,8 @@ 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, @@ -25,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) { @@ -38,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; } @@ -141,24 +185,28 @@ async function connect(input: { 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, [CLIENT_CAPS.pluginTimelineItems]: input.pluginTimelineItems ?? false, [CLIENT_CAPS.workspaceSetupBlocked]: input.workspaceSetupBlocked ?? false, ...(input.timelineNotifications === undefined ? {} : { [CLIENT_CAPS.timelineNotifications]: input.timelineNotifications }), - ...(input.timelineReplacementInvalidation - ? { [CLIENT_CAPS.timelineReplacementInvalidation]: true } - : {}), + [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; } @@ -358,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); @@ -409,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( @@ -444,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, { @@ -467,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, { @@ -500,8 +548,8 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg expect(capable.hasTimeline(agentB.id)).toBe(false); await Promise.all([ - legacy.client.fetchAgents({ subscribe: { subscriptionId: "legacy-directory" } }), - capable.client.fetchAgents({ subscribe: { subscriptionId: "capable-directory" } }), + legacy.observeLegacyDirectory("legacy-directory"), + capable.observeLegacyDirectory("capable-directory"), ]); capable.client.sendHeartbeat({ deviceType: "mobile", diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 04406b21f0..7a6384f9a6 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +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"; @@ -364,7 +367,8 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const messages = options.messages ?? []; const sessionOptions: SessionOptions = { - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: options.clientId ?? "test-client", onMessage: (message) => messages.push(message), ...(options.targetedMessages @@ -372,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(), @@ -517,6 +524,12 @@ test("routes plugin requests and releases its owned catalog subscription on clea }; 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", @@ -1308,43 +1321,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") { @@ -5200,6 +5226,7 @@ test("acknowledges a timeline subscription only to its socket source", async () payload: { agentIds: ["agent-a"], requestId: "timeline-subscription-targeted", + subscriptionId: expect.any(String), }, }, }, @@ -5725,34 +5752,50 @@ test("provider snapshots preserve versionless visibility while capabilities upda { 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", - }); + 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, - }); + 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.updateAppVersion("0.1.45"); + 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, - }); + 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([ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6f2641a1f3..753c163caf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,14 @@ 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 } from "@getpaseo/protocol/messages"; -import type { AgentRequests } from "./agent/requests/index.js"; +import { relative } from "node:path"; +import { isAbsolute } from "node:path"; +import { CreationService } 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"; @@ -117,12 +124,6 @@ import { } 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"; @@ -165,7 +166,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, @@ -199,7 +200,6 @@ import { } from "./session/git-mutation/git-mutation-service.js"; import { createWorkspaceProvisioningService, - WorkspaceProvisioningError, type WorkspaceProvisioningService, } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { @@ -291,17 +291,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" @@ -414,6 +403,7 @@ type WorkspaceUpdatePayload = Extract< >["payload"]; interface WorkspaceUpdatesSubscriptionState { subscriptionId: string; + owner: OwnedSubscription; syncEnabled?: boolean; filter?: WorkspaceUpdatesFilter; isBootstrapping: boolean; @@ -447,6 +437,7 @@ const nodeSessionFileSystem: SessionFileSystem = { type AgentMcpTransportFactory = () => Promise; export interface SessionOptions { + browserToolsBroker?: BrowserToolsBroker | null; clientId: string; permissions: readonly DaemonPermission[]; appVersion?: string | null; @@ -455,7 +446,7 @@ 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; logger: pino.Logger; @@ -465,7 +456,8 @@ export interface SessionOptions { worktreesRoot?: string; agentManager: AgentManager; agentStorage: AgentStorage; - agentRequests: Pick; + messageReceipts: Pick; + creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; directorySync?: DirectorySyncService; @@ -601,15 +593,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 { @@ -617,7 +600,6 @@ interface ArchivedRecordSnapshot { } interface WorkspaceUpdateOptions { - dedupeGitState?: boolean; removedProjectId?: string; optimisticStatus?: WorkspaceDescriptorPayload["status"]; } @@ -644,6 +626,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 || @@ -655,7 +647,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; @@ -669,7 +695,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) @@ -704,32 +730,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 defaultEventSubscriptionSource = {}; - private readonly eventSubscriptions = new Map>(); + 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; @@ -745,7 +770,7 @@ 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; @@ -755,8 +780,9 @@ export class Session { private readonly daemonSession: DaemonSession; private readonly hubExecutionController: HubExecutionController | null; private readonly workspaceScripts: WorkspaceScriptsService; - private readonly agentRequests: Pick; + private readonly messageReceipts: Pick; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; + private readonly creationService: Pick; constructor(options: SessionOptions) { const { @@ -815,6 +841,7 @@ export class Session { daemonRuntimeConfig, getWebSocketRuntimeMetrics, } = options; + this.browserToolsBroker = options.browserToolsBroker; this.clientId = clientId; this.authorization = new SessionAuthorization(permissions); this.appVersion = appVersion ?? null; @@ -829,12 +856,12 @@ export class Session { this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; this.pushNotifications = pushNotifications; this.paseoHome = paseoHome; - this.agentRequests = options.agentRequests; + 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, @@ -910,11 +937,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, @@ -930,7 +953,26 @@ export class Session { isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons), supportsCompactProviderSnapshots: () => this.supports(CLIENT_CAPS.compactProviderSnapshots), - wantsSnapshotChanges: () => this.wantsEvent("providers_snapshot_update"), + 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(), @@ -980,6 +1022,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(), @@ -1001,14 +1059,13 @@ 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.agentUpdates = createAgentUpdatesService({ emit: (message) => this.emit(message), @@ -1075,11 +1132,11 @@ export class Session { logger: this.sessionLogger, emit: (message) => this.emit(message), spawnWorkspaceScript, + wantsStatusUpdates: () => this.wantsEvent("script_status_update"), assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts, }); - this.subscribeToOptionalManagers(); this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, projectRegistry: this.projectRegistry, @@ -1091,43 +1148,44 @@ export class Session { 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"); } @@ -1138,49 +1196,60 @@ 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.eventSubscriptions.delete(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); - this.eventSubscriptions.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 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; + 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; } // COMPAT(timelineItemCapabilities): plugin items added in v0.8.0, notifications in v0.7.2. @@ -1197,77 +1266,78 @@ export class Session { event: Extract, serializedEvent: Extract["payload"]["event"], ): void { - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { - if (serializedEvent.type === "timeline" && !this.supportsTimelineItem(serializedEvent.item)) - return; - 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; + 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 } : {}), + }, + }); } - - for (const [source, capabilities] of this.clientCapabilitiesBySource) { + 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; - const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline); - if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") { - if (!this.wantsEvent("agent_attention_required", source)) continue; - 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), - }); + 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)); @@ -1281,15 +1351,23 @@ export class Session { 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), }; 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 { @@ -1300,7 +1378,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, @@ -1336,25 +1414,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 }, @@ -1374,15 +1456,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): @@ -1392,7 +1484,7 @@ export class Session { thinkingOptionId?: string | null; } | undefined { - const focusedAgentId = this.clientActivity?.focusedAgentId; + const focusedAgentId = this.getClientActivity()?.focusedAgentId; if (!focusedAgentId) { return undefined; } @@ -1510,33 +1602,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 { @@ -1575,10 +1709,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, @@ -1586,7 +1721,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; @@ -1596,16 +1734,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()) @@ -1634,7 +1780,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; @@ -1645,7 +1791,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 }, @@ -1654,6 +1800,17 @@ 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 { @@ -1688,18 +1845,34 @@ export class Session { }; } - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { + 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.supportsTimelineItem(update.row.item)) + (update.type !== "timeline" || this.supportsSubagentTimelineItem(update.row.item)) ) { this.emit(message); } return; } - for (const [source, capabilities] of this.clientCapabilitiesBySource) { - if (!capabilities.has(CLIENT_CAPS.providerSubagents)) continue; - if (update.type === "timeline" && !this.supportsTimelineItem(update.row.item, source)) + 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); } @@ -1739,7 +1912,7 @@ export class Session { } if ( - this.voiceSession.isActiveForAgent(event.agentId) && + this.voiceSessions.isActiveForAgent(event.agentId) && event.event.type === "permission_requested" && isVoicePermissionAllowed(event.event.request) ) { @@ -1787,7 +1960,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, @@ -1853,7 +2027,14 @@ export class Session { } private isProviderVisibleToClient(provider: string): boolean { - if (this.supports(CLIENT_CAPS.allProviders) || 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); @@ -1899,6 +2080,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; @@ -1967,6 +2152,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[] { @@ -1981,28 +2173,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) ?? @@ -2252,28 +2522,14 @@ export class Session { 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": @@ -2338,34 +2594,41 @@ export class Session { case "agent.provider_subagents.timeline.get.request": return this.handleProviderSubagentTimelineRequest(msg, source); case "session.events.set_subscription.request": { - this.eventSubscriptions.set( - source ?? this.defaultEventSubscriptionSource, - new Set(msg.events), - ); + 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 }, + payload: { requestId: msg.requestId, subscriptionId: owner.responseId }, }, source, ); - return undefined; + 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": @@ -2388,6 +2651,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": @@ -2500,11 +2779,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": @@ -2584,8 +2864,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.title.set.request": return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": @@ -2630,10 +2908,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": @@ -2651,7 +2928,7 @@ 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; @@ -2707,7 +2984,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); } } @@ -2746,8 +3023,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", @@ -2765,15 +3042,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 { @@ -3571,6 +3848,265 @@ 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; @@ -3578,18 +4114,13 @@ export class Session { if (msg.initialPrompt !== undefined) { throw new Error("Idempotent creation requires sending the initial prompt separately"); } - const { requestId: _requestId, idempotencyKey, ...request } = msg; - const id = await this.agentRequests.create({ - key: idempotencyKey, - request, - findAgent: async (agentId) => - this.agentManager.getAgent(agentId) != null || - (await this.agentStorage.get(agentId)) !== null, - create: async (agentId) => { - await this.createSessionAgent(msg, agentId); - }, - }); - const record = await this.agentStorage.get(id); + 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 { @@ -3605,7 +4136,7 @@ export class Session { }, }); } catch (error) { - const wireError = toWorktreeWireError(error); + const wireError = error instanceof SessionRequestError ? error : toWorktreeWireError(error); this.sessionLogger.error({ err: error }, "Failed to create agent"); this.emit({ type: "status", @@ -3631,6 +4162,7 @@ export class Session { private async createSessionAgent( msg: CreateAgentRequestMessage, agentId?: string, + onAgentReady?: (agent: AgentSnapshotPayload) => Promise, ): Promise { const { config, @@ -3700,6 +4232,10 @@ 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, @@ -4121,29 +4657,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); } } @@ -4292,7 +4835,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, @@ -4303,8 +4847,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); } } @@ -4324,7 +4868,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"); } @@ -4430,6 +4974,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 }, @@ -5185,6 +5738,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 { @@ -5202,20 +5757,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; } @@ -5249,10 +5806,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 }); } } @@ -5390,17 +5944,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( @@ -5412,7 +5960,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); @@ -5434,7 +5984,7 @@ export class Session { subscription: WorkspaceUpdatesSubscriptionState, options: WorkspaceUpdateOptions | undefined, ): Promise { - if (this.workspaceUpdatesSubscription !== subscription) { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) { return; } @@ -5444,7 +5994,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); @@ -5457,18 +6007,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); @@ -5573,12 +6117,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; @@ -5589,16 +6129,26 @@ 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, + subscriptionId: owner.id, + isProviderVisible: (provider) => + this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)), filter: request.filter, syncEnabled: Boolean(request.sync), + emit: (message) => { + if (message.type === "agent_update") owner.emit(message); + }, }); + this.refreshObservationProducers(); } const payload = request.sync @@ -5622,11 +6172,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"; @@ -5718,9 +6268,30 @@ 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, + 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( { @@ -5732,22 +6303,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, @@ -5758,12 +6317,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", @@ -5774,13 +6334,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"); @@ -5808,7 +6367,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: { @@ -5855,70 +6413,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); } } @@ -6039,16 +6584,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", @@ -6075,61 +6631,35 @@ export class Session { } } - private async handleWorkspaceCreateRequest( + private async createRequestedWorkspace( request: Extract, - ): Promise { - try { - if (this.pluginRuntime) { - const { type, requestId, ...input } = request; - const transformed = await this.pluginRuntime.before("workspace.create", input); - request = { ...transformed, type, requestId }; - } - 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", - ); - 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, - }, - }); + workspaceId?: string, + ): Promise { + 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; @@ -6138,19 +6668,10 @@ export class Session { cwd, explicitTitle ?? promptTitle, request.source.projectId, - { expectsInitialAgent: Boolean(request.firstAgentContext) }, + { expectsInitialAgent: Boolean(request.firstAgentContext), workspaceId }, ); 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, @@ -6174,29 +6695,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); @@ -6204,6 +6720,7 @@ export class Session { const result = await this.createPaseoWorktreeWorkflow( { cwd: sourceCwd, + workspaceId, projectId: source.projectId, worktreeSlug: source.worktreeSlug, action: source.action, @@ -6220,26 +6737,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( @@ -7123,105 +7625,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 @@ -7244,14 +7653,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 } @@ -7505,6 +7914,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, @@ -7525,17 +7938,36 @@ export class Session { 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: timeline.hasOlder, - hasNewer: timeline.hasNewer, - rows: rows.map((row) => ({ - item: row.item, - timestamp: row.timestamp, - seq: row.seq, - })), + 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, }, }, @@ -7623,6 +8055,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 { @@ -7665,20 +8110,20 @@ export class Session { logger: this.sessionLogger, }); if (result.disposition === "turn_started") { - await waitForAgentRunStartWithTimeout(this.agentManager, agentId); + await waitForAgentRunStartWithTimeout( + this.agentManager, + agentId, + this.delivery.requestSignal, + ); } }; if (msg.messageId) { - await this.agentRequests.send({ + await this.messageReceipts.send({ agentId, messageId: msg.messageId, request: { prompt, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt" }, prepare: async () => { - await ensureAgentLoaded(agentId, { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - logger: this.sessionLogger, - }); + await this.prepareAgentMessage(agentId, msg.text); }, send, }); @@ -7696,6 +8141,7 @@ 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", @@ -7714,6 +8160,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({ @@ -7773,7 +8221,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); @@ -7796,6 +8244,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")); @@ -7836,41 +8285,80 @@ export class Session { */ // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. private wantsEvent(event: SessionEventSubscription, source?: object): boolean { - if (!source && this.clientCapabilitiesBySource.size > 0) { - return [...this.clientCapabilitiesBySource.keys()].some((candidate) => - this.wantsEvent(event, candidate), - ); + 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.clientCapabilitiesBySource.get(source)! + ? (this.clientSources.get(source)?.capabilities ?? this.clientCapabilities) : this.clientCapabilities; - if (event === "project.update" && !capabilities.has(CLIENT_CAPS.projectUpdates)) return false; - return ( - !capabilities.has(CLIENT_CAPS.explicitEventSubscriptions) || - this.eventSubscriptions.get(source ?? this.defaultEventSubscriptionSource)?.has(event) === - true + 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 ( - msg.type === "project.update" || - msg.type === "providers_snapshot_update" || - msg.type === "agent_attention_required" || - msg.type === "agent_permission_request" || - msg.type === "agent_permission_resolved" - ) { - if (this.clientCapabilitiesBySource.size > 0 && this.onMessageToSource) { - for (const source of this.clientCapabilitiesBySource.keys()) { - if (this.wantsEvent(msg.type, source)) this.onMessageToSource(source, msg); - } - return; - } - if (!this.wantsEvent(msg.type)) return; - } + 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. @@ -7884,8 +8372,8 @@ export class Session { ); } if (msg.type === "workspace_setup_progress" || msg.type === "workspace_setup_status_response") { - if (this.clientCapabilitiesBySource.size > 0 && this.onMessageToSource) { - for (const [source] of this.clientCapabilitiesBySource) { + if (this.clientSources.size > 0 && this.onMessageToSource) { + for (const [source] of this.clientSources) { this.onMessageToSource(source, this.workspaceSetupMessageForClient(msg, source)); } return; @@ -7897,12 +8385,14 @@ export class Session { // COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. private workspaceSetupMessageForClient( - message: Extract< - SessionOutboundMessage, - { type: "workspace_setup_progress" | "workspace_setup_status_response" } - >, + 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); @@ -7933,6 +8423,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; } @@ -7941,6 +8432,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; } @@ -7953,6 +8445,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(); @@ -7964,8 +8457,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) { @@ -7974,14 +8465,11 @@ export class Session { } this.providerCatalogSession.dispose(); - await this.voiceSession.cleanup(); - this.terminalController.dispose(); this.checkoutSession.cleanup(); this.workspaceGitObserver.dispose(); - this.workspaceFilesSession.dispose(); } } @@ -8035,3 +8523,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 69c548b922..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,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +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"; @@ -24,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; @@ -192,7 +188,8 @@ function createSessionForWorkspaceGitWatchTests(options?: { }; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), @@ -203,6 +200,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { agentManager: createStub({ subscribe: () => () => {}, listAgents: () => [], + listProviderSubagentActivity: () => [], getAgent: () => null, }), agentStorage: createStub({ @@ -331,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", @@ -394,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, @@ -403,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; @@ -493,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( @@ -575,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, @@ -584,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 c444d9ed24..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,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +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 @@ -89,7 +92,8 @@ function createHarness(input: { }; const session = new Session({ - agentRequests: createAgentRequestsStub(), + 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 2914e58215..2866732abe 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { execFileSync } from "node:child_process"; import { existsSync, @@ -146,7 +149,6 @@ interface SessionTestAccess { upsert(record: unknown): Promise; }; agentUpdates: AgentUpdatesService; - workspaceUpdatesSubscription: unknown; interruptAgentIfRunning(agentId: string): unknown; reconcileWorkspaceRecord(workspaceId: string): Promise<{ changed: boolean; @@ -155,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; @@ -172,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; @@ -209,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() }) }); @@ -642,7 +645,8 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: options.appVersion ?? null, @@ -865,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 }); @@ -915,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, @@ -999,7 +1007,8 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const emitted: SessionOutboundMessage[] = []; const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", serverId: "test-server", permissions: OWNER_PERMISSIONS, @@ -1153,7 +1162,8 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1291,7 +1301,8 @@ test("create_agent_request does not title an existing workspace from the agent p let generateCalls = 0; const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1462,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({ @@ -1504,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({ @@ -1561,7 +1572,8 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -1664,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"); @@ -2044,7 +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({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2145,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", @@ -2213,7 +2226,8 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2328,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", @@ -2373,7 +2387,8 @@ test("close_items_request continues after an archive failure", async () => { const killTerminalBestEffort = vi.fn(); const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2479,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", @@ -3645,7 +3660,8 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -3731,13 +3747,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([ [ @@ -3825,33 +3841,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(); @@ -3915,13 +3911,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()); @@ -4012,15 +4007,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(); @@ -4229,13 +4220,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([ [ @@ -4392,13 +4382,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, @@ -4595,7 +4584,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"); @@ -4604,9 +4593,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({ @@ -4656,13 +4642,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, @@ -5303,13 +5288,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({ @@ -6105,13 +6089,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()); @@ -6845,7 +6828,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", @@ -6865,20 +6848,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]); @@ -6937,13 +6917,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); }; @@ -7112,6 +7095,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({ @@ -7132,13 +7119,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]; @@ -7178,6 +7158,7 @@ test("workspace_update includes updated runtime fields", async () => { const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + onMessage: (message) => emitted.push(message), }), ); const project = createPersistedProjectRecord({ @@ -7198,16 +7179,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]; @@ -7380,19 +7357,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 () => {}, @@ -7444,12 +7408,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; }; }); @@ -7559,12 +7523,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?.({ @@ -7638,28 +7607,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); @@ -8072,13 +8032,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", @@ -8249,13 +8207,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", @@ -8319,13 +8275,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", @@ -8422,11 +8376,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); @@ -8459,13 +8413,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 }; } @@ -8595,7 +8547,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), @@ -8639,7 +8591,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), @@ -8697,7 +8649,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), @@ -8761,7 +8713,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), @@ -8814,7 +8766,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), @@ -8853,7 +8805,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), 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 a4b1579688..58521f336f 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; @@ -162,7 +152,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, @@ -173,7 +163,8 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU ) => deps.sequenceAgentUpdate(payload, agent, project, agentId, sub.syncEnabled === true); 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 +172,112 @@ 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, - }) + observers.some( + (sub) => + 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 && 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,7 +299,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU } function forwardLiveAgent(agent: ManagedAgent): Promise { - if (!subscription) { + if (!hasSubscription()) { const workspaceId = agent.workspaceId; return workspaceId ? enqueueAgentUpdate(agent.id, async () => { @@ -408,17 +317,14 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU 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..98185ddc08 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"; @@ -48,6 +49,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 = { @@ -173,7 +175,23 @@ function makeCheckoutSession(options?: { 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 +550,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 +584,7 @@ describe("CheckoutSession", () => { }, }); - checkout.handleUnsubscribeDiffRequest({ + await unsubscribe({ type: "unsubscribe_checkout_diff_request", subscriptionId: "s1", }); @@ -574,22 +592,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 +626,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 +643,7 @@ describe("CheckoutSession", () => { requestId: "r", }); - checkout.cleanup(); + await close(); expect(subscriptions[0].unsubscribeCalls).toBe(1); expect(subscriptions[1].unsubscribeCalls).toBe(1); diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 789f1f0449..152734603c 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) { @@ -400,48 +403,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 { @@ -1413,10 +1445,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-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/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/owned-subscriptions/index.ts b/packages/server/src/server/session/owned-subscriptions/index.ts new file mode 100644 index 0000000000..9765d9c064 --- /dev/null +++ b/packages/server/src/server/session/owned-subscriptions/index.ts @@ -0,0 +1,433 @@ +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 { + request: SessionInboundMessage; +} +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; +} + +/** Source provenance and lifetimes. Domain producers own snapshots, filters and buffering. */ +export class SessionDelivery { + private readonly requests = new AsyncLocalStorage(); + private readonly projectionSource = new AsyncLocalStorage(); + private readonly sources = new Map(); + private readonly proofs = new WeakMap(); + private readonly defaultSource = {}; + + 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 { + 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 { + return this.projectionSource.getStore() ?? this.requests.getStore()?.source.socket; + } + + get requestSignal(): AbortSignal { + const request = this.requests.getStore(); + if (!request) throw new Error("Operation has no requesting source"); + return request.source.cancellation.signal; + } + + forSource(source: object, project: () => T): T { + return this.projectionSource.run(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); + } + + async close(): Promise { + await Promise.all([...this.sources.keys()].map((socket) => this.detach(socket))); + } + + async request( + socket: object | undefined, + request: SessionInboundMessage, + run: () => Promise, + ): Promise { + socket ??= this.defaultSource; + if (!this.sources.has(socket)) this.attach(socket, false); + const source = this.sources.get(socket)!; + const owner: RequestOwner = { source, request, active: true }; + try { + await this.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.requests.getStore(); + if (owner?.active && owner.source.socket === socket && isReply(owner.request, message)) + this.proofs.set(message, owner); + return message; + } + + reply(message: SessionOutboundMessage): boolean { + const owner = this.requests.getStore(); + if (!owner?.active) 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; + } + 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.requests.getStore(); + 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.requests.getStore(); + 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.requests.getStore()?.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.requests.getStore()?.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.requests.getStore(); + 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..2fcfc1b97e --- /dev/null +++ b/packages/server/src/server/session/owned-subscriptions/replies.ts @@ -0,0 +1,69 @@ +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"], + "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/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index b72a5142d9..a174f1db5a 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 @@ -54,7 +54,10 @@ function makeSubsystem(options: MakeOptions = {}) { isProviderVisibleToClient: (provider) => visible.has(provider), supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false, supportsProviderSnapshotReferences: () => false, - wantsSnapshotChanges: () => true, + publishSnapshot: (project) => { + const message = project(); + if (message) emitted.push(message); + }, supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false, listProviderAvailability: async () => [], listDraftFeatures: async () => [], @@ -396,7 +399,10 @@ it("announces shared content without retransmitting models or hashing discovery isProviderVisibleToClient: () => true, supportsCustomModeIcons: () => true, supportsCompactProviderSnapshots: () => true, - wantsSnapshotChanges: () => true, + publishSnapshot: (project) => { + const message = project(); + if (message) emitted.push(message); + }, supportsProviderSnapshotReferences: () => references, listProviderAvailability: async () => [], listDraftFeatures: async () => [], 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 7175cabbc9..dd87543c19 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -48,7 +48,7 @@ export interface ProviderCatalogSessionHost { supportsCustomModeIcons(): boolean; supportsCompactProviderSnapshots(): boolean; supportsProviderSnapshotReferences(): boolean; - wantsSnapshotChanges(): boolean; + publishSnapshot(project: () => SessionOutboundMessage | null): void; listProviderAvailability(): Promise; listDraftFeatures(config: AgentSessionConfig): Promise; } @@ -81,15 +81,18 @@ export class ProviderCatalogSession { this.logger = options.logger; } + get isObserving(): boolean { + return this.unsubscribeSnapshotEvents !== null; + } + start(): void { + if (this.unsubscribeSnapshotEvents) return; const handleProviderSnapshotChange = (transition: ProviderSnapshotTransition) => { - if (!this.host.wantsSnapshotChanges()) return; - const previous = this.visibleSnapshot(transition.previous); - const current = this.visibleSnapshot(transition.current); - if (sameSnapshotRecords(previous.records, current.records)) return; - this.host.emit({ - type: "providers_snapshot_update", - payload: this.snapshotPayload(current), + 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); 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.ts b/packages/server/src/server/session/voice/voice-session.ts index f6331dba3f..b374e759ae 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; } @@ -636,8 +655,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"); } @@ -1191,6 +1211,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 +1259,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 +1311,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.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index a89e044883..ecd338ff4e 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 @@ -40,6 +40,7 @@ export interface ImportWorkspaceResult { export interface CreateWorktreeWorkspaceInput { sourceCwd: string; projectId?: string; + workspaceId?: string; repoRoot: string; cwd: string; worktreeRoot: string; @@ -61,7 +62,7 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; workspaceId?: string }, ): Promise; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, @@ -201,7 +202,7 @@ export function createWorkspaceProvisioningService(deps: { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; workspaceId?: string }, ): Promise { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -211,7 +212,7 @@ 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, @@ -237,7 +238,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", 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..2116e82b11 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 @@ -66,6 +66,7 @@ export function createWorkspaceScriptsService(deps: { globalServicePorts?: PaseoServicePortAllocation; logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; + wantsStatusUpdates?: () => boolean; spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise; assertAutomationAllowed: (workspaceId: string) => Promise; }): WorkspaceScriptsService { @@ -129,6 +130,7 @@ export function createWorkspaceScriptsService(deps: { } async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise { + if (deps.wantsStatusUpdates && !deps.wantsStatusUpdates()) return; try { const workspace = await workspaceRegistry.get(workspaceId); if (!workspace) return; diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 8e32c27cbe..2cfe5d3e18 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +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"; @@ -100,7 +103,8 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage, 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..2a437f9bbf 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -62,6 +62,7 @@ interface FakeAgentSessionOptions { } export interface TestAgentClientOptions { + beforeCreateSession?: () => Promise; closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput) => void; supportsMcpServers?: boolean; @@ -737,6 +738,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", @@ -1202,6 +1213,7 @@ class FakeAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise { + await this.options.beforeCreateSession?.(); return new FakeAgentSession({ providerName: this.provider, config: { ...config }, diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 15ed7ecc09..1c98686042 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -1,5 +1,10 @@ +import pino from "pino"; import { randomUUID } from "node:crypto"; -import { vi } from "vitest"; +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"; @@ -28,7 +33,10 @@ export function asSessionLogger(stub: { export function asAgentManager(stub: { [K in keyof SessionOptions["agentManager"]]?: unknown; }): SessionOptions["agentManager"] { - return createStub(stub); + return createStub({ + listProviderSubagentActivity: () => [], + ...stub, + }); } export function asAgentStorage(stub: { @@ -278,13 +286,8 @@ export function createProviderSnapshotManagerStub(): { }; } -export function createAgentRequestsStub(): SessionOptions["agentRequests"] { +export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] { return { - async create(input) { - const agentId = randomUUID(); - await input.create(agentId); - return agentId; - }, send: (input) => input.send(), }; } @@ -301,3 +304,17 @@ export function createProviderSnapshot( }), }; } + +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/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index 4f7f800dc9..daa1ebbb3d 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -332,30 +332,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 29841b7938..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" | "agent.timeline.replacement">) => { +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; 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 c470476913..79577d6889 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"; @@ -46,15 +47,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); @@ -889,7 +902,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", }, }, 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 30fce35b13..705a53342e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,4 +1,7 @@ -import { AgentRequests } from "./agent/requests/index.js"; +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"; @@ -92,11 +95,7 @@ import { 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"; @@ -418,15 +417,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; @@ -463,11 +453,6 @@ interface PluginSessionConnection extends SessionConnectionBase { type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection; -interface BrowserToolsRegistration { - capabilitySignature: string; - unregister: () => void; -} - interface SocketSessionOptions { clientId: string; appVersion: string | null; @@ -478,7 +463,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; @@ -547,7 +532,8 @@ export class VoiceAssistantWebSocketServer { private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; - private readonly agentRequests: AgentRequests; + private readonly messageReceipts: MessageReceipts; + private readonly creationService: CreationService; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceLabelService: WorkspaceLabelService | null; @@ -596,7 +582,6 @@ export class VoiceAssistantWebSocketServer { 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; @@ -604,6 +589,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, @@ -668,7 +672,13 @@ export class VoiceAssistantWebSocketServer { this.orchestrationSkills = orchestrationSkills; this.agentManager = agentManager; this.agentStorage = agentStorage; - this.agentRequests = new AgentRequests(join(paseoHome, "agent-requests")); + 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; @@ -1010,7 +1020,6 @@ export class VoiceAssistantWebSocketServer { for (const connection of new Set(this.externalSessionsByKey.values())) { if (connection.principalId === principalId) { connection.session.setPermissions(permissions); - this.syncBrowserToolsClientRegistration(connection); } } } @@ -1103,18 +1112,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; } @@ -1135,13 +1164,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, @@ -1232,7 +1272,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); } } @@ -1345,10 +1385,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"; @@ -1382,12 +1423,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, @@ -1412,7 +1454,8 @@ export class VoiceAssistantWebSocketServer { worktreesRoot: this.worktreesRoot, agentManager: this.agentManager, agentStorage: this.agentStorage, - agentRequests: this.agentRequests, + messageReceipts: this.messageReceipts, + creationService: this.creationService, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, workspaceLabelService: this.workspaceLabelService ?? undefined, @@ -1448,6 +1491,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) => { @@ -1565,7 +1610,6 @@ export class VoiceAssistantWebSocketServer { this.externalSessionsByKey.set(sessionKey, connection); } pending.identity.sessionId = connection.session.getSessionId(); - this.syncBrowserToolsClientRegistration(connection); this.sendToClient(ws, this.createServerInfoMessage(connection.session)); connection.connectionLogger.info( { @@ -1598,18 +1642,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( { @@ -1632,7 +1674,10 @@ export class VoiceAssistantWebSocketServer { desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { + ownedSubscriptions: true, agentRequestReceipts: true, + workspaceRequestReceipts: true, + creationLifecycle: true, hubAgentRpc: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, @@ -1728,6 +1773,8 @@ 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. @@ -1802,7 +1849,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), + }); } } @@ -1894,7 +1950,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"); @@ -1972,7 +2027,6 @@ export class VoiceAssistantWebSocketServer { this.externalSessionsByKey.delete(connection.sessionKey); } } - this.unregisterBrowserToolsClient(connection); connection.connectionLogger.trace( { clientId: connection.clientId, totalSessions: this.sessions.size }, @@ -1981,54 +2035,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; @@ -2071,35 +2077,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: { @@ -2128,7 +2112,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, @@ -2287,15 +2271,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; @@ -2348,32 +2323,11 @@ export class VoiceAssistantWebSocketServer { } 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}`, - }, - }), - ); + this.sessions.get(ws)?.session.delivery.protocolFailure(ws, { + ...requestInfo, + error: `Invalid message: ${err.message}`, + code: "invalid_message", + }); } private decodeRawMessagePayloadForError(data: Buffer | ArrayBuffer | Buffer[] | string): { @@ -2498,8 +2452,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, @@ -2532,14 +2486,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({ @@ -2564,8 +2526,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 = { @@ -2576,7 +2540,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, @@ -2598,7 +2563,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); } } @@ -2616,20 +2583,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; @@ -2663,8 +2638,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: { @@ -2678,7 +2655,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 baf720b51b..34abdebc3f 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,4 +1,7 @@ -import { createAgentRequestsStub } from "./test-utils/session-stubs.js"; +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import pino from "pino"; import { z } from "zod"; import { describe, expect, test } from "vitest"; @@ -208,12 +211,13 @@ function createSessionForWireCompatTest(options?: { ]; const session = new Session({ - agentRequests: createAgentRequestsStub(), + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "wire-compat-client", permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null, onMessage: (message) => messages.push(message), - onMessageToSource: options?.onMessageToSource, + onMessageToSource: options?.onMessageToSource ?? ((_source, message) => messages.push(message)), logger: pino({ level: "silent" }), downloadTokenStore: {} as SessionOptions["downloadTokenStore"], pushNotifications: {} as SessionOptions["pushNotifications"], @@ -277,6 +281,7 @@ function createSessionForWireCompatTest(options?: { terminalManager: null, }); + session.updateClientCapabilities(options?.clientCapabilities ?? null, {}); return session; } @@ -342,6 +347,8 @@ describe("wire compatibility", () => { { type: "project.update", payload: { + generation: expect.any(String), + seq: 1, kind: "upsert", project: { projectId: "project-1", @@ -356,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 }, }, ]); }); @@ -549,13 +556,40 @@ 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]), }); - session.updateClientCapabilities({}, legacy); - session.updateClientCapabilities({ workspace_setup_blocked: true }, capable); + 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: { @@ -590,6 +624,20 @@ test("setup progress is adapted per socket without changing the canonical snapsh }, }, ]); + 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..cc05d496ba 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 @@ -205,8 +205,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 +233,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" }); 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-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 3f2a150b99..50c226cfb4 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -645,3 +645,52 @@ describe("WorkspaceDirectory empty projects", () => { expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]); }); }); + +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 8817576885..13b9996489 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -153,6 +153,18 @@ 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(); /** @@ -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)); @@ -575,6 +582,20 @@ 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, + })); + } + async listDescriptors(): Promise { return Array.from( ( 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 c55faa2bb5..5952307db4 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"); @@ -216,6 +219,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 +532,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 +557,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 +601,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 +658,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 +713,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 +763,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 +823,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 +852,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 +896,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 +907,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), @@ -923,7 +955,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "main", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: null, branchRemoteName: null, diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index fc1d211f33..524360678b 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { basename, 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"; @@ -94,8 +95,6 @@ const WATCH_RECOVERY_MAX_ATTEMPTS = 3; 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; @@ -566,10 +565,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 +722,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } - getCheckoutDiff( + async getCheckoutDiff( cwd: string, options: CheckoutDiffCompare, readOptions?: WorkspaceGitReadOptions, @@ -734,13 +730,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 +757,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( 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-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/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 9fbd7e3d2a..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,7 +323,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(), @@ -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(), @@ -617,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(), @@ -714,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) => { @@ -807,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 998c5c93e8..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({ @@ -436,9 +437,6 @@ export class TerminalSessionController { } else { terminals = await this.getAllTerminalSessions(); } - for (const terminal of terminals) { - this.ensureExitSubscription(terminal); - } this.emit({ type: "list_terminals_response", payload: { @@ -582,7 +580,6 @@ export class TerminalSessionController { rows: msg.size?.rows, cols: msg.size?.cols, }); - this.ensureExitSubscription(session); this.emit({ type: "create_terminal_response", payload: { @@ -662,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", @@ -687,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, @@ -721,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; } @@ -745,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; } @@ -812,8 +821,6 @@ export class TerminalSessionController { return; } - this.ensureExitSubscription(session); - try { const capture = await this.terminalManager.captureTerminal(msg.terminalId, { start: msg.start, @@ -848,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; @@ -873,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: [], @@ -897,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, @@ -919,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; @@ -950,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 || @@ -986,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; } @@ -1005,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 }; @@ -1015,7 +1057,7 @@ export class TerminalSessionController { return { shouldContinue: false }; } - this.emitBinary( + activeStream.owner.emitBinary( encodeLegacyTerminalSnapshotFrame({ slot: activeStream.slot, snapshot, @@ -1039,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 }; @@ -1049,7 +1091,7 @@ export class TerminalSessionController { return { shouldContinue: false }; } - this.emitBinary( + activeStream.owner.emitBinary( encodeTerminalRestoreFrame({ slot: activeStream.slot, snapshot, @@ -1100,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/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.test.ts b/packages/server/src/utils/checkout-git.test.ts index 45c0aff7c1..b5f505ea2d 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1507,8 +1507,7 @@ const x = 1; 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 HEAD -- :(literal)generated.js :(literal)small.ts"); expect(metrics.maxConcurrent).toBeLessThanOrEqual(8); }); @@ -2992,6 +2991,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"], { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index d20a41616e..e1246a90a5 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, @@ -639,7 +641,7 @@ function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }) } 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 { @@ -716,7 +718,7 @@ async function getTrackedDiffTextForPath(input: { const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace: input.ignoreWhitespace, - extra: [...getCheckoutDiffRefArgs(input.refsForDiff), "--", input.path], + extra: [...getCheckoutDiffRefArgs(input.refsForDiff), "--", `:(literal)${input.path}`], }), { cwd: input.cwd, @@ -732,6 +734,56 @@ async function getTrackedDiffTextForPath(input: { }; } +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"; @@ -1317,6 +1369,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 +1450,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), ]); @@ -1898,7 +1967,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 +2075,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 }), ]); } } @@ -2978,13 +3047,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 +3091,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 +3122,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 +3302,32 @@ async function processTrackedChanges( let trackedDiffText = ""; let trackedDiffBytes = 0; + const renamedPaths = new Set( + trackedChanges.filter((change) => change.oldPath).map((change) => change.path), + ); 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, - }), - ), + // Keep rename/copy path filtering identical to the existing per-file command. + const ordinary = await getTrackedDiffTexts({ + cwd, + refsForDiff, + paths: paths.filter((path) => !renamedPaths.has(path)), + ignoreWhitespace, + }); + const renamed = await Promise.all( + paths + .filter((path) => renamedPaths.has(path)) + .map((path) => + getTrackedDiffTextForPath({ + cwd, + refsForDiff, + 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/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/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/website/package.json b/packages/website/package.json index 346bbeda1f..0545ce6850 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.8.0-beta.1", + "version": "0.8.0", "private": true, "type": "module", "scripts": { diff --git a/plugin-examples/local-plugin/client/main.tsx b/plugin-examples/local-plugin/client/main.tsx index 172639e183..2bd574dc26 100644 --- a/plugin-examples/local-plugin/client/main.tsx +++ b/plugin-examples/local-plugin/client/main.tsx @@ -13,6 +13,7 @@ import { incrementRpc } from "../shared/increment"; export function contributeClient(client: PluginClientContext) { const pills = new Map(); let stopped = false; + const lifetime = new AbortController(); const register = (agent: { id: string; workspaceId?: string | null }) => { if (stopped || !agent.workspaceId) return; pills.get(agent.id)?.remove(); @@ -39,20 +40,30 @@ export function contributeClient(client: PluginClientContext) { pills.get(agentId)?.remove(); pills.delete(agentId); }; - const unsubscribe = client.paseo.agents.subscribe((update) => { - if (update.kind === "remove") remove(update.agentId); - else register(update.agent); - }); void client.paseo.agents - .list() - .then(({ entries }) => { - for (const { agent } of entries) register(agent); + .list({ subscribe: {}, signal: lifetime.signal }) + .then(({ subscription }) => { + subscription.subscribe({ + snapshot: ({ entries }) => { + for (const pill of pills.values()) pill.remove(); + pills.clear(); + for (const { agent } of entries) register(agent); + }, + update: (message) => { + if (message.type !== "agent_update") return; + const update = message.payload; + if (update.kind === "remove") remove(update.agentId); + else register(update.agent); + }, + }); return undefined; }) - .catch(() => undefined); + .catch((error) => { + if (!stopped) console.error("Agent observation failed", error); + }); return () => { stopped = true; - unsubscribe(); + lifetime.abort(); for (const pill of pills.values()) pill.remove(); pills.clear(); }; diff --git a/public-docs/cli.md b/public-docs/cli.md index ed626863f2..93b066e25b 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -276,18 +276,55 @@ Detaching is an explicit lifecycle action, not a creation flag. The agent keeps ## Daemon management +Define an instance once, then start its saved configuration: + ```bash -paseo daemon start # Start the daemon -paseo daemon start --web-ui # Start and serve the bundled web UI -paseo daemon status # Check status -paseo reload # Reload config.json (top-level alias) -paseo daemon reload # Reload config.json -paseo daemon stop # Stop the daemon +paseo daemon config set daemon.listen 127.0.0.1:6799 --home ~/paseo-test +paseo daemon config set daemon.relay.enabled false --home ~/paseo-test +paseo daemon start --home ~/paseo-test +paseo project ls --home ~/paseo-test +paseo daemon restart --home ~/paseo-test +paseo daemon stop --home ~/paseo-test ``` -Reload validates the whole file, applies runtime-safe changes, and reports `appliedPaths`, `restartRequiredPaths`, and `overrideControlledPaths`. Human output prints `paseo daemon restart` only when a changed setting needs it. Use `--json` or `--format yaml` for the structured result. Run `paseo --host reload` to reload a remote daemon's own configuration file. An older host that does not support reload returns an update-host error. +`start` runs in the background and reports the actual listening address and supervisor PID. It accepts only home selection and `--timeout ` (default 600). If waiting times out, the supervisor remains running: use the printed status, log, and stop instructions. A worker that exits before becoming ready makes startup fail. -Use `PASEO_HOME` to run multiple isolated daemon instances. +`restart` requests a replacement worker from the existing supervisor. It rereads the file and retains the supervisor's original environment and arguments. Success confirms a different ready worker, following a changed address for a home target. It never starts a stopped daemon or refreshes the supervisor binary. A timeout reports whether the request was acknowledged; it does not prove why reconnection failed. + +`stop --home` waits for that local supervisor to exit. On POSIX it signals the supervisor without contacting a TCP endpoint. On Windows it uses the ready daemon's shutdown RPC; an unbound instance requires explicit `--force`. `--force` permits forced process-tree cleanup after the graceful timeout (default 15 seconds). `stop --host` only reports **shutdown requested**; remote process exit is not verified. A service manager may start another instance after the captured supervisor exits. + +`status` separates local supervisor state, its published endpoint, the configured address, and RPC reachability. A stopped home is never probed at its configured address. An unbound live supervisor is **not ready**. If an authenticated local connection remains open but status details time out, the result stays `reachable` with a note explaining the unavailable details. Worker and provider fields are omitted. An explicit `--host` query still fails when its status request fails. + +`reload` validates the file, applies runtime-safe changes, and reports `appliedPaths`, `restartRequiredPaths`, and `overrideControlledPaths`. It never implicitly restarts. Use `--json` or `--format yaml` for structured results. An older host lacking the capability reports that it needs an update. + +The root aliases `start`, `status`, `restart`, `reload`, and `pair` use the same commands as `daemon`. Root `run` and `stop` remain agent operations. + +### Foreground deployments and migration + +Use environment overrides with the foreground deployment command: + +```bash +PASEO_LISTEN=127.0.0.1:6799 PASEO_RELAY_ENABLED=false paseo daemon run --home ~/paseo-test +``` + +It stays attached until the supervisor exits or you cancel, without a readiness timeout. Worker restart retains these launch inputs. Stop and relaunch the deployment to change them. If the home already has a live supervisor, `run` returns `already_running` without owning or launching a foreground process. + +Managed `start` ignores inherited daemon-setting overrides, including `PORT`, `PASEO_LISTEN`, relay, voice, and web UI settings. It preserves provider credentials and executable/runtime controls. `start --foreground` is removed; use `daemon run`. Former start/restart configuration flags such as `--port`, `--no-relay`, and `--web-ui` fail before side effects, with the corresponding `config set` migration. See [configuration edits](/docs/configuration#apply-changes). + +### Select one daemon + +Every daemon-connected CLI command accepts global `--home` or `--host`, before or after the command. A home selects a local supervisor's published endpoint; a host selects an explicit endpoint. There is no configured-address or default-port fallback. + +| Selectors | Result | +| ------------------------------------------ | ------------------------------------------------------ | +| `--home` | That local home, overriding both environment selectors | +| `--host` | That endpoint, overriding both environment selectors | +| Both flags, or conflicting duplicate flags | `TARGET_AMBIGUOUS` | +| Only `PASEO_HOME` or only `PASEO_HOST` | The corresponding target | +| Both environment selectors, without a flag | `TARGET_AMBIGUOUS` | +| Neither | Default local home, `~/.paseo` | + +Local-only `start`, `daemon run`, `config`, `onboard`, and `set-password` reject explicit `--host` and ignore `PASEO_HOST`. Endpoint operations retain TCP, Unix socket, Windows pipe, SSH, and pairing-offer transports. A host-side CLI controlling a container needs `--host` or `PASEO_HOST`. ## Hub @@ -329,12 +366,12 @@ The global `--host` option accepts either a local target (`host:port`, a unix so Get an offer URL from the daemon you want to control: ```bash -paseo daemon pair # asks before enabling relay, then prints the QR and link +paseo daemon pair # prints the QR and link when relay is enabled paseo daemon pair --relay # enables relay without prompting paseo daemon pair --json # structured output; never prompts ``` -Relay is off for new installations. In non-interactive or JSON mode, a disabled relay returns a `RELAY_DISABLED` error; pass `--relay` to provide explicit consent. Relay pairing is end-to-end encrypted. See [Security](/docs/security). +Relay is off for new installations. A disabled relay returns a `RELAY_DISABLED` error; pass `--relay` to provide explicit consent. For a stopped home, pairing is labelled offline; `--relay` saves relay enablement and the offer includes a start instruction. A live but unreachable home never falls back to an offline identity. Relay pairing is end-to-end encrypted. See [Security](/docs/security). Use it from anywhere: diff --git a/public-docs/configuration.md b/public-docs/configuration.md index a220420ee4..ca578a96ac 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -1,6 +1,6 @@ --- title: Configuration -description: Configure Paseo via config.json, environment variables, and CLI overrides. +description: Configure managed instances and foreground deployments. nav: Configuration order: 40 category: Configuration @@ -8,7 +8,7 @@ category: Configuration # Configuration -Paseo loads configuration from a single JSON file in your Paseo home directory, with optional environment variable and CLI overrides. +Managed instances load their configuration from the home’s `config.json` and defaults. Foreground deployments can override that file with environment variables. ## Where config lives @@ -18,18 +18,13 @@ By default, Paseo uses `~/.paseo` as its home directory. The configuration file ~/.paseo/config.json ``` -You can change the home directory by setting `PASEO_HOME` or passing `--home` to `paseo daemon start`. +You can change the home directory by setting `PASEO_HOME` or selecting `--home` on a CLI command. ## Precedence -Paseo merges configuration in this order: +Managed `start` and Desktop use defaults, then `config.json`. Inherited daemon-setting environment variables are removed from their launches. -1. Defaults -2. `config.json` -3. Environment variables -4. CLI flags - -Lists append across sources (for example, `hostnames` and `cors.allowedOrigins`). +Foreground `paseo daemon run`, Docker, and direct supervisor launches apply environment variables after the file. Existing legacy supervisor flags retain their precedence across worker restart. Changing deployment overrides requires stopping and relaunching that deployment. Lists append across sources, including hostnames and CORS origins. ## Example @@ -51,7 +46,19 @@ Minimal example that configures listening address, hostnames, and MCP: ## Apply changes -After saving `config.json`, reload it: +Edit the selected file through the CLI: + +```bash +paseo daemon config get daemon.listen --home ~/paseo-test +paseo daemon config set daemon.listen 127.0.0.1:6800 --home ~/paseo-test +paseo daemon config unset features.webUi.enabled --home ~/paseo-test +``` + +`get [path]` reports configured values and labels missing fields unset. It does not create a home. `set` parses JSON, otherwise treats the input as a string; `--string` forces a literal string. `--json` selects output format. Use whole-object JSON for dynamic keys containing dots. Unknown paths, invalid values, and invalid existing files are rejected without writing. Passwords use `set-password`. + +A successful edit saves the validated file, then reloads once if the instance is reachable. Output distinguishes applied changes, restart requirements, and deployment overrides. A stopped/unbound/unreachable instance reports **saved; not applied**. A failed reload reports **saved; reload failed** with a nonzero exit; the file remains saved. Edits never implicitly restart. + +After editing `config.json` directly, reload it: ```bash paseo reload @@ -69,7 +76,7 @@ New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon w Listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths. -Environment variables and daemon start flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Remove the override and restart the daemon if you want the file value to become authoritative. +Deployment environment variables and legacy supervisor flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Stop and relaunch the deployment without the override to make the file value authoritative. ## Agent providers @@ -104,13 +111,14 @@ The daemon can serve the browser web client from the same HTTP server. This is e Enable it from the CLI: ```bash -paseo daemon start --web-ui +paseo daemon config set features.webUi.enabled true +paseo daemon start ``` Or set the environment variable: ```bash -PASEO_WEB_UI_ENABLED=true paseo daemon start +PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Or persist it in `config.json`: @@ -225,7 +233,7 @@ Set the persisted value in `config.json`: } ``` -`PASEO_RELAY_ENABLED=true|false` overrides the persisted value for that daemon launch. The matching `paseo daemon start --relay` and `--no-relay` flags have the same authority. Remove the launch override before changing relay from Paseo Desktop or `paseo daemon pair --relay`. +`PASEO_RELAY_ENABLED=true|false` overrides the file for a foreground deployment. Managed `start` uses the file. End and relaunch a deployment to remove its override before changing relay from the app or `paseo daemon pair --relay`. ## Common env vars diff --git a/public-docs/connectivity.md b/public-docs/connectivity.md index 425e605fd1..3263e61510 100644 --- a/public-docs/connectivity.md +++ b/public-docs/connectivity.md @@ -43,7 +43,7 @@ Set a different remote daemon port with `daemonPort`: paseo --host 'ssh://user@host?daemonPort=7777' ls -a ``` -Put `--host` before the command. `paseo daemon status` checks only the local daemon; use `paseo --host ssh://user@host ls` to verify a remote connection. `paseo --host ssh://user@host run --cwd /path/on/remote ...` requires a working directory that exists on the remote host. +Put `--host` before the command. `paseo daemon status` observes the default local home; use `paseo --host ssh://user@host daemon status` to query a remote daemon. `paseo --host ssh://user@host run --cwd /path/on/remote ...` requires a working directory that exists on the remote host. In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination. diff --git a/public-docs/docker.md b/public-docs/docker.md index be533e66a2..f6324a09f2 100644 --- a/public-docs/docker.md +++ b/public-docs/docker.md @@ -41,6 +41,8 @@ The image: The image does not bundle agent CLIs such as Claude Code, Codex, OpenCode, Copilot, or Pi. Add the agents you use with a small child image. +Host-side CLI commands select the container explicitly, for example `paseo project ls --host 127.0.0.1:6767`. Without an endpoint selector the CLI looks for a local home’s supervisor. Container environment settings are deployment overrides; worker restart preserves them. Your container manager owns full supervisor replacement. + ## Docker Compose ```yaml diff --git a/public-docs/hub/configuration/hub-yml.md b/public-docs/hub/configuration/hub-yml.md index 0bc82abf16..d243b1a08c 100644 --- a/public-docs/hub/configuration/hub-yml.md +++ b/public-docs/hub/configuration/hub-yml.md @@ -188,20 +188,21 @@ An environment or dynamic named-agent expression must have a finite set of possi ### Steps -| Field | Required | Notes | -| --------------- | -------- | ---------------------------------------------------------------------------------------- | -| `id` | yes | Unique within the workflow. | -| `environment` | yes | Literal environment name or finite expression resolving to one. | -| `max_runtime` | yes | Step hard limit. | -| `idle_timeout` | yes | Idle limit no longer than `max_runtime`. | -| `agent` | yes | Named agent, finite expression selecting a named agent, or complete static inline agent. | -| `prompt` | yes | Ordered `text` and `include` blocks. | -| `if` | no | Expression deciding whether the step runs. | -| `env` | no | Environment variables from connection values. | -| `output.schema` | no | JSON Schema for structured step output. | -| `allow_outputs` | no | Provider output capabilities with optional `max` and `required`. | -| `auto_archive` | no | Archive the agent after the step ends. | -| `github` | no | Explicit GitHub authority for this step. | +| Field | Required | Notes | +| ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `id` | yes | Unique within the workflow. | +| `environment` | yes | Literal environment name or finite expression resolving to one. | +| `max_runtime` | yes | Step hard limit. | +| `idle_timeout` | yes | Idle limit no longer than `max_runtime`. | +| `startup_timeout` | no | Step startup waiting budget. See [startup timeout](/docs/hub/configuration#startup-timeout) for defaults and limits. | +| `agent` | yes | Named agent, finite expression selecting a named agent, or complete static inline agent. | +| `prompt` | yes | Ordered `text` and `include` blocks. | +| `if` | no | Expression deciding whether the step runs. | +| `env` | no | Environment variables from connection values. | +| `output.schema` | no | JSON Schema for structured step output. | +| `allow_outputs` | no | Provider output capabilities with optional `max` and `required`. | +| `auto_archive` | no | Archive the agent after the step ends. | +| `github` | no | Explicit GitHub authority for this step. | An inline agent is static and complete: @@ -270,7 +271,7 @@ Keys use the existing expression syntax and must resolve to a non-empty string o An existing session keeps its daemon, agent configuration, target, environment, and tool contracts. Changing those settings for the same key fails with an explanation; choose a different key or **New agent**. Prompts and output destinations belong to each arrival and may change. A worktree branch is chosen when the session is first created and reused on later arrivals. -Triggers that mint temporary environment credentials, including a `run.github` grant or a connection token in `run.env`, must choose **New agent** when the event has a conversation or uses a custom key. These credentials expire with the execution, and an existing agent's environment cannot be refreshed. Hub-managed reply tools remain available with continuation. +A follow-up steers the active agent without extending its runtime deadline or credential expiry. With a `run.github` grant or connection token in `run.env`, follow-ups share the active agent’s credentials. Once all requests finish, Hub revokes leased tokens; the next arrival starts a new agent with fresh credentials. Agents without temporary credentials can be reused after completion. ### Upgrading diff --git a/public-docs/hub/configuration/index.md b/public-docs/hub/configuration/index.md index 1d82ff27d6..202fb4d05d 100644 --- a/public-docs/hub/configuration/index.md +++ b/public-docs/hub/configuration/index.md @@ -64,6 +64,23 @@ A Discord starter uses `discord.mention`, your Discord user ID, and `discord.rep Setup asks before replacing the selected trigger file. It preserves other triggers and any existing legacy bundle. Read [Hub security](/docs/hub/security) before widening `from_users` or the agent's authority. +## Startup timeout + +Hub waits up to **two minutes** for agent startup, including worktree creation, provider startup, and initial prompt acceptance. For a slower machine, set `run.startup_timeout` in the trigger YAML: + +```yaml +name: inspect +on: + manual.run: {} +run: + target: { daemon: devbox, cwd: /workspace/project } + agent: { provider: codex, mode: full-access } + startup_timeout: 5m + prompt: Inspect this repository and summarize its current state. +``` + +Use a positive duration in `ms`, `s`, `m`, or `h`, up to `24h`. Omitting the field uses `2m`. The existing `max_runtime` and `idle_timeout` limits still apply during startup and can expire sooner. This setting changes Hub's waiting budget; provider-specific timeouts remain in effect. + ## Deploy from the CLI Run from the repository root: diff --git a/public-docs/hub/github.md b/public-docs/hub/github.md index 51b9a0e28b..c31abe4b56 100644 --- a/public-docs/hub/github.md +++ b/public-docs/hub/github.md @@ -37,7 +37,7 @@ steps: ${{ paseo.prompt }} ``` -The agent can use `git` and `gh` within the declared repositories and permissions. Hub mints the token when the step starts and revokes it when execution ends. +The agent can use `git` and `gh` within the declared repositories and permissions. See [agent continuation](/docs/hub/configuration/hub-yml#agent-continuation) for the token lifecycle when requests continue an agent. ## Fields @@ -73,4 +73,4 @@ env: SOME_TOKEN: "${{ paseo.connections.some-connection.token }}" ``` -Hub resolves the value for the step and does not persist it. See [Hub security](/docs/hub/security) for provider and host boundaries. +Hub resolves the value when it prepares the agent's environment. See [Hub security](/docs/hub/security) for provider and host boundaries. diff --git a/public-docs/hub/triggers/index.md b/public-docs/hub/triggers/index.md index fa595d34b1..5fc4367934 100644 --- a/public-docs/hub/triggers/index.md +++ b/public-docs/hub/triggers/index.md @@ -44,7 +44,7 @@ If the daemon is offline or needs a newer Paseo version, the agent selectors sho Dashboard triggers default to **Same conversation**. Messages in the same Slack or Discord thread, events on the same GitHub issue or pull request, and events on the same Linear issue continue the existing agent in that project. An event without a conversation starts a new agent. -If the agent is busy, the new prompt steers its current work. If its workspace is archived, Hub asks Paseo to restore it before sending the prompt. Each arrival still has its own deadline, output limits, and completion status. +If the agent is busy, the new prompt steers its current work. Each arrival keeps its own output limits and completion status. If a reusable workspace is archived, Hub asks Paseo to restore it before sending the prompt. Choose **Custom key** to group arrivals by an input, or **New agent** to keep them separate. A self-contained trigger document can express the same choice: @@ -67,7 +67,7 @@ run: prompt: Handle this request and call finish_execution when complete. ``` -The [continuation reference](/docs/hub/configuration/hub-yml#agent-continuation) describes keys and compatibility. The run detail shows whether each arrival created, continued, or restored an agent. +The [continuation reference](/docs/hub/configuration/hub-yml#agent-continuation) covers keys and agent reuse. The run detail shows whether each arrival created, continued, or restored an agent. Hub includes an `executionId` in each prompt. When using a continuing agent, pass that ID to `reply` and `finish_execution`. These tools act on that arrival's destination and contract; an old or unrelated execution ID is rejected. diff --git a/public-docs/index.md b/public-docs/index.md index 7bf22016a4..f93216883d 100644 --- a/public-docs/index.md +++ b/public-docs/index.md @@ -16,6 +16,27 @@ Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub rele The desktop app bundles its own daemon and starts it automatically, no separate install required. On first launch you'll see a brief startup screen, then connect from your phone using **Settings → your host → Pair Device**. +### Linux + +Use the `.deb` on Debian/Ubuntu or the `.rpm` on Fedora to keep Chromium's sandbox available even when your distribution restricts user namespaces. The installer configures the bundled sandbox helper; you do not need to change system security settings. + +For an AppImage, make the download executable and open it: + +```bash +chmod +x Paseo-x86_64.AppImage +./Paseo-x86_64.AppImage +``` + +If it reports `error loading libfuse.so.2`, run without FUSE: + +```bash +./Paseo-x86_64.AppImage --appimage-extract-and-run +``` + +Alternatively, install `libfuse2t64` on Ubuntu 24.04 or newer (`sudo apt install libfuse2t64`), or your distribution's FUSE 2 compatibility package. This dependency belongs to the AppImage runtime, before Paseo starts. + +Paseo checks sandbox availability each time it launches. AppImage and extracted tar archives retain sandboxing when user namespaces work. On a restricted host without a usable installed helper, they launch with Chromium's sandbox disabled. Prefer the installed package if you require OS process isolation. **Settings → Diagnostics → App Diagnostics** reports the sandbox state and reason; the desktop log records the same decision. An explicit `--no-sandbox` argument overrides the automatic choice. + ## Server / CLI For headless machines, dev boxes, or any setup where you want the daemon running without the desktop UI: diff --git a/public-docs/plugins/v0.8/providers.md b/public-docs/plugins/v0.8/providers.md index 71eaa08dce..744366ade6 100644 --- a/public-docs/plugins/v0.8/providers.md +++ b/public-docs/plugins/v0.8/providers.md @@ -117,6 +117,11 @@ later through `session.config`. prompt, MCP servers, tool policy, model, mode, settings, opaque provider options, and persistence preference. +For persisted host-scoped plugin settings, use the handle returned by `server.registerSettings()`. +Read it in catalog and session callbacks, and subscribe when the provider keeps in-memory +configuration. See [settings](./reference.md#persisted-values) for the `read()` and `subscribe()` +contract. + Create the native session with the selected catalog values, then publish its effective state: ```ts diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index 3677956a75..62c2f0ed80 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -854,7 +854,12 @@ export default function contribute(client: PluginClientContext) { `query.itemType` is the stable, coarse selector. Inspect the selected item inside `transform` for provider- or tool-specific recognition. Returning `undefined` keeps the original entry. Returning `items` replaces it; an empty array removes it. Item `data` must be JSON-compatible. The `phase` -input is `"streaming"` for running tool calls and loading reasoning, and `"complete"` otherwise. +input is `"streaming"` for the live assistant message, running tool calls, and loading reasoning; +it is `"complete"` for committed or fetched messages and finished tools or reasoning. +Assistant and reasoning callbacks receive the full accumulated text on each update, including +paragraph separators. Paseo invokes transformers before splitting native Markdown or grouping +tools in Overview. A claimed assistant message remains one source item throughout streaming; +return `undefined` until recognizable if the first text is insufficient to identify it. Each replacement may set an optional plugin-local `id`; otherwise Paseo uses its index within that source item's output. @@ -1044,9 +1049,22 @@ export const preferences = defineSettings({ }); ``` -Register it with `server.registerSettings(preferences)` in `index.server.ts` before returning -cleanup. This server entry is required for built-in persistence; a screen using its own data -can remain client-only. +Register it in `index.server.ts` before returning cleanup. The returned handle lets server code +read the document and react to changes. A screen using its own data can remain client-only. + +```ts +export default function contribute(server: PluginServerContext) { + const settings = server.registerSettings(preferences); + + settings.subscribe((next) => { + if (next.status === "ready") { + console.log("Settings changed", next.revision); + } + }); + + return () => {}; +} +``` | Definition field | Contract | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | @@ -1065,6 +1083,19 @@ Call `useSettings(preferences)` in any contributed component. It returns a discr | `invalid` | `error` and `revision`; stored data is preserved. | | `error` | `error` from the read/connection. | +The server handle exposes `read()` and `subscribe()`. `read()` returns the same `ready` or +`invalid` state as the client hook, including the opaque revision. `subscribe()` returns a cleanup +function and receives a new `ready` state after a successful save, reset, or migration. Invalid +writes and revision conflicts do not notify subscribers. Listener failures are logged without +turning a committed write into a failed save. + +```ts +const current = await settings.read(); +if (current.status === "ready") { + // Use current.values and current.revision. +} +``` + Every state also exposes `saving`, `saveError`, and these actions: - `save(values, revision): Promise` validates and saves a complete document. Returns @@ -1357,6 +1388,10 @@ const pill = client.addComposerPill({ }); ``` +For pills that follow the agent directory, use an explicit [owned list subscription](/docs/sdk/events#follow-one-agents-status). +The [local plugin example](https://github.com/getpaseo/paseo/blob/main/plugin-examples/local-plugin/client/main.tsx) +replaces registrations on each snapshot and aborts the observation during entry cleanup, including pending bootstrap. + ## Button descriptor These contracts are exported from `@getpaseo/plugin/client`. diff --git a/public-docs/sdk/agents.md b/public-docs/sdk/agents.md index 565bedbef8..fa3ed80c00 100644 --- a/public-docs/sdk/agents.md +++ b/public-docs/sdk/agents.md @@ -156,12 +156,17 @@ The handle exposes `status`, `capabilities`, `availableModes`, `pendingPermissio A handle from `ref()` has observed nothing, so every one of them is `null` until `refresh()`, `run()`, `waitForFinish()`, a timeline refetch, or `subscribe()` delivers a snapshot. Optional values in an observed snapshot also read as `null`. Use `current()` when you need the whole snapshot or need to distinguish those states. -`subscribe()` keeps the properties current, so a long-lived handle can poll them without another RPC: +`subscribe()` is a local listener. An owned agent-directory observation supplies its updates: ```ts const unsubscribe = agent.subscribe(() => { if (agent.status === "error") console.error(agent.lastError); }); +const directory = await client.agents.list({ subscribe: {} }); + +// When this view closes: +unsubscribe(); +await directory.subscription.release(); ``` ## List the commands a session loaded diff --git a/public-docs/sdk/events.md b/public-docs/sdk/events.md index dafba6bcb3..9762323dce 100644 --- a/public-docs/sdk/events.md +++ b/public-docs/sdk/events.md @@ -8,39 +8,54 @@ category: TypeScript SDK # SDK events -Subscriptions report changes after they happen. Fetch an initial snapshot first, then apply updates to it. - -Every `subscribe()` method returns an unsubscribe function. Timeline, project, and provider subscriptions establish network demand and restore it after reconnect. Unsubscribing releases that demand when its last listener leaves. Agent and workspace directories use the explicit `list({ subscribe })` bootstrap shown below. Unsubscribing never stops or archives the underlying resource. +Use an owned subscription to fetch a snapshot and follow its changes. On capable daemons, connecting, plain reads, and local directory listeners do not start observation. Each observation gets a new server-issued ID, even when its filter matches another observation. ## Follow one agent's status -The daemon sends agent-directory updates only after the connection opens an agent-list subscription: - ```ts -await client.agents.list({ +const directory = await client.agents.list({ filter: { includeArchived: false }, - subscribe: { subscriptionId: "issue-board-agents" }, + subscribe: {}, }); -const agent = client.agents.ref(agentId); -await agent.refresh(); - -const unsubscribe = agent.subscribe((update) => { - if (update.kind === "upsert") { - console.log(update.agent.status); - } else { - console.log("Agent removed from the active directory"); - } +directory.subscription.subscribe({ + snapshot({ entries }) { + const entry = entries.find(({ agent }) => agent.id === agentId); + console.log(entry?.agent.status); + }, + update(message) { + if (message.type !== "agent_update") return; + const update = message.payload; + if (update.kind === "upsert" && update.agent.id === agentId) { + console.log(update.agent.status); + } else if (update.kind === "remove" && update.agentId === agentId) { + console.log("Agent removed from this directory"); + } + }, }); + +// When this view closes: +await directory.subscription.release(); ``` -The handle updates its properties and `current()` value before it calls your handler. +`list({ subscribe: {} })` returns the snapshot, `subscriptionId`, and `subscription`. Omitting `subscribe` returns only a snapshot. Do not supply a subscription ID. The subscription delivers its snapshot before updates and receives a new ID and snapshot after reconnect. Releasing it leaves other observations and the underlying agents intact. + +`client.agents.subscribe()` and agent-handle `subscribe()` add local listeners to observations owned by that API instance. They do not request data. Use the returned subscription's callbacks when multiple filtered views need separate updates. ## Follow timeline events ```ts const unsubscribe = agent.timeline.subscribe((update) => { const { event } = update; + if (event.type === "subscription_restored") { + // Live delivery has resumed. Choose whether to fetch missed history. + console.log("Reconnected; events may have been missed"); + return; + } + if (event.type === "error") { + console.error("Timeline observation stopped:", event.error); + return; + } if (event.type === "replacement") { // Previously fetched history belongs to an old epoch. Fetch the page your UI needs. void agent.timeline.refetch().then((page) => console.log(page.entries)); @@ -56,7 +71,13 @@ const unsubscribe = agent.timeline.subscribe((update) => { }); ``` -Await `unsubscribe.ready` before starting work whose events you need to observe. It resolves when the daemon acknowledges the subscription and rejects if establishment fails. Call `unsubscribe()` to release it. +Await `unsubscribe.ready` before starting work whose events you need to observe. It waits for the initial live subscription (local listener attachment on broadcast-only hosts); initial history is a separate [read](#fetch-timeline-history). Call `unsubscribe()` to release demand, or await `unsubscribe.release()` for teardown. + +After reconnect, the same handle receives `{ agentId, subscriptionId, event: { type: "subscription_restored" } }` before subsequent live updates. This is a local SDK notification after membership acknowledgement (local attachment on broadcast-only hosts). The subscription gets a fresh ID. No history is fetched automatically, and missed events are not replayed. + +Choose recovery for your consumer: continue live, request a recent page, or call `timeline.refetch({ direction: "after", cursor: { epoch, seq } })` using your saved position. Live delivery continues while your read is pending; buffer or reconcile those events with the returned page by epoch and sequence. Follow `hasNewer` and `endCursor` to read further pages when needed. A live `replacement` invalidates the previous epoch. A failed explicit history read rejects that read and leaves the live subscription active. + +Subscription establishment failures deliver `{ agentId, event: { type: "error", error } }` and release the observation. Establish a new subscription when ready to retry. Releasing a subscription stops its callbacks and prevents restoration after reconnect. Assistant messages can arrive in pieces. Concatenate their text when you need a complete message, or use `run()` and read `lastMessage` when you only need the final reply. @@ -72,7 +93,7 @@ const page = await agent.timeline.refetch({ }); for (const entry of page.entries) { - console.log(entry.seq, entry.event.type); + console.log(entry.seqStart, entry.seqEnd, entry.item.type); } ``` @@ -80,19 +101,19 @@ Use `startCursor`, `endCursor`, `hasOlder`, and `hasNewer` from the result to pa ## Follow workspace updates -Workspace updates also require a directory subscription: - ```ts -await client.workspaces.list({ - subscribe: { subscriptionId: "issue-board-workspaces" }, +const directory = await client.workspaces.list({ subscribe: {} }); +directory.subscription.subscribe({ + snapshot({ entries }) { + console.log(entries); + }, + update(message) { + if (message.type === "workspace_update") console.log(message.payload); + }, }); -const workspace = client.workspaces.ref(workspaceId); -const unsubscribe = workspace.subscribe((update) => { - if (update.kind === "upsert") { - console.log(update.workspace.status); - } -}); +// When this view closes: +await directory.subscription.release(); ``` ## Follow provider catalog changes @@ -105,6 +126,19 @@ const unsubscribe = client.providers.subscribe((update) => { ready.map((entry) => entry.provider), ); }); +console.log(await client.providers.snapshot()); + +// When this view closes: +unsubscribe(); ``` -Always call the returned unsubscribe functions before discarding the owning object. Call `client.close()` when the application no longer needs the daemon connection. +Provider and project `subscribe()` calls request their own updates and release that demand on unsubscribe. For an initial project cache, buffer updates while awaiting `client.projects.list()`, then apply them after the snapshot. + +Call `client.close()` when the application no longer needs the connection. + +## Older daemons + +The same methods use the existing connection and legacy delivery behavior. Old directory subscriptions +share a server slot: a later filtered observation replaces that slot's filter. Handles have local IDs +and separate callbacks, but independent server filters require an updated daemon. Releasing a handle +detaches its callbacks; old daemons may continue sending broadcasts. diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 85e4d60b9d..620f398691 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -53,7 +53,9 @@ Create a new client after `close()`. | `list(options?)` | `PaseoAgentListResult` | Lists a page of agents. `scope`, `filter`, `sort`, `page`, and `subscribe` match the daemon directory query. | | `create(options)` | `PaseoAgentHandle` | Creates an agent and a fresh workspace for `cwd`. Requires `config`. | | `ref(agentOrId)` | `PaseoAgentHandle` | Creates a local handle without fetching. | -| `subscribe(handler)` | Unsubscribe function | Listens for connection-local agent directory updates. Call `list({ subscribe })` first. | +| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | + +`list({ subscribe: {} })` also returns a `subscriptionId` and an owned `subscription`. Its `subscribe({ snapshot, update, error? })` callbacks receive the snapshot before scoped wire updates; `release()` ends that observation. Capable daemons assign the ID and keep observations independent. Older daemons use local IDs and their established shared delivery behavior. Plain lists create no observation. The same contract applies to workspace lists. See [events](./events.md). Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, and `labels`. @@ -108,27 +110,27 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o `agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`. -`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. The handler also receives `{ agentId, event: { type: "replacement", epoch } }` when history is replaced; refetch the page you need. See [events](./events.md#follow-timeline-events). +`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events). ## `client.projects` -| Method | Result | Behavior | -| -------------------- | ------------------------ | --------------------------------------------------------------------------------------- | -| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | -| `subscribe(handler)` | Unsubscribe function | Listens only for future project upserts/removals. Pair with `list()` for initial state. | +| Method | Result | Behavior | +| -------------------- | ------------------------ | ---------------------------------------------------------------------------------------------- | +| `list(options?)` | `PaseoProjectListResult` | Lists every registered project, including projects with no active workspaces. | +| `subscribe(handler)` | Unsubscribe function | Requests future project updates; unsubscribe releases demand. `list()` supplies initial state. | -To build a complete project cache without an initialization gap, subscribe and buffer updates before awaiting `list()`. Initialize the cache from the list result, then apply buffered updates in arrival order. +See [events](./events.md#follow-provider-catalog-changes) for explicit event observation and cleanup. ## `client.workspaces` -| Method | Result | Behavior | -| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------- | -| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | -| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | -| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. | -| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | -| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | -| `subscribe(handler)` | Unsubscribe function | Listens for connection-local workspace updates. Call `list({ subscribe })` first. | +| Method | Result | Behavior | +| ------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------- | +| `list(options?)` | `PaseoWorkspaceListResult` | Lists, filters, pages, or subscribes to the workspace directory. | +| `open(cwd)` | `PaseoWorkspaceHandle` | Reuses the active workspace for a directory or creates one. | +| `create(options)` | `PaseoWorkspaceHandle` | Always creates a fresh directory-backed or Paseo-worktree workspace. | +| `ref(workspaceOrId)` | `PaseoWorkspaceHandle` | Creates a local handle. | +| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | +| `subscribe(handler)` | Unsubscribe function | Local listener for this API instance. Requires an owned `list({ subscribe: {} })` observation. | A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. @@ -185,7 +187,7 @@ Use `workspace.terminals.create(options?)` and `workspace.terminals.list(options | `listFeatures(draftConfig)` | Features result | Discovers features for the current draft provider configuration. | | `diagnostic(provider)` | Diagnostic result | Returns human-readable setup diagnostics. | | `listUsage(options?)` | `PaseoProviderUsageResult` | Returns normalized subscription windows, balances, and provider details. Rejects with an update-host error when unsupported. Options: `requestId`. | -| `subscribe(handler)` | Unsubscribe function | Listens for catalog updates. | +| `subscribe(handler)` | Unsubscribe function | Requests future catalog updates; unsubscribe releases demand. | ## `client.config` diff --git a/public-docs/web-ui.md b/public-docs/web-ui.md index fd2628ed9f..9e80e8355e 100644 --- a/public-docs/web-ui.md +++ b/public-docs/web-ui.md @@ -20,16 +20,17 @@ The web app ships inside the daemon package, so the UI you serve always matches ## Enable it -The bundled web UI is off by default. Turn it on when you start the daemon: +The bundled web UI is off by default. Save the setting, then start the daemon: ```bash -paseo daemon start --web-ui +paseo daemon config set features.webUi.enabled true +paseo daemon start ``` -Or with an environment variable: +Or run a foreground deployment with an environment variable: ```bash -PASEO_WEB_UI_ENABLED=true paseo daemon start +PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Or persist it in `config.json` so it survives restarts: @@ -73,7 +74,8 @@ The rest of this page builds from local to public. **Verify a direct connection By default the daemon listens on `127.0.0.1:6767`, reachable only from the same machine. To reach it from other devices, bind it to a network interface: ```bash -paseo daemon start --web-ui --listen 0.0.0.0:6767 +paseo daemon config set daemon.listen 0.0.0.0:6767 +paseo daemon start ``` > **Anyone who can reach the listening address can use your agents.** Before you bind beyond localhost, set a password and review your host allowlist. The relay pairing path avoids this entirely by keeping the daemon bound to localhost, see [Security](/docs/security). @@ -83,7 +85,7 @@ Two things to configure when you expose the daemon directly: 1. **Set a password** so only authorized clients can connect: ```bash - PASEO_PASSWORD=my-secret paseo daemon start --web-ui --listen 0.0.0.0:6767 + paseo daemon set-password ``` See [password authentication](/docs/configuration#password-authentication) for the persistent setup. Password auth controls access; it does not encrypt traffic, put TLS in front of it (below) on any untrusted network. @@ -91,7 +93,7 @@ Two things to configure when you expose the daemon directly: 2. **Allow your hostname** so the daemon's DNS-rebinding protection accepts requests for your domain: ```bash - paseo daemon start --web-ui --listen 0.0.0.0:6767 --hostnames ".example.com" + paseo daemon config set daemon.hostnames '[".example.com"]' ``` See [DNS rebinding protection](/docs/security#dns-rebinding-protection) for how the host allowlist works. @@ -179,7 +181,7 @@ If your proxy reaches the daemon from another address, as in some Docker, LAN, o `PASEO_TRUSTED_PROXIES` accepts the same comma-separated values: ```bash -PASEO_TRUSTED_PROXIES=loopback,172.16.0.0/12 paseo daemon start --web-ui +PASEO_TRUSTED_PROXIES=loopback,172.16.0.0/12 PASEO_WEB_UI_ENABLED=true paseo daemon run ``` Only use `trustedProxies: true` when your final trusted proxy overwrites client-supplied `X-Forwarded-*` headers. Otherwise a client could spoof forwarded header values. diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 997cb5c7cb..2dc78fa49c 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -281,11 +281,24 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => { ]); }); -test("non-required Docker and Nix workflows avoid runners with workflow path filters", () => { +test("packaging runs on main without allocating pull-request runners", () => { for (const workflowPath of [dockerWorkflowPath, nixWorkflowPath]) { const source = readFileSync(workflowPath, "utf8"); const trigger = source.split("jobs:", 1)[0]; - assert.match(trigger, /^\s+paths:\s*$/m); + assert.match(trigger, /push:\s*\n\s+branches: \[main\]/); + assert.doesNotMatch(trigger, /pull_request/); assert.doesNotMatch(source, /dorny\/paths-filter/); } }); + +test("desktop packaging smokes main pushes and only the pull requests that touch packaging", () => { + const source = readFileSync(new URL(".github/workflows/desktop-packages.yml", repoRoot), "utf8"); + const trigger = source.split("jobs:", 1)[0]; + assert.match(trigger, /push:\s*\n\s+branches: \[main\]/); + assert.match(trigger, /pull_request:\s*\n\s+branches: \[main\]\s*\n\s+paths:/); + assert.match(trigger, /- "packages\/desktop\/\*\*"/); + assert.doesNotMatch(source, /dorny\/paths-filter/); + for (const action of ["actions/checkout", "actions/setup-node", "actions/upload-artifact"]) { + assert.match(source, new RegExp(`${action}@[0-9a-f]{40} # v\\d+\\.\\d+\\.\\d+`)); + } +}); diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 280db160fe..60ca70057a 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -23,6 +23,8 @@ For a local daemon, `project create` defaults to the current directory and resol **`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`. +Choose `baseBranch` explicitly: `origin/main` selects the remote-tracking branch; `refs/heads/main` selects local main. Bare `main` prefers local main when it exists, otherwise origin/main. Paseo retains the resolved ref for workspace comparisons, even after rebasing the branch or changing its PR target. + **`list_workspaces`** — list active workspaces. **`archive_workspace`** — `{ workspaceId }`. Archives the workspace, its agents, and its terminals. Local directories remain; Paseo removes an owned worktree only after its final active workspace reference is archived. @@ -113,11 +115,11 @@ Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. Th The CLI and tools use the same ownership semantics even where their syntax differs: ```bash -paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x --base main +paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x --base origin/main paseo workspace create --isolation worktree --mode checkout-branch --branch existing-work paseo workspace create --isolation worktree --mode checkout-pr --pr-number 42 paseo run --provider codex/gpt-5.4 --mode full-access --workspace "" -paseo run --provider codex/gpt-5.4 --mode full-access --new-workspace worktree --worktree-mode branch-off --new-branch fix-x --base main "" +paseo run --provider codex/gpt-5.4 --mode full-access --new-workspace worktree --worktree-mode branch-off --new-branch fix-x --base origin/main "" paseo send "" paseo ls paseo schedule create --cron "*/15 * * * *" "ping main build"