diff --git a/.claude/skills/store-screenshots/SKILL.md b/.claude/skills/store-screenshots/SKILL.md new file mode 100644 index 0000000000..f0c319dd05 --- /dev/null +++ b/.claude/skills/store-screenshots/SKILL.md @@ -0,0 +1,19 @@ +--- +name: store-screenshots +description: Refresh the App Store and Google Play screenshots for phones and tablets. Use when the user says "store screenshots", "app store screenshots", "play store screenshots", "iPad screenshots", "recapture the screenshots", or wants the store panels changed. +user-invocable: true +--- + +# Store screenshots + +Follow `assets/store-screenshots/README.md`. It owns the panels, the creative direction, the render command and the recapture steps. Change copy, crops and layout in `assets/store-screenshots/render.mjs`, not in the exports. + +Working rules: + +- Capture at native density: `--pixel-density 3` on the iPhone 16 Pro Max, `--pixel-density 2` on the iPad Pro 13-inch. The default is 1x, which is too soft for the store. +- Every headline wraps to two lines and every subline fits on one. The renderer enforces this; rewrite the copy rather than shrink the type. +- Use SF Pro. Never substitute another font. +- Open `output/preview.png` and a full-size panel from each store before you report a render. Show the user the preview. +- Do not upload screenshots to a store, a PR, or anywhere public, and do not commit, unless the user asks. +- Stage the demo host on its own port and pick a free Metro port. Do not restart the main daemons. +- When you learn something the next capture needs, fix the README rather than this skill. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e632cb4b96..285ff8e072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: [main] - # Every base branch, so stacked PRs and PRs into `next` get the same checks. + # Every base branch, so stacked PRs and PRs into any base get the same checks. pull_request: merge_group: workflow_dispatch: @@ -133,11 +133,12 @@ jobs: if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} run: npm run typecheck:examples --workspace=@getpaseo/client + # The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04. typecheck: name: typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -280,10 +281,10 @@ jobs: # otherwise has no server-test coverage. Scoped to the observer/watcher suites to # bound runtime. server-tests-macos: - name: server-tests (macos-14, file observation) + name: server-tests (macos-latest, file observation) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} - runs-on: macos-14 + runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -395,7 +396,7 @@ jobs: name: app-tests needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -417,19 +418,11 @@ jobs: - name: Run app unit tests run: npm run test --workspace=@getpaseo/app - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - - - name: Run Android module tests - run: npm run test:android --workspace=@getpaseo/app - playwright-1: name: playwright (shard 1/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "1/8" PLAYWRIGHT_ARTIFACT: "1" @@ -485,7 +478,7 @@ jobs: name: playwright (shard 2/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "2/8" PLAYWRIGHT_ARTIFACT: "2" @@ -495,7 +488,7 @@ jobs: name: playwright (shard 3/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "3/8" PLAYWRIGHT_ARTIFACT: "3" @@ -505,7 +498,7 @@ jobs: name: playwright (shard 4/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "4/8" PLAYWRIGHT_ARTIFACT: "4" @@ -515,7 +508,7 @@ jobs: name: playwright (shard 5/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "5/8" PLAYWRIGHT_ARTIFACT: "5" @@ -525,7 +518,7 @@ jobs: name: playwright (shard 6/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "6/8" PLAYWRIGHT_ARTIFACT: "6" @@ -535,7 +528,7 @@ jobs: name: playwright (shard 7/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "7/8" PLAYWRIGHT_ARTIFACT: "7" @@ -545,7 +538,7 @@ jobs: name: playwright (shard 8/8) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: PLAYWRIGHT_SHARD: "8/8" PLAYWRIGHT_ARTIFACT: "8" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f6b07c8b37..26be12291b 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -100,10 +100,10 @@ jobs: fail-fast: false matrix: include: - - runner: macos-14 + - runner: macos-latest electron_arch: arm64 node_heap_mb: 4096 - - runner: macos-15-intel + - runner: macos-26-intel electron_arch: x64 node_heap_mb: 8192 permissions: @@ -169,6 +169,34 @@ jobs: NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} run: | set -euo pipefail + export PASEO_REAL_XCRUN="$(command -v xcrun)" + stapler_bin="$(mktemp -d)" + trap 'rm -rf "$stapler_bin"' EXIT + cat > "$stapler_bin/xcrun" <<'STAPLER' + #!/bin/bash + if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then + exec "$PASEO_REAL_XCRUN" "$@" + fi + output="$(mktemp)" + trap 'rm -f "$output"' EXIT + for attempt in 1 2 3 4; do + if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then + cat "$output" + exit 0 + else + status=$? + fi + cat "$output" + if [[ "$status" != 68 || "$attempt" == 4 ]] || + ! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then + exit "$status" + fi + echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2 + sleep 30 + done + STAPLER + chmod +x "$stapler_bin/xcrun" + export PATH="$stapler_bin:$PATH" build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.channel=$RELEASE_CHANNEL") diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index f37a8b0cdf..b653564793 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -101,7 +101,7 @@ jobs: run: nix build .#desktop -o result-desktop build-desktop-darwin: - runs-on: macos-14 + runs-on: macos-latest steps: - uses: actions/checkout@v4 diff --git a/.oxlintrc.json b/.oxlintrc.json index f5078649c9..c82b517844 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -85,6 +85,176 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + // Exceptions name individual APIs in individual modules; new APIs still fail in these files. + // TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions. + { + "files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], + "rules": { + "paseo/no-sync-io": [ + "error", + { + "allow": { + // TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together. + "executable-resolution/executable-resolution.ts": ["existsSync"], + // TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together. + "executable-resolution/windows.ts": ["readdirSync"], + // TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion. + "server/agent/providers/claude/agent.ts": ["existsSync"], + // TODO: new-session construction still resolves its project directory synchronously; move preparation before construction. + "server/agent/providers/claude/project-dir.ts": ["realpathSync"], + // TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion. + "server/agent/providers/claude/subagents/workflow-output.ts": [ + "closeSync", + "openSync", + "readSync" + ], + // TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness. + "server/agent/providers/omp/agent.ts": ["existsSync"], + // TODO: synchronous provider configuration discovery; move discovery into async launch preparation. + "server/agent/providers/omp/provider-config.ts": ["existsSync"], + // TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness. + "server/agent/providers/omp/session-descriptor.ts": ["existsSync"], + // TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await. + "server/agent/providers/opencode/server-manager.ts": ["mkdirSync"], + // TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together. + "server/agent/providers/pi/agent.ts": [ + "existsSync", + "mkdtempSync", + "readFileSync", + "rmSync", + "writeFileSync" + ], + // Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there. + "server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"], + // Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal. + "server/bootstrap.ts": ["existsSync", "unlinkSync"], + // Startup configuration resolution: detect the bundled app distribution before the daemon listens. + "server/config.ts": ["existsSync"], + // Startup identity: load or create the daemon keypair before accepting connections. + "server/daemon-keypair.ts": ["existsSync", "readFileSync"], + // Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate. + "server/daemon-worker.ts": ["appendFileSync", "mkdirSync"], + // Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache. + "server/host-name.ts": ["execFileSync"], + // TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes. + "server/hub/relationship-controller.ts": [ + "existsSync", + "readFileSync", + "renameSync", + "rmSync" + ], + // Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection. + "server/local-credential.ts": ["readFileSync"], + // Startup logger construction: create the log directory before opening the logging destination. + "server/logger.ts": ["mkdirSync"], + // TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together. + "server/orchestration-skills/internal/paths.ts": ["existsSync"], + // Module initialization: the package version is resolved once and cached at import. + "server/package-version.ts": ["existsSync", "readFileSync"], + // Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities. + "server/persisted-config.ts": ["existsSync", "readFileSync"], + // Startup pid identity: read and memoize the Linux boot id for the daemon lock. + "server/pid-lock.ts": ["readFileSync"], + // Startup builtin loader construction: select the bundled plugin root before loading plugins. + "server/plugins/builtin/index.ts": ["existsSync"], + // Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process. + "server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"], + // Build child process: select the esbuild binary for the isolated plugin compiler. + "server/plugins/compiler-esbuild.ts": ["existsSync"], + // Build child process: TypeScript resolution and source reads run in the isolated plugin compiler. + "server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"], + // TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue. + "server/plugins/managed-source.ts": ["existsSync", "readFileSync"], + // TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports. + "server/private-files.ts": [ + "chmodSync", + "mkdirSync", + "realpathSync", + "renameSync", + "rmSync", + "writeFileSync" + ], + // TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness. + "server/push/token-store.ts": ["existsSync", "readFileSync"], + // Startup identity: load or create the server id before accepting connections. + "server/server-id.ts": ["existsSync", "readFileSync"], + // Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy. + "server/service-proxy.ts": ["existsSync", "unlinkSync"], + // TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together. + "server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"], + // TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection. + "server/session/daemon/npm-global-cli.ts": ["lstatSync"], + // Speech child process: model-file validation runs in the worker-backed local speech provider. + "server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"], + // Speech child process: native addon discovery and patchelf run outside the daemon. + "server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [ + "existsSync", + "spawnSync" + ], + // Speech child process: model-file validation runs in the worker-backed local speech provider. + "server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"], + // TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion. + "server/web-ui.ts": ["readFileSync", "statSync"], + // Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts. + "server/workspace-registry-bootstrap.ts": ["statSync"], + // Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker. + "terminal/agent-hooks/agent-hook-installer.ts": [ + "existsSync", + "readFileSync", + "rmSync" + ], + // Terminal child process: launcher readiness and PTY record ownership run outside the daemon. + "terminal/terminal-worker-process.ts": [ + "chmodSync", + "closeSync", + "existsSync", + "openSync", + "rmSync", + "writeSync" + ], + // Worker startup only: choose and secure the local listening socket before accepting a controller. + "terminal/terminal-worker-socket-path.ts": ["lstatSync", "mkdirSync"], + // Startup only: build an independent worker installation before launching; Electron ASAR sources require plain fs reads. + "terminal/terminal-worker-runtime.ts": [ + "chmodSync", + "existsSync", + "mkdirSync", + "readdirSync", + "readFileSync", + "renameSync", + "rmSync", + "statSync", + "writeFileSync" + ], + // TODO: CLI shim lookup moved out of terminal.ts; migrate the executable-resolution boundary together. + "terminal/paseo-cli-path.ts": ["existsSync"], + // Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop. + "terminal/terminal-ts-loader.mjs": ["existsSync"], + // Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager. + "terminal/terminal.ts": [ + "accessSync", + "chmodSync", + "existsSync", + "mkdirSync", + "readFileSync", + "statSync" + ], + // TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them. + "utils/path.ts": ["realpathSync"] + } + } + ] + } + }, + { + "files": [ + "packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}", + "packages/server/src/**/test-utils/**", + "packages/server/src/server/daemon-e2e/**", + "packages/server/src/server/agent/providers/pi/extensions/*-test.ts" + ], + "rules": { "paseo/no-sync-io": "off" } + }, { "files": [ "plugins/*-provider/server/**/*.ts", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d48d9c289..6ee9a07b36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,160 @@ # Changelog +## 0.15.0-beta.1 - 2026-11-01 + +Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations. + +Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history. + +Worktree setup can copy ignored files and use plugin-defined locations before agents start. + +### Added + +- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371)) +- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger)) +- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena)) +- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc)) +- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348)) +- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim)) +- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso)) +- Added daemon-wide support for `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177)) +- Fetch usage only while displayed, with a host setting to disable usage checks entirely ([#8181](https://github.com/getpaseo/paseo/pull/8181)) + +### Improved + +- Replaced mobile terminal text rendering to align CJK characters, emoji, styled text and the cursor with terminal columns ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375)) +- Restore the configured terminal scrollback on reconnect, up to 10,000 retained lines ([#8282](https://github.com/getpaseo/paseo/pull/8282) by [@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch)) +- Load paginated Codex histories when importing or reopening sessions ([#8171](https://github.com/getpaseo/paseo/pull/8171) by [@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage)) +- Stopped rereading and retaining an unused copy of Codex history when reopening a cached chat ([#8132](https://github.com/getpaseo/paseo/pull/8132)) +- Split large daemon responses across relay frames instead of disconnecting at the frame limit ([#8302](https://github.com/getpaseo/paseo/pull/8302)) +- Truncate oversized chat entries before sending them to the app, including large tool results and embedded data ([#8274](https://github.com/getpaseo/paseo/pull/8274)) +- Show subagent activity in its own panel instead of duplicating it inside the parent’s tool card ([#8276](https://github.com/getpaseo/paseo/pull/8276)) +- Show exact usage reset times alongside more precise countdowns ([#8162](https://github.com/getpaseo/paseo/pull/8162)) + +### Plugins + +- Fixed discarded messages returning after rewinding plugin conversations ([#8368](https://github.com/getpaseo/paseo/pull/8368) by [@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet)) +- Reduced plugin downloads with smaller client bundles and a cache reused across reconnects and app launches ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- Preserved plugin screens and local state during temporary host disconnections ([#8105](https://github.com/getpaseo/paseo/pull/8105) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- Let plugins choose worktree locations and creation options through `worktree.create.before` ([#8117](https://github.com/getpaseo/paseo/pull/8117) by [@mcowger](https://github.com/mcowger)) +- Added `server.addHook` with an awaited `worktree.create.after` hook before workspace setup and agents start ([#8081](https://github.com/getpaseo/paseo/pull/8081)) +- Released redundant plugin streaming snapshots instead of retaining them for the session’s lifetime ([#7675](https://github.com/getpaseo/paseo/pull/7675) by [@suntianc](https://github.com/suntianc)) + +### Fixed + +- Fixed rewinding Claude conversations to messages sent during an active turn ([#8372](https://github.com/getpaseo/paseo/pull/8372) by [@tieo](https://github.com/tieo)) +- Fixed duplicate rows and missing replies when resuming Claude subagents after reopening a session ([#8408](https://github.com/getpaseo/paseo/pull/8408) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- Fixed Codex agents created without a prompt failing after their runtime reopens ([#8393](https://github.com/getpaseo/paseo/pull/8393) by [@babo-jeonguk](https://github.com/babo-jeonguk)) +- Fixed newly running agents appearing as finished immediately after creation ([#8396](https://github.com/getpaseo/paseo/pull/8396) by [@luoph](https://github.com/luoph)) +- Fixed ACP startup exits waiting for a timeout instead of reporting the process error ([#8384](https://github.com/getpaseo/paseo/pull/8384) by [@Peanut-HZM](https://github.com/Peanut-HZM)) +- Fixed skills missing from new ACP agents when commands arrive in multiple batches ([#8387](https://github.com/getpaseo/paseo/pull/8387) by [@Peanut-HZM](https://github.com/Peanut-HZM)) +- Fixed sidebar titles and statuses stopping updates after directory subscriptions are released ([#8403](https://github.com/getpaseo/paseo/pull/8403) by [@ragokan](https://github.com/ragokan)) +- Kept the open workspace visible in collapsed sidebar groups ([#8395](https://github.com/getpaseo/paseo/pull/8395) by [@0xroylee](https://github.com/0xroylee)) +- Fixed relative chat links opening missing files instead of files in nested repositories ([#8361](https://github.com/getpaseo/paseo/pull/8361) by [@apple-ouyang](https://github.com/apple-ouyang)) +- Accepted host:port and pasted URLs in the Direct connection Host field ([#8223](https://github.com/getpaseo/paseo/pull/8223) by [@DaydreamCoding](https://github.com/DaydreamCoding)) +- Fixed terminal Ctrl+V paste over plain HTTP on non-Mac keyboards ([#8230](https://github.com/getpaseo/paseo/pull/8230) by [@DaydreamCoding](https://github.com/DaydreamCoding)) +- Stopped failed history loads from repeatedly reopening the provider, with Retry available in the chat ([#8278](https://github.com/getpaseo/paseo/pull/8278)) +- Fixed Claude settings files being discarded when Paseo adds its own launch settings ([#8148](https://github.com/getpaseo/paseo/pull/8148) by [@yayugu](https://github.com/yayugu)) +- Fixed steering messages replacing a turn that was still starting and losing its original prompt ([#6192](https://github.com/getpaseo/paseo/pull/6192)) +- Fixed Pi tool-result images missing during streaming and after reopening the chat ([#8280](https://github.com/getpaseo/paseo/pull/8280) by [@duyiliu](https://github.com/duyiliu)) +- Fixed completed `edxeth/pi-subagents` children remaining marked as working ([#8176](https://github.com/getpaseo/paseo/pull/8176) by [@Zeno2019](https://github.com/Zeno2019)) +- Fixed Claude background subagent status after reopening a conversation ([#6259](https://github.com/getpaseo/paseo/pull/6259)) +- Fixed reused plugin subagents showing their previous completed status during new work ([#8080](https://github.com/getpaseo/paseo/pull/8080) by [@bccy](https://github.com/bccy)) +- Fixed custom Codex providers with reserved names such as `ollama` ([#6954](https://github.com/getpaseo/paseo/pull/6954) by [@mr860819](https://github.com/mr860819)) +- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM)) +- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285)) +- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377)) +- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer)) +- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin)) +- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk)) +- Fixed split dividers drifting ahead of or behind the pointer while dragging ([#6011](https://github.com/getpaseo/paseo/pull/6011)) +- Fixed delayed native filesystem watcher startup leaving Git updates unobserved ([#8107](https://github.com/getpaseo/paseo/pull/8107)) +- Fixed bundled skill arguments being passed incorrectly to Codex and Pi ([#8252](https://github.com/getpaseo/paseo/pull/8252) by [@AspireOne](https://github.com/AspireOne)) + +### Notes + +- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them +- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents +- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings + +## 0.14.0 - 2026-10-29 + +Plugins can now add tools to individual agents and configure their MCP servers when sessions open. + +Completed conversation turns can be collapsed into summaries and expanded to read the full exchange. + +Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in. + +### Added + +- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938)) +- Added Chat display settings that group tool calls or collapse completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav)) +- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983)) +- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295)) +- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte)) +- Added sent-message recall with Up and Down in an empty web or desktop Composer ([#7710](https://github.com/getpaseo/paseo/pull/7710)) +- Added long-press Send to queue a message or send it immediately ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi)) +- Added project-specific editor choices, including Xcode on macOS ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy)) + +### Improved + +- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782)) +- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784)) +- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093)) +- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) +- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart)) +- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928)) +- Stopped hidden chats from rendering incoming text updates ([#7901](https://github.com/getpaseo/paseo/pull/7901)) +- Reduced stalls when highlighting and wrapping large diffs ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945)) +- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan)) +- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959)) +- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944)) + +### Fixed + +- Fixed terminal glyph corruption when shared WebGL texture atlases change ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan)) +- Fixed restored workspaces with no open chat staying on Loading workspace at launch on phones ([#7918](https://github.com/getpaseo/paseo/pull/7918)) +- Fixed restored agent tabs appearing in unrelated workspaces ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds)) +- Fixed background Git fetches opening credential prompts ([#8019](https://github.com/getpaseo/paseo/pull/8019)) +- Fixed failed SSH tunnels crashing callers with an unhandled EPIPE ([#7876](https://github.com/getpaseo/paseo/pull/7876)) +- Fixed brief Windows file locks preventing atomic saves ([#7955](https://github.com/getpaseo/paseo/pull/7955)) +- Fixed large ACP terminal output blocking daemon requests ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr)) +- Fixed messages sent during Pi manual compaction failing instead of waiting ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon)) +- Fixed late tool updates creating duplicate rows during overlapping turns ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph)) +- Fixed plugin ACP reasoning appearing inside assistant replies when message IDs match ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder)) +- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski)) +- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910)) +- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku)) +- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088)) +- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao)) +- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916)) +- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1)) +- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842)) +- Fixed model discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood)) +- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan)) + +### Notes + +- Voice mode now defaults to off; enable it in Voice settings +- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings +- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0 +- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss)) + +## 0.13.2 - 2026-10-26 + +### Fixed + +- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907)) +- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914)) + +## 0.13.1 - 2026-10-26 + +### Fixed + +- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt)) + ## 0.13.0 - 2026-10-24 Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 09080be3bc..532ed24887 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,4 +1,159 @@ -# 更新日志 +# Changelog + +## 0.15.0-beta.1 - 2026-11-01 + +daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。 + +移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。 + +worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。 + +### 新增 + +- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371)) +- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger)) +- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena)) +- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc)) +- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348)) +- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim)) +- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso)) +- 让 daemon 的网络请求支持 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177)) +- 仅在显示用量时查询用量,并提供主机设置以完全关闭用量查询 ([#8181](https://github.com/getpaseo/paseo/pull/8181)) + +### 改进 + +- 更换移动端终端文本渲染方式,让中日韩字符、表情符号、带样式文本和光标对齐终端列 ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375)) +- 重新连接时恢复设置指定的终端回滚历史,最多保留 10,000 行 ([#8282](https://github.com/getpaseo/paseo/pull/8282),贡献者:[@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch)) +- 导入或重新打开 Codex 会话时加载分页历史 ([#8171](https://github.com/getpaseo/paseo/pull/8171),贡献者:[@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage)) +- 重新打开已有缓存的 Codex 聊天时,不再重复读取并保留未使用的历史副本 ([#8132](https://github.com/getpaseo/paseo/pull/8132)) +- 将 daemon 的大型响应拆分为多个 relay 帧,避免达到单帧限制时断开连接 ([#8302](https://github.com/getpaseo/paseo/pull/8302)) +- 向应用发送聊天条目前截断超大内容,包括大型工具结果和内嵌数据 ([#8274](https://github.com/getpaseo/paseo/pull/8274)) +- 在子 Agent 自己的面板中显示活动,不再在父 Agent 的工具卡片中重复显示 ([#8276](https://github.com/getpaseo/paseo/pull/8276)) +- 在更精确的倒计时旁显示用量限制的具体重置时间 ([#8162](https://github.com/getpaseo/paseo/pull/8162)) + +### 插件 + +- 修复了插件会话回退后已丢弃的消息重新出现的问题 ([#8368](https://github.com/getpaseo/paseo/pull/8368),贡献者:[@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet)) +- 通过缩小客户端代码包并在重连和应用启动时复用缓存,减少插件下载量 ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- 在主机暂时断开连接期间保留插件页面和本地状态 ([#8105](https://github.com/getpaseo/paseo/pull/8105),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- 让插件通过 `worktree.create.before` 指定 worktree 的位置和创建选项 ([#8117](https://github.com/getpaseo/paseo/pull/8117),贡献者:[@mcowger](https://github.com/mcowger)) +- 新增 `server.addHook`,并在工作区初始化和 Agent 启动前等待 `worktree.create.after` hook 完成 ([#8081](https://github.com/getpaseo/paseo/pull/8081)) +- 释放多余的插件流式快照,不再将其保留到会话结束 ([#7675](https://github.com/getpaseo/paseo/pull/7675),贡献者:[@suntianc](https://github.com/suntianc)) + +### 修复 + +- 修复了无法将 Claude 会话回退到运行过程中发送的消息的问题 ([#8372](https://github.com/getpaseo/paseo/pull/8372),贡献者:[@tieo](https://github.com/tieo)) +- 修复了重新打开会话后恢复 Claude 子 Agent 时出现重复行和回复缺失的问题 ([#8408](https://github.com/getpaseo/paseo/pull/8408),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev)) +- 修复了未发送提示词的 Codex Agent 在运行时重新打开后无法使用的问题 ([#8393](https://github.com/getpaseo/paseo/pull/8393),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk)) +- 修复了刚开始运行的 Agent 在创建后立即显示为已完成的问题 ([#8396](https://github.com/getpaseo/paseo/pull/8396),贡献者:[@luoph](https://github.com/luoph)) +- 修复了 ACP 启动进程退出后仍等待超时而未及时报告错误的问题 ([#8384](https://github.com/getpaseo/paseo/pull/8384),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM)) +- 修复了命令分批到达时新 ACP Agent 的技能列表不完整的问题 ([#8387](https://github.com/getpaseo/paseo/pull/8387),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM)) +- 修复了目录订阅释放后侧栏标题和状态停止更新的问题 ([#8403](https://github.com/getpaseo/paseo/pull/8403),贡献者:[@ragokan](https://github.com/ragokan)) +- 在折叠的侧栏分组中保持当前工作区可见 ([#8395](https://github.com/getpaseo/paseo/pull/8395),贡献者:[@0xroylee](https://github.com/0xroylee)) +- 修复了聊天中的相对链接未打开嵌套仓库中的文件而指向不存在文件的问题 ([#8361](https://github.com/getpaseo/paseo/pull/8361),贡献者:[@apple-ouyang](https://github.com/apple-ouyang)) +- 在直接连接的主机字段中支持 host:port 和粘贴的 URL ([#8223](https://github.com/getpaseo/paseo/pull/8223),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding)) +- 修复了非 Mac 键盘通过普通 HTTP 访问网页终端时 Ctrl+V 无法粘贴的问题 ([#8230](https://github.com/getpaseo/paseo/pull/8230),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding)) +- 停止在历史加载失败后反复重新打开 Provider,可在聊天中手动重试 ([#8278](https://github.com/getpaseo/paseo/pull/8278)) +- 修复了 Paseo 添加自身启动设置时丢弃 Claude 设置文件的问题 ([#8148](https://github.com/getpaseo/paseo/pull/8148),贡献者:[@yayugu](https://github.com/yayugu)) +- 修复了引导消息替换尚在启动的轮次并丢失原始提示词的问题 ([#6192](https://github.com/getpaseo/paseo/pull/6192)) +- 修复了 Pi 工具结果中的图片在流式输出和重新打开聊天后缺失的问题 ([#8280](https://github.com/getpaseo/paseo/pull/8280),贡献者:[@duyiliu](https://github.com/duyiliu)) +- 修复了 `edxeth/pi-subagents` 子 Agent 完成后仍显示为工作中的问题 ([#8176](https://github.com/getpaseo/paseo/pull/8176),贡献者:[@Zeno2019](https://github.com/Zeno2019)) +- 修复了重新打开会话后的 Claude 后台子 Agent 状态 ([#6259](https://github.com/getpaseo/paseo/pull/6259)) +- 修复了复用的插件子 Agent 开始新任务时仍显示上次已完成状态的问题 ([#8080](https://github.com/getpaseo/paseo/pull/8080),贡献者:[@bccy](https://github.com/bccy)) +- 修复了使用 `ollama` 等保留名称的自定义 Codex Provider ([#6954](https://github.com/getpaseo/paseo/pull/6954),贡献者:[@mr860819](https://github.com/mr860819)) +- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM)) +- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285)) +- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377)) +- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer)) +- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin)) +- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk)) +- 修复了拖动分隔线时,分隔线偏离指针的问题 ([#6011](https://github.com/getpaseo/paseo/pull/6011)) +- 修复了原生文件监听器启动延迟导致 Git 更新未被监听的问题 ([#8107](https://github.com/getpaseo/paseo/pull/8107)) +- 修复了内置 skill 参数未正确传递给 Codex 和 Pi 的问题 ([#8252](https://github.com/getpaseo/paseo/pull/8252),贡献者:[@AspireOne](https://github.com/AspireOne)) + +### 说明 + +- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端 +- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能 +- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置 + +## 0.14.0 - 2026-10-29 + +插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。 + +已完成的对话轮次可以折叠为摘要,也可以展开查看完整对话。 + +语音设置现在支持配置语音服务商并一键下载本地模型,包括可用于中文听写的 SenseVoice 和 Whisper。语音模式现在需要主动开启。 + +### 新增 + +- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938)) +- 新增“聊天显示”设置,可将工具调用分组或折叠已完成的对话轮次 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav)) +- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983)) +- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295)) +- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte)) +- 新增在网页和桌面端的空 Composer 中按上下键回顾已发送消息 ([#7710](https://github.com/getpaseo/paseo/pull/7710)) +- 新增长按发送按钮排队发送或立即发送消息 ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi)) +- 新增按项目记忆编辑器的功能,macOS 支持 Xcode ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy)) + +### 改进 + +- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782)) +- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784)) +- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093)) +- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) +- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart)) +- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928)) +- 隐藏的聊天不再渲染收到的文本更新 ([#7901](https://github.com/getpaseo/paseo/pull/7901)) +- 减少大型 diff 高亮和自动换行时的卡顿 ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945)) +- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan)) +- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959)) +- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944)) + +### 修复 + +- 修复共享 WebGL 纹理图集变化时终端字符显示错误的问题 ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan)) +- 修复手机启动时没有打开聊天的已恢复工作区停留在加载状态的问题 ([#7918](https://github.com/getpaseo/paseo/pull/7918)) +- 修复已恢复的 Agent 标签出现在无关工作区中的问题 ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds)) +- 修复后台 Git 拉取弹出凭据提示的问题 ([#8019](https://github.com/getpaseo/paseo/pull/8019)) +- 修复 SSH 隧道失败时未处理的 EPIPE 导致调用方崩溃的问题 ([#7876](https://github.com/getpaseo/paseo/pull/7876)) +- 修复 Windows 短暂文件锁导致原子保存失败的问题 ([#7955](https://github.com/getpaseo/paseo/pull/7955)) +- 修复 ACP 终端产生大量输出时阻塞 daemon 的问题 ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr)) +- 修复 Pi 手动压缩上下文期间发送消息直接失败而不等待的问题 ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon)) +- 修复轮次重叠时较晚的工具更新产生重复行的问题 ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph)) +- 修复插件 ACP 推理和回复的消息 ID 相同时,推理内容混入回复的问题 ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder)) +- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski)) +- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910)) +- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku)) +- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088)) +- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao)) +- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916)) +- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1)) +- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842)) +- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood)) +- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan)) + +### 说明 + +- 语音模式默认关闭,可在语音设置中启用 +- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置 +- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置 +- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss)) + +## 0.13.2 - 2026-10-26 + +### 修复 + +- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907)) +- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) + +## 0.13.1 - 2026-10-26 + +### 修复 + +- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献) ## 0.13.0 - 2026-10-24 diff --git a/CLAUDE.md b/CLAUDE.md index 3f1731597c..71682af7e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries | | [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | +| [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | | [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints | | [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations | @@ -66,6 +67,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata | | [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security | | [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist | +| [docs/terminal-worker.md](docs/terminal-worker.md) | Terminal worker process model, socket/record/secret, protocol-bump rule and draining, the worker's file copy, Windows desktop | | [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider | | [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | | [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority | @@ -116,10 +118,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir ## Release branches -When the user says "this goes to next", create or -retarget the PR to `next` and preserve that destination through delivery. Follow -[release branch discipline](docs/release.md#release-branch-discipline) for creating -and updating `next`, integrating it after a release, and releasing a hotfix from a tag. +Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each +release builds in a short-lived release workspace from `origin/main` or a release tag; see +[release base and source](docs/release.md#release-base-and-source). ## Critical rules diff --git a/assets/store-screenshots/.gitignore b/assets/store-screenshots/.gitignore new file mode 100644 index 0000000000..22908c2c7d --- /dev/null +++ b/assets/store-screenshots/.gitignore @@ -0,0 +1 @@ +output/preview.png diff --git a/assets/store-screenshots/README.md b/assets/store-screenshots/README.md new file mode 100644 index 0000000000..fc37d98bd8 --- /dev/null +++ b/assets/store-screenshots/README.md @@ -0,0 +1,102 @@ +# Store screenshots + +Five panels for the App Store and Google Play, in phone and tablet sets, made from native captures of the real app. Both sets tell the same story with the same copy; only the capture, the device frame and the callouts change. + +| # | Headline | Capture | Phone callout | Tablet callout | +| --- | --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------- | +| 1 | Run many coding agents at once | Sidebar grouped by status: Needs input, Ready to review, Working, Done | — (agent marks in the background) | — (agent marks in the background) | +| 2 | Your full dev environment on your phone | Agent chat: reasoning, tool calls, final summary | — | — (Changes pane open beside the chat) | +| 3 | Ask an agent to change the app itself | A chat asking for a plugin, the turn collapsed, the new Deploys pill | The plugin's Preview deploys sheet | The plugin's Deploys popover | +| 4 | Review the diff before you merge | `daemon-client.ts` diff with removed and added lines | Jump to file: four changed files with counts | — (the full-width diff reads as is) | +| 5 | A real terminal in every workspace | `git status` and a vitest run in the same worktree | The passing test tree | The passing test tree | + +On tablets, panel 2 says "on your tablet" and panel 3 calls the popover a panel. A scene overrides its copy per device in `render.mjs`. + +## Creative direction + +The panels follow paseo.sh: + +- **Canvas:** the site's `#101615` background. +- **Type:** centred SF Pro headlines in sentence case, with no closing period. Every headline wraps to exactly two lines. +- **Sublines:** one plain grey sentence on one line that says what you can do. The renderer fails if a headline or subline wraps differently, because the device is placed from that fixed text height. +- **Agent marks:** panel 1 scatters faded, rotated, monochrome marks of the agents featured on the homepage behind the device. They are rendered from `packages/website/src/components/agent-icons.tsx`, so they follow the site. +- **Device:** dark-mode captures in a plain black frame; the iPhone frame has a Dynamic Island. The device starts a fixed gap below the text and runs just off the bottom edge, so a taller canvas gets a larger device instead of a larger gap. A canvas too narrow for that, like a 3:4 iPad on Google's 9:16 tablet canvas, shows the whole device at full width with the text and device centred. +- **Callouts:** a callout lifts one region of a capture out of the device, larger than the device around it, so the detail reads at store thumbnail size. On tablets, place callouts over empty screen so they do not hide the context they explain. + +Everything on screen is real app UI showing one story. The hero agent adds jitter to the reconnect backoff in this repository. Its diff, test run and terminal come from a real worktree. The other workspaces fill the sidebar's status groups. + +Panel 3 shows a real plugin. `demo/preview-deploys/` is a trusted local plugin that adds a Deploys composer pill; its popover opens as a sheet on phones and as a popover on tablets. The deploys it lists are sample data. The demo host installs it, and a scripted agent in the "Build a preview deploys plugin" workspace plays the turn that writes it. + +## Files + +- `raw/iphone/`: native 1320 × 2868 captures from an iPhone 16 Pro Max simulator. `files.png` and `plugin-sheet.png` are the sources for the Jump to file and Preview deploys callouts. +- `raw/ipad/`: native 2064 × 2752 captures from an iPad Pro 13-inch (M4) simulator. `plugin-sheet.png` is the source for the Deploys popover callout. +- `render.mjs`: the layout, copy and callout crops. Edit `SCENES`, `DEVICES` and `STORES` there. +- `demo/`: the staged demo host the captures come from. +- `output/apple/`: 1320 × 2868 for the iPhone 6.9-inch slot. +- `output/google/`: 1440 × 2560 (9:16) phone screenshots, reusing the iPhone captures. +- `output/apple-ipad/`: 2064 × 2752 for the iPad 13-inch slot. +- `output/google-tablet/`: 1800 × 3200 (9:16) tablet screenshots, reusing the iPad captures. +- `output/preview.png`: contact sheet with one row per store, for review only. It is ignored by Git; do not upload it to a store. + +All exports are opaque RGB PNGs. + +## Render + +```sh +SF_FONT_DIR=/path/to/fonts node assets/store-screenshots/render.mjs +``` + +`SF_FONT_DIR` must hold `SFNS.ttf` copied from a Mac's `/System/Library/Fonts`. On macOS the renderer finds it without the variable. SF Pro is not redistributable, so it is not in the repository. The renderer fails rather than fall back to another font. + +Each capture and callout is resized with Lanczos to the exact pixels it fills, and the page renders at device scale 1. Do not render at a higher scale and downsample: Chrome's image downscaling is what made earlier exports look soft and aliased. + +Open `output/preview.png` and at least one full-size panel per store before you call a render done. The line guard catches wrapping, not a callout that covers the wrong thing. + +Callout crops are in native capture pixels. To find them, overlay a 100 px grid on the capture with sharp and read the coordinates off it. + +## Recapture + +1. Stage the demo host. It runs an in-process daemon on port 7791 with scripted Claude Code and Codex clients, a local clone of this repository as the `paseo` project, real worktrees, and a terminal in the hero worktree: + + ```sh + npx tsx assets/store-screenshots/demo/stage.mts + ``` + + Each run re-clones the repository into the demo home, so stale worktrees from an earlier run do not block it. The hero agent is paced so it reports a realistic "Worked for" time. Wait for `READY` in its output. + +2. Start Metro against it on a free port. When the simulator runs on another Mac, forward Metro and the daemon to it. Other agents may already hold a Metro port; pick another one rather than stopping theirs. + + ```sh + EXPO_PUBLIC_LOCAL_DAEMON=127.0.0.1:7791 npm run start:expo --workspace=@getpaseo/app -- --port + ssh -o ExitOnForwardFailure=yes -R :127.0.0.1: -R 7791:127.0.0.1:7791 + ``` + +3. On the simulator, install and open the dev client (`sh.paseo.debug`) at `http://127.0.0.1:`. Use an iPhone 16 Pro Max for the phone set and an iPad Pro 13-inch (M4) in portrait for the tablet set. + - Set dark appearance and override the status bar to 9:41 with full battery and signal (`xcrun simctl status_bar override`). + - On first launch, accept the open-in-app dialog, decline notifications, turn off the dev client's Tools button, and close any error toast. `agent-device react-native dismiss-overlay` clears overlays. + - Name the host "MacBook Pro" and set the sidebar badge to Hidden. + - Group the sidebar by status. + - If you reset the simulator, reinstall the dev client. + +4. Capture the sidebar first. Opening a "Ready to review" workspace marks it done. Select a Working row before you capture. + +5. Capture every screen at native density. `agent-device screenshot` defaults to 1x, which is too soft for the store: + + ```sh + agent-device screenshot /tmp/sidebar.png --pixel-density 3 --session store # iPhone: 1320 × 2868 + agent-device screenshot /tmp/sidebar.png --pixel-density 2 --session store # iPad: 2064 × 2752 + ``` + + - **Chat:** expand the tool-call group and scroll to the bottom. On iPad, open Explorer → Changes beside the chat. + - **Diff:** on iPhone, Explorer → Changes, then use Jump to file to reach `daemon-client.ts`. On iPad, collapse the sidebar, open a Diff tab from the main area's **+** menu and scroll to the `daemon-client.ts` hunks. + - **Terminal:** the second workspace tab. Type the commands with `npm run cli -- terminal send-keys --host 127.0.0.1:7791 "" Enter` instead of the simulator keyboard, then dismiss the software keyboard. + - **Plugin:** the "Build a preview deploys plugin" workspace with the turn collapsed, then tap **Deploys** for `plugin-sheet.png`. After editing the plugin, reload it with `npm run cli -- plugin reload --host 127.0.0.1:7791 preview-deploys`. + +6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview. + +## Publish + +The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports. + +Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en). diff --git a/assets/store-screenshots/demo/change/apply.mjs b/assets/store-screenshots/demo/change/apply.mjs new file mode 100644 index 0000000000..ae5efe295b --- /dev/null +++ b/assets/store-screenshots/demo/change/apply.mjs @@ -0,0 +1,84 @@ +// Applies the demo change to a checkout of the Paseo repository. +import { copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = process.argv[2]; +const client = path.join(repo, "packages/client/src"); + +copyFileSync( + path.join(here, "reconnect-delay.ts"), + path.join(client, "connection/reconnect-delay.ts"), +); +copyFileSync( + path.join(here, "reconnect-delay.test.ts"), + path.join(client, "connection/reconnect-delay.test.ts"), +); + +function edit(file, pairs) { + let text = readFileSync(file, "utf8"); + for (const [from, to] of pairs) { + if (!text.includes(from)) throw new Error(`Missing anchor in ${file}: ${from.slice(0, 60)}`); + text = text.replace(from, to); + } + writeFileSync(file, text); +} + +edit(path.join(client, "daemon-client.ts"), [ + [ + `import { CreationClient } from "./creation/index.js";\n`, + `import { CreationClient } from "./creation/index.js";\nimport { getReconnectDelayMs } from "./connection/reconnect-delay.js";\n`, + ], + [ + ` private reconnectAttempt = 0;\n`, + ` private reconnectAttempt = 0;\n private nextReconnectDelay: { attempt: number; delayMs: number } | null = null;\n`, + ], + [ + ` nextRetryAt: new Date( + Date.now() + + Math.min( + (this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) * + 2 ** this.reconnectAttempt, + this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, + ), + ).toISOString(), +`, + ` nextRetryAt: new Date(Date.now() + this.getNextReconnectDelayMs()).toISOString(), +`, + ], + [ + ` private armReconnectTimer(): void { + const attempt = this.reconnectAttempt; + const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS; + const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS; + const delay = Math.min(baseDelay * 2 ** attempt, maxDelay); +`, + ` // The countdown and the timer must agree, so each attempt draws its jitter once. + private getNextReconnectDelayMs(): number { + const attempt = this.reconnectAttempt; + if (this.nextReconnectDelay?.attempt !== attempt) { + this.nextReconnectDelay = { + attempt, + delayMs: getReconnectDelayMs(attempt, { + baseDelayMs: this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS, + maxDelayMs: this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, + }), + }; + } + return this.nextReconnectDelay.delayMs; + } + + private armReconnectTimer(): void { + const attempt = this.reconnectAttempt; + const delay = this.getNextReconnectDelayMs(); +`, + ], +]); + +edit(path.join(repo, "CHANGELOG.md"), [ + [ + `# Changelog\n\n`, + `# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Spread reconnect attempts after a relay restart instead of retrying every client at once\n\n`, + ], +]); diff --git a/assets/store-screenshots/demo/change/reconnect-delay.test.ts b/assets/store-screenshots/demo/change/reconnect-delay.test.ts new file mode 100644 index 0000000000..3b5367784c --- /dev/null +++ b/assets/store-screenshots/demo/change/reconnect-delay.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { getReconnectDelayMs } from "./reconnect-delay"; + +const options = { baseDelayMs: 1500, maxDelayMs: 30_000 }; + +describe("getReconnectDelayMs", () => { + it("doubles the ceiling on each attempt", () => { + const top = () => 1; + expect(getReconnectDelayMs(0, options, top)).toBe(1500); + expect(getReconnectDelayMs(1, options, top)).toBe(3000); + expect(getReconnectDelayMs(2, options, top)).toBe(6000); + }); + + it("never waits longer than the max delay", () => { + expect(getReconnectDelayMs(12, options, () => 1)).toBe(30_000); + }); + + it("spreads retries between half and the full ceiling", () => { + expect(getReconnectDelayMs(3, options, () => 0)).toBe(6000); + expect(getReconnectDelayMs(3, options, () => 0.5)).toBe(9000); + expect(getReconnectDelayMs(3, options, () => 1)).toBe(12_000); + }); + + it("keeps the first retry under two seconds", () => { + for (let i = 0; i < 100; i += 1) { + expect(getReconnectDelayMs(0, options)).toBeLessThanOrEqual(1500); + } + }); +}); diff --git a/assets/store-screenshots/demo/change/reconnect-delay.ts b/assets/store-screenshots/demo/change/reconnect-delay.ts new file mode 100644 index 0000000000..88de867aa6 --- /dev/null +++ b/assets/store-screenshots/demo/change/reconnect-delay.ts @@ -0,0 +1,18 @@ +export interface ReconnectDelayOptions { + baseDelayMs: number; + maxDelayMs: number; +} + +/** + * Exponential backoff with equal jitter. Half of each delay is fixed and half + * is random, so a relay restart spreads reconnects out instead of every client + * retrying on the same tick. + */ +export function getReconnectDelayMs( + attempt: number, + options: ReconnectDelayOptions, + random: () => number = Math.random, +): number { + const ceiling = Math.min(options.baseDelayMs * 2 ** attempt, options.maxDelayMs); + return Math.round(ceiling / 2 + random() * (ceiling / 2)); +} diff --git a/assets/store-screenshots/demo/preview-deploys/index.client.tsx b/assets/store-screenshots/demo/preview-deploys/index.client.tsx new file mode 100644 index 0000000000..72ab9a709a --- /dev/null +++ b/assets/store-screenshots/demo/preview-deploys/index.client.tsx @@ -0,0 +1,204 @@ +// The plugin the demo agent "builds" in the store screenshots: a Deploys pill on +// every composer that opens the latest preview deploy for each branch. The +// deploys are fixed sample data; the UI is a real plugin surface. +import type { + PluginButtonContentProps, + PluginButtonRegistration, + PluginClientContext, +} from "@getpaseo/plugin/client"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; + +type DeployStatus = "ready" | "building" | "failed"; + +interface Deploy { + branch: string; + status: DeployStatus; + detail: string; + age: string; + progress?: number; +} + +const DEPLOYS: Deploy[] = [ + { branch: "fix/reconnect-jitter", status: "ready", detail: "Built in 48s", age: "2m" }, + { + branch: "perf/provider-snapshot-cache", + status: "building", + detail: "Bundling app", + age: "now", + progress: 0.64, + }, + { branch: "feat/remember-terminal", status: "ready", detail: "Built in 51s", age: "18m" }, + { + branch: "i18n/ko-settings", + status: "failed", + detail: "Missing key settings.notifications", + age: "26m", + }, +]; + +function DeploysSheet({ theme, layout }: PluginButtonContentProps) { + const mono = layout.platform === "ios" ? "Menlo" : "monospace"; + const styles = useMemo( + () => ({ + // Popovers on tablets and desktop are narrow by default; give the + // branch names room. Phones show the sheet at full width. + body: { gap: 14, width: layout.compact ? undefined : 400 }, + production: { + padding: 14, + gap: 6, + borderRadius: 12, + backgroundColor: theme.colors.surface2, + }, + productionTop: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 }, + label: { color: theme.colors.foregroundMuted, fontSize: 13 }, + version: { color: theme.colors.foreground, fontSize: 22, fontWeight: "600" as const }, + live: { color: theme.colors.statusSuccess, fontSize: 13, fontWeight: "600" as const }, + heading: { color: theme.colors.foregroundMuted, fontSize: 13, marginTop: 4 }, + list: { + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.border, + overflow: "hidden" as const, + }, + row: { + flexDirection: "row" as const, + alignItems: "center" as const, + gap: 12, + paddingVertical: 12, + paddingHorizontal: 14, + }, + divider: { height: 1, backgroundColor: theme.colors.border }, + text: { flex: 1, gap: 4 }, + branch: { color: theme.colors.foreground, fontSize: 14, fontFamily: mono }, + detail: { color: theme.colors.foregroundMuted, fontSize: 13 }, + failed: { color: theme.colors.statusDanger, fontSize: 13 }, + age: { color: theme.colors.foregroundMuted, fontSize: 13 }, + track: { + height: 4, + marginTop: 4, + borderRadius: 2, + backgroundColor: theme.colors.surface2, + overflow: "hidden" as const, + }, + }), + [theme, mono, layout.compact], + ); + const dotColor: Record = { + ready: theme.colors.statusSuccess, + building: theme.colors.statusWarning, + failed: theme.colors.statusDanger, + }; + + return ( + + + + + Live + Production · main + + v0.14.0-beta.1 + Deployed 3h ago + + Previews + + {DEPLOYS.map((deploy, index) => ( + + {index > 0 ? : null} + + + + + {deploy.branch} + + + {deploy.detail} + + {deploy.progress !== undefined ? ( + + ) : null} + + {deploy.age} + + + ))} + + + ); +} + +function Progress({ value, track, color }: { value: number; track: object; color: string }) { + const fill = useMemo( + () => ({ width: `${value * 100}%` as const, height: 4, backgroundColor: color }), + [value, color], + ); + return ( + + + + ); +} + +function Dot({ color }: { color: string }) { + const style = useMemo( + () => ({ width: 8, height: 8, borderRadius: 4, backgroundColor: color }), + [color], + ); + return ; +} + +export default function contribute(client: PluginClientContext) { + const pills = new Map(); + const lifetime = new AbortController(); + const register = (agent: { id: string; workspaceId?: string | null }) => { + if (lifetime.signal.aborted || !agent.workspaceId) return; + pills.get(agent.id)?.remove(); + pills.set( + agent.id, + client.addComposerPill({ + id: "deploys", + workspaceId: agent.workspaceId, + agentId: agent.id, + button: { + title: "Preview deploys", + icon: "Rocket", + label: "Deploys", + behavior: { kind: "popover", Content: DeploysSheet }, + }, + }), + ); + }; + void client.paseo.agents + .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") { + pills.get(update.agentId)?.remove(); + pills.delete(update.agentId); + } else register(update.agent); + }, + }); + return undefined; + }) + .catch((error) => { + if (!lifetime.signal.aborted) console.error("Agent observation failed", error); + }); + return () => { + lifetime.abort(); + for (const pill of pills.values()) pill.remove(); + pills.clear(); + }; +} diff --git a/assets/store-screenshots/demo/preview-deploys/paseo-plugin.json b/assets/store-screenshots/demo/preview-deploys/paseo-plugin.json new file mode 100644 index 0000000000..0da849e8f7 --- /dev/null +++ b/assets/store-screenshots/demo/preview-deploys/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "preview-deploys", + "requirements": { "paseo": ">=0.8.0" } +} diff --git a/assets/store-screenshots/demo/scripted-agents.mts b/assets/store-screenshots/demo/scripted-agents.mts new file mode 100644 index 0000000000..603017f323 --- /dev/null +++ b/assets/store-screenshots/demo/scripted-agents.mts @@ -0,0 +1,281 @@ +// Scripted Claude Code and Codex clients for staging store screenshots. +// Each agent replays a fixed conversation through the real daemon, so the app +// renders real timeline rows, permission prompts, and running states. +import { randomUUID } from "node:crypto"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentMode, + AgentModelDefinition, + AgentPermissionRequest, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptInput, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, + AgentTimelineItem, + ToolCallDetail, +} from "../../../packages/server/src/server/agent/agent-sdk-types.js"; +import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; + +export type ScriptStep = + | { kind: "say"; text: string } + | { kind: "think"; text: string } + | { kind: "tool"; name: string; detail: ToolCallDetail } + | { kind: "todo"; items: { text: string; completed: boolean }[] } + | { kind: "wait"; ms: number } + | { kind: "running-tool"; name: string; detail: ToolCallDetail } + | { kind: "ask"; name: string; title: string; detail: ToolCallDetail }; + +export interface AgentScript { + /** Matched against the start of the prompt that opens the turn. */ + prompt: string; + steps: ScriptStep[]; + /** Leave the turn open after the last step. */ + keepRunning?: boolean; + /** Pause between steps, so "Worked for" reflects a believable duration. */ + stepDelayMs?: number; +} + +const CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: true, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, +}; + +const MODELS: Record = { + claude: [ + { + provider: "claude", + id: "claude-opus-5-5", + label: "Opus 5.5", + isDefault: true, + thinkingOptions: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "medium", + }, + { provider: "claude", id: "claude-sonnet-5-5", label: "Sonnet 5.5" }, + ], + codex: [ + { + provider: "codex", + id: "gpt-5.6-sol", + label: "GPT-5.6-Sol", + isDefault: true, + thinkingOptions: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "medium", + }, + ], +}; + +function promptText(prompt: AgentPromptInput): string { + if (typeof prompt === "string") return prompt; + return prompt + .map((block) => ("text" in block && typeof block.text === "string" ? block.text : "")) + .join(""); +} + +class ScriptedSession implements AgentSession { + readonly capabilities = CAPABILITIES; + readonly id = randomUUID(); + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); + private readonly history: AgentStreamEvent[] = []; + private pending: AgentPermissionRequest[] = []; + private activeTurnId: string | null = null; + + constructor( + readonly provider: string, + private readonly config: AgentSessionConfig, + private readonly scripts: AgentScript[], + ) {} + + private emit(event: AgentStreamEvent): void { + const tagged = this.activeTurnId ? { ...event, turnId: this.activeTurnId } : event; + this.history.push(tagged); + for (const listener of this.listeners) listener(tagged); + } + + private timeline(item: AgentTimelineItem): void { + this.emit({ type: "timeline", provider: this.provider, item }); + } + + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + const turnId = randomUUID(); + this.activeTurnId = turnId; + const text = promptText(prompt); + const script = this.scripts.find((candidate) => text.startsWith(candidate.prompt)); + setTimeout(() => void this.play(text, script), 50); + return { turnId }; + } + + private async play(text: string, script: AgentScript | undefined): Promise { + this.emit({ type: "turn_started", provider: this.provider }); + this.timeline({ type: "user_message", text, messageId: randomUUID() }); + const delay = script?.stepDelayMs ?? 400; + for (const step of script?.steps ?? [{ kind: "say", text: "Done." } as const]) { + await new Promise((resolve) => setTimeout(resolve, step.kind === "wait" ? step.ms : delay)); + this.playStep(step); + if (step.kind === "ask") return; + } + if (script?.keepRunning) return; + this.emit({ + type: "turn_completed", + provider: this.provider, + usage: { inputTokens: 48_210, outputTokens: 3_904 }, + }); + this.activeTurnId = null; + } + + private playStep(step: ScriptStep): void { + switch (step.kind) { + case "say": + this.timeline({ type: "assistant_message", text: step.text, messageId: randomUUID() }); + return; + case "think": + this.timeline({ type: "reasoning", text: step.text }); + return; + case "todo": + this.timeline({ type: "todo", items: step.items }); + return; + case "wait": + return; + case "tool": + case "running-tool": { + const callId = randomUUID(); + this.timeline({ + type: "tool_call", + callId, + name: step.name, + status: "running", + error: null, + detail: step.detail, + }); + if (step.kind === "tool") { + this.timeline({ + type: "tool_call", + callId, + name: step.name, + status: "completed", + error: null, + detail: step.detail, + }); + } + return; + } + case "ask": { + const request: AgentPermissionRequest = { + id: randomUUID(), + provider: this.provider, + name: step.name, + kind: "tool", + title: step.title, + detail: step.detail, + }; + this.pending = [request]; + this.emit({ type: "permission_requested", provider: this.provider, request }); + } + } + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + async *streamHistory(): AsyncGenerator { + yield* this.history; + } + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: this.config.model ?? MODELS[this.provider]?.[0]?.id ?? null, + thinkingOptionId: this.config.thinkingOptionId ?? "medium", + modeId: this.config.modeId ?? null, + }; + } + + async getAvailableModes(): Promise { + return getAgentProviderDefinition(this.provider).modes; + } + + async getCurrentMode(): Promise { + return this.config.modeId ?? null; + } + + async setMode(): Promise {} + + getPendingPermissions(): AgentPermissionRequest[] { + return this.pending; + } + + async respondToPermission(requestId: string, resolution: AgentPermissionResponse) { + this.pending = []; + this.emit({ type: "permission_resolved", provider: this.provider, requestId, resolution }); + } + + describePersistence(): AgentPersistenceHandle { + return { provider: this.provider, sessionId: this.id }; + } + + async interrupt(): Promise { + if (!this.activeTurnId) return; + this.emit({ type: "turn_canceled", provider: this.provider, reason: "Interrupted" }); + this.activeTurnId = null; + } + + async close(): Promise {} +} + +export class ScriptedAgentClient implements AgentClient { + readonly capabilities = CAPABILITIES; + + constructor( + readonly provider: string, + private readonly scripts: AgentScript[], + ) {} + + async createSession(config: AgentSessionConfig): Promise { + return new ScriptedSession(this.provider, config, this.scripts); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + return new ScriptedSession( + this.provider, + { provider: this.provider, cwd: overrides?.cwd ?? process.cwd(), ...overrides }, + this.scripts, + ); + } + + async fetchCatalog() { + return { + models: MODELS[this.provider] ?? [], + modes: getAgentProviderDefinition(this.provider).modes, + }; + } + + async isAvailable(): Promise { + return true; + } +} diff --git a/assets/store-screenshots/demo/stage.mts b/assets/store-screenshots/demo/stage.mts new file mode 100644 index 0000000000..3c6a3cadf0 --- /dev/null +++ b/assets/store-screenshots/demo/stage.mts @@ -0,0 +1,556 @@ +// Stages the demo host used for store screenshots. +// +// npx tsx assets/store-screenshots/demo/stage.mts +// +// Starts an in-process daemon on DEMO_PORT (default 7791) with scripted Claude +// Code and Codex clients, adds a local clone of this repository as the `paseo` +// project, cuts real worktrees, applies a real change to the hero worktree, and +// opens a terminal there. Leave it running while you capture. +import { execFileSync, execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createTestPaseoDaemon } from "../../../packages/server/src/server/test-utils/paseo-daemon.js"; +import { DaemonClient } from "../../../packages/server/src/server/test-utils/daemon-client.js"; +import { type AgentScript, ScriptedAgentClient } from "./scripted-agents.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "../../.."); +const demoRoot = path.join(repoRoot, ".dev/store-capture/demo"); +const codeRoot = path.join(demoRoot, "code"); +const pluginDir = path.join(here, "preview-deploys"); +const port = Number(process.env.DEMO_PORT ?? 7791); +const minute = 60_000; + +const claudeScripts: AgentScript[] = []; +const codexScripts: AgentScript[] = []; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", ["-c", "user.name=Mo", "-c", "user.email=mo@paseo.sh", ...args], { + cwd, + encoding: "utf8", + }); +} + +function run(cwd: string, command: string): string { + return execSync(command, { cwd, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0" } }); +} + +async function prepareRepos(): Promise { + // A fresh clone each run: worktrees from an earlier run stay registered in it. + // Worktree setup (npm ci, builds) is dropped so new worktrees open instantly. + const paseo = path.join(codeRoot, "paseo"); + await rm(paseo, { recursive: true, force: true }); + await mkdir(codeRoot, { recursive: true }); + execFileSync("git", ["clone", "-q", "--local", repoRoot, paseo]); + git(paseo, "checkout", "-q", "-B", "main"); + git(paseo, "remote", "remove", "origin"); + const config = JSON.parse(await readFile(path.join(paseo, "paseo.json"), "utf8")); + delete config.worktree; + await writeFile(path.join(paseo, "paseo.json"), `${JSON.stringify(config, null, 2)}\n`); + git(paseo, "commit", "-qam", "chore: skip worktree setup in demo"); + const dotfiles = path.join(codeRoot, "dotfiles"); + if (!existsSync(dotfiles)) { + await mkdir(dotfiles, { recursive: true }); + await writeFile(path.join(dotfiles, "tmux.conf"), "set -g mouse on\nset -g base-index 1\n"); + await writeFile(path.join(dotfiles, "zshrc"), "export EDITOR=nvim\nalias g=git\n"); + git(dotfiles, "init", "-q", "-b", "main"); + git(dotfiles, "add", "."); + git(dotfiles, "commit", "-qm", "Initial dotfiles"); + } +} + +async function main(): Promise { + await prepareRepos(); + const home = path.join(demoRoot, "home"); + await rm(home, { recursive: true, force: true }); + await mkdir(home, { recursive: true }); + + const daemon = await createTestPaseoDaemon({ + agentClients: { + claude: new ScriptedAgentClient("claude", claudeScripts), + codex: new ScriptedAgentClient("codex", codexScripts), + }, + listen: "127.0.0.1", + listenPort: port, + paseoHomeRoot: home, + mcpEnabled: false, + cleanup: false, + pluginsEnabled: true, + plugins: { + "preview-deploys": { source: "directory", path: pluginDir, enabled: true }, + }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0", + }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + + const paseo = await client.addProject(path.join(codeRoot, "paseo")); + const dotfiles = await client.addProject(path.join(codeRoot, "dotfiles")); + const paseoProjectId = paseo.project!.projectId; + + async function worktree(title: string, branchName: string) { + const result = await client.createWorkspace({ + title, + source: { + kind: "worktree", + projectId: paseoProjectId, + action: "branch-off", + baseBranch: "main", + branchName, + }, + }); + if (!result.workspace) throw new Error(`No workspace for ${title}: ${JSON.stringify(result)}`); + return result.workspace; + } + + async function agent(input: { + provider: "claude" | "codex"; + workspace: { id: string; workspaceDirectory?: string | null }; + cwd: string; + title: string; + script: AgentScript; + }) { + (input.provider === "claude" ? claudeScripts : codexScripts).push(input.script); + return client.createAgent({ + provider: input.provider, + cwd: input.cwd, + workspaceId: input.workspace.id, + title: input.title, + initialPrompt: input.script.prompt, + }); + } + + const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + // Done: finished earlier and already read. + const release = await worktree("Release v0.14.0-beta.1", "release/0.14.0-beta.1"); + const releaseAgent = await agent({ + provider: "codex", + workspace: release, + cwd: release.workspaceDirectory!, + title: "Release v0.14.0-beta.1", + script: { + prompt: "Cut 0.14.0-beta.1 from main and draft the release notes.", + steps: [ + { + kind: "tool", + name: "shell", + detail: { + type: "shell", + command: "npm run release:beta", + output: "Tagged v0.14.0-beta.1\n", + exitCode: 0, + }, + }, + { + kind: "say", + text: "Tagged `v0.14.0-beta.1` and drafted the release notes. CI is building the desktop and mobile artifacts.", + }, + ], + }, + }); + const dotfilesWorkspace = ( + await client.createWorkspace({ + title: "Add a tmux status line", + source: { + kind: "directory", + path: path.join(codeRoot, "dotfiles"), + projectId: dotfiles.project!.projectId, + }, + }) + ).workspace!; + const tmuxAgent = await agent({ + provider: "claude", + workspace: dotfilesWorkspace, + cwd: path.join(codeRoot, "dotfiles"), + title: "Add a tmux status line", + script: { + prompt: "Add a minimal tmux status line with the session name and battery.", + steps: [ + { + kind: "tool", + name: "Edit", + detail: { + type: "edit", + filePath: "tmux.conf", + oldString: "", + newString: "set -g status-right '#S · #{battery_percentage}'", + }, + }, + { + kind: "say", + text: "Added a status line with the session name on the left and battery on the right.", + }, + ], + }, + }); + await pause(3000); + await client.clearAgentAttention([releaseAgent.id, tmuxAgent.id]); + + // Working: turns that stay open while you capture. + const ipad = await worktree( + "Keep the composer above the iPad keyboard", + "fix/ipad-keyboard-inset", + ); + await agent({ + provider: "codex", + workspace: ipad, + cwd: ipad.workspaceDirectory!, + title: "Keep the composer above the iPad keyboard", + script: { + prompt: + "On iPad with a hardware keyboard attached, the composer jumps up as if the software keyboard were open. Find out why and fix it.", + keepRunning: true, + steps: [ + { + kind: "tool", + name: "shell", + detail: { + type: "shell", + command: 'rg -n "keyboardHeight" packages/app/src', + output: + "packages/app/src/keyboard/shift/internal/provider.ts:41: const keyboardHeight = useSharedValue(0);\n", + exitCode: 0, + }, + }, + { + kind: "tool", + name: "read", + detail: { + type: "read", + filePath: "packages/app/src/keyboard/shift/internal/provider.ts", + }, + }, + { + kind: "say", + text: "The shift provider trusts the keyboard frame event. With a hardware keyboard, iPadOS still reports the shortcuts bar as a keyboard frame, so we lift the composer by its full height. Checking the frame against the screen bottom next.", + }, + { + kind: "running-tool", + name: "shell", + detail: { type: "shell", command: "npx vitest run packages/app/src/keyboard" }, + }, + ], + }, + }); + const snapshots = await worktree( + "Cache provider snapshots on disk", + "perf/provider-snapshot-cache", + ); + await agent({ + provider: "claude", + workspace: snapshots, + cwd: snapshots.workspaceDirectory!, + title: "Cache provider snapshots on disk", + script: { + prompt: + "Cold start spends about two seconds probing providers. Cache the last provider snapshot on disk and refresh it in the background.", + keepRunning: true, + steps: [ + { + kind: "todo", + items: [ + { text: "Find where snapshots are built", completed: true }, + { text: "Persist the last snapshot per host", completed: false }, + { text: "Refresh in the background after boot", completed: false }, + ], + }, + { + kind: "tool", + name: "Read", + detail: { + type: "read", + filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts", + }, + }, + { + kind: "running-tool", + name: "Edit", + detail: { + type: "edit", + filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts", + }, + }, + ], + }, + }); + + // Needs input: waiting on a permission. + const korean = await worktree("Korean translations for Settings", "i18n/ko-settings"); + await agent({ + provider: "claude", + workspace: korean, + cwd: korean.workspaceDirectory!, + title: "Korean translations for Settings", + script: { + prompt: "Translate the new Settings strings into Korean and open a PR.", + steps: [ + { + kind: "tool", + name: "Read", + detail: { type: "read", filePath: "packages/app/src/i18n/resources/en.ts" }, + }, + { + kind: "tool", + name: "Edit", + detail: { type: "edit", filePath: "packages/app/src/i18n/resources/ko.ts" }, + }, + { + kind: "tool", + name: "Bash", + detail: { + type: "shell", + command: "npm run test --workspace=@getpaseo/app -- i18n", + output: "Test Files 3 passed (3)\n Tests 41 passed (41)\n", + exitCode: 0, + }, + }, + { kind: "say", text: "Translated 18 strings. I'll push the branch so I can open the PR." }, + { + kind: "ask", + name: "Bash", + title: "Bash", + detail: { type: "shell", command: "git push -u origin i18n/ko-settings" }, + }, + ], + }, + }); + + // Ready to review: a second finished agent. + const terminals = await worktree( + "Remember the last terminal per workspace", + "feat/remember-terminal", + ); + await agent({ + provider: "codex", + workspace: terminals, + cwd: terminals.workspaceDirectory!, + title: "Remember the last terminal per workspace", + script: { + prompt: + "When I switch back to a workspace, open the terminal I was using last instead of the first one.", + steps: [ + { + kind: "tool", + name: "shell", + detail: { + type: "shell", + command: 'rg -n "activeTerminalId" packages/app/src', + output: "", + exitCode: 0, + }, + }, + { + kind: "say", + text: "Workspaces now remember their last focused terminal and reopen it when you switch back. The choice is stored per host, so it survives restarts.", + }, + ], + }, + }); + + // Plugin: the agent "built" the preview-deploys plugin this daemon runs. + const plugin = await worktree("Build a preview deploys plugin", "plugins/preview-deploys"); + const pluginSource = (file: string) => run(pluginDir, `cat ${file}`); + await agent({ + provider: "claude", + workspace: plugin, + cwd: plugin.workspaceDirectory!, + title: "Build a preview deploys plugin", + script: { + prompt: "Build me a plugin that shows our preview deploys right next to the composer.", + stepDelayMs: 6_000, + steps: [ + { kind: "tool", name: "Read", detail: { type: "read", filePath: "docs/plugins.md" } }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "plugins/preview-deploys/paseo-plugin.json", + content: pluginSource("paseo-plugin.json"), + }, + }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "plugins/preview-deploys/index.client.tsx", + content: pluginSource("index.client.tsx"), + }, + }, + { + kind: "tool", + name: "Bash", + detail: { + type: "shell", + command: "paseo plugin install ./plugins/preview-deploys", + output: "Installed preview-deploys\n", + exitCode: 0, + }, + }, + { + kind: "say", + text: "Done. **Deploys** now sits above the composer in every workspace. Tap it to see production and the latest preview for each branch, with build progress and failures.", + }, + ], + }, + }); + + // Hero: Claude finished a real change in a real worktree. + const jitter = await worktree("Add jitter to reconnect backoff", "fix/reconnect-jitter"); + const cwd = jitter.workspaceDirectory!; + await symlink(path.join(repoRoot, "node_modules"), path.join(cwd, "node_modules")); + execFileSync("node", [path.join(here, "change/apply.mjs"), cwd]); + const clientDiff = git(cwd, "diff", "--", "packages/client/src/daemon-client.ts"); + const vitest = run( + cwd, + "npx vitest run packages/client/src/connection/reconnect-delay.test.ts", + ).replace(/^ RUN .*\n/m, ""); + const readExcerpt = run(cwd, "sed -n '7067,7086p' packages/client/src/daemon-client.ts"); + await agent({ + provider: "claude", + workspace: jitter, + cwd, + title: "Add jitter to reconnect backoff", + script: { + prompt: + 'When the relay restarts, every phone reconnects at the same moment and we get a spike. Add jitter to the reconnect backoff, keep the 30s cap, and make sure the "Retrying in" countdown still matches the real delay.', + stepDelayMs: 7_000, + steps: [ + { + kind: "tool", + name: "Grep", + detail: { + type: "search", + toolName: "grep", + query: "reconnectAttempt", + filePaths: ["packages/client/src/daemon-client.ts"], + numFiles: 1, + numMatches: 9, + mode: "files_with_matches", + }, + }, + { + kind: "tool", + name: "Read", + detail: { + type: "read", + filePath: "packages/client/src/daemon-client.ts", + offset: 7040, + limit: 60, + content: readExcerpt, + }, + }, + { + kind: "say", + text: "The delay is calculated twice: once for `nextRetryAt`, which drives the countdown, and again when the timer is armed. Both use plain doubling, so every client retries on the same schedule. If I add randomness in both places the countdown will be wrong, so I'll draw the jitter once per attempt and share it.", + }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "packages/client/src/connection/reconnect-delay.ts", + content: run(cwd, "cat packages/client/src/connection/reconnect-delay.ts"), + }, + }, + { + kind: "tool", + name: "Edit", + detail: { + type: "edit", + filePath: "packages/client/src/daemon-client.ts", + unifiedDiff: clientDiff, + }, + }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "packages/client/src/connection/reconnect-delay.test.ts", + content: run(cwd, "cat packages/client/src/connection/reconnect-delay.test.ts"), + }, + }, + { + kind: "tool", + name: "Bash", + detail: { + type: "shell", + command: "npx vitest run packages/client/src/connection/reconnect-delay.test.ts", + output: vitest, + exitCode: 0, + }, + }, + { + kind: "tool", + name: "Edit", + detail: { + type: "edit", + filePath: "CHANGELOG.md", + unifiedDiff: git(cwd, "diff", "--", "CHANGELOG.md"), + }, + }, + { + kind: "say", + text: [ + "Reconnects now back off with jitter.", + "", + "- `getReconnectDelayMs` picks a delay between half and the full backoff, still capped at 30s.", + '- `DaemonClient` draws the delay once per attempt, so the "Retrying in" countdown matches the actual retry.', + "- Four new tests cover the doubling, the cap, the jitter range, and a quick first retry.", + "", + "Tests pass. After a relay restart, clients now spread their reconnects across the backoff window instead of arriving together.", + ].join("\n"), + }, + ], + }, + }); + + const terminal = await client.createTerminal(cwd, "zsh", undefined, { + workspaceId: jitter.id, + command: "bash", + args: ["--rcfile", path.join(here, "terminal-rc.sh"), "-i"], + }); + const terminalId = terminal.terminal?.id; + if (terminalId) { + await pause(1500); + for (const line of [ + "git status --short", + "cd packages/client", + "npx vitest run reconnect --reporter=tree", + ]) { + client.sendTerminalInput(terminalId, { type: "input", data: `${line}\r` }); + await pause(4000); + } + } + + console.log(`READY port=${daemon.port} home=${daemon.paseoHome}`); + await writeFile( + path.join(demoRoot, "state.json"), + JSON.stringify( + { port: daemon.port, home: daemon.paseoHome, hero: { workspaceId: jitter.id, cwd } }, + null, + 2, + ), + ); + const stop = async () => { + await client.close(); + await daemon.close(); + process.exit(0); + }; + process.on("SIGTERM", stop); + process.on("SIGINT", stop); + void minute; +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/assets/store-screenshots/demo/terminal-rc.sh b/assets/store-screenshots/demo/terminal-rc.sh new file mode 100644 index 0000000000..ab33d79466 --- /dev/null +++ b/assets/store-screenshots/demo/terminal-rc.sh @@ -0,0 +1,19 @@ +# Shell profile for the demo terminal: a Pure-style two-line prompt that fits a +# phone-width terminal, and vitest output without the absolute checkout path +# or the long timing breakdown. Vitest drops its colors when piped, so the +# wrapper puts its usual green checks and grey timings back. +export FORCE_COLOR=1 +__where() { local top; top=$(git rev-parse --show-toplevel 2>/dev/null); printf '~/paseo%s' "${PWD#"$top"}"; } +__branch() { git branch --show-current 2>/dev/null; } +__dirty() { [ -n "$(git status --porcelain 2>/dev/null)" ] && printf '*'; } +PROMPT_COMMAND='printf "\e]0;zsh\a"' +PS1='\n\[\e[34m\]$(__where)\[\e[0m\] \[\e[90m\]$(__branch)$(__dirty)\[\e[0m\]\n\[\e[35m\]❯\[\e[0m\] ' +npx() { + command npx "$@" 2>&1 | sed -E \ + -e '1{/^$/d}' -e '/ RUN /d' -e '/Start at/d' -e 's/ \(transform.*\)//' \ + -e $'s/✓/\e[32m✓\e[0m/' \ + -e $'s/ ([0-9]+ms)$/ \e[90m\\1\e[0m/' \ + -e $'s/ (\\([0-9]+( tests)?\\))/ \e[90m\\1\e[0m/' \ + -e $'s/([0-9]+ passed)/\e[1;32m\\1\e[0m/' +} +clear diff --git a/assets/store-screenshots/output/apple-ipad/01-sidebar.png b/assets/store-screenshots/output/apple-ipad/01-sidebar.png new file mode 100644 index 0000000000..03f1d8ed61 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/01-sidebar.png differ diff --git a/assets/store-screenshots/output/apple-ipad/02-chat.png b/assets/store-screenshots/output/apple-ipad/02-chat.png new file mode 100644 index 0000000000..1c56073112 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/02-chat.png differ diff --git a/assets/store-screenshots/output/apple-ipad/03-plugins.png b/assets/store-screenshots/output/apple-ipad/03-plugins.png new file mode 100644 index 0000000000..87063634d3 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/03-plugins.png differ diff --git a/assets/store-screenshots/output/apple-ipad/04-review.png b/assets/store-screenshots/output/apple-ipad/04-review.png new file mode 100644 index 0000000000..80e8cf8a52 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/04-review.png differ diff --git a/assets/store-screenshots/output/apple-ipad/05-terminal.png b/assets/store-screenshots/output/apple-ipad/05-terminal.png new file mode 100644 index 0000000000..01b8d93c2a Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/05-terminal.png differ diff --git a/assets/store-screenshots/output/apple/01-sidebar.png b/assets/store-screenshots/output/apple/01-sidebar.png new file mode 100644 index 0000000000..f2a7ab849c Binary files /dev/null and b/assets/store-screenshots/output/apple/01-sidebar.png differ diff --git a/assets/store-screenshots/output/apple/02-chat.png b/assets/store-screenshots/output/apple/02-chat.png new file mode 100644 index 0000000000..b9afa6607d Binary files /dev/null and b/assets/store-screenshots/output/apple/02-chat.png differ diff --git a/assets/store-screenshots/output/apple/03-plugins.png b/assets/store-screenshots/output/apple/03-plugins.png new file mode 100644 index 0000000000..6f74f4ce53 Binary files /dev/null and b/assets/store-screenshots/output/apple/03-plugins.png differ diff --git a/assets/store-screenshots/output/apple/04-review.png b/assets/store-screenshots/output/apple/04-review.png new file mode 100644 index 0000000000..9b34d6cce0 Binary files /dev/null and b/assets/store-screenshots/output/apple/04-review.png differ diff --git a/assets/store-screenshots/output/apple/05-terminal.png b/assets/store-screenshots/output/apple/05-terminal.png new file mode 100644 index 0000000000..fae3170671 Binary files /dev/null and b/assets/store-screenshots/output/apple/05-terminal.png differ diff --git a/assets/store-screenshots/output/google-tablet/01-sidebar.png b/assets/store-screenshots/output/google-tablet/01-sidebar.png new file mode 100644 index 0000000000..8560682610 Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/01-sidebar.png differ diff --git a/assets/store-screenshots/output/google-tablet/02-chat.png b/assets/store-screenshots/output/google-tablet/02-chat.png new file mode 100644 index 0000000000..a8f2342ab3 Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/02-chat.png differ diff --git a/assets/store-screenshots/output/google-tablet/03-plugins.png b/assets/store-screenshots/output/google-tablet/03-plugins.png new file mode 100644 index 0000000000..3fe70263ca Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/03-plugins.png differ diff --git a/assets/store-screenshots/output/google-tablet/04-review.png b/assets/store-screenshots/output/google-tablet/04-review.png new file mode 100644 index 0000000000..ae54bfa798 Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/04-review.png differ diff --git a/assets/store-screenshots/output/google-tablet/05-terminal.png b/assets/store-screenshots/output/google-tablet/05-terminal.png new file mode 100644 index 0000000000..9921c839dd Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/05-terminal.png differ diff --git a/assets/store-screenshots/output/google/01-sidebar.png b/assets/store-screenshots/output/google/01-sidebar.png new file mode 100644 index 0000000000..abed766b66 Binary files /dev/null and b/assets/store-screenshots/output/google/01-sidebar.png differ diff --git a/assets/store-screenshots/output/google/02-chat.png b/assets/store-screenshots/output/google/02-chat.png new file mode 100644 index 0000000000..bd42e5df49 Binary files /dev/null and b/assets/store-screenshots/output/google/02-chat.png differ diff --git a/assets/store-screenshots/output/google/03-plugins.png b/assets/store-screenshots/output/google/03-plugins.png new file mode 100644 index 0000000000..e26ecb407e Binary files /dev/null and b/assets/store-screenshots/output/google/03-plugins.png differ diff --git a/assets/store-screenshots/output/google/04-review.png b/assets/store-screenshots/output/google/04-review.png new file mode 100644 index 0000000000..38e9287c6a Binary files /dev/null and b/assets/store-screenshots/output/google/04-review.png differ diff --git a/assets/store-screenshots/output/google/05-terminal.png b/assets/store-screenshots/output/google/05-terminal.png new file mode 100644 index 0000000000..1445744fcb Binary files /dev/null and b/assets/store-screenshots/output/google/05-terminal.png differ diff --git a/assets/store-screenshots/raw/ipad/chat.png b/assets/store-screenshots/raw/ipad/chat.png new file mode 100644 index 0000000000..f8abc88b6c Binary files /dev/null and b/assets/store-screenshots/raw/ipad/chat.png differ diff --git a/assets/store-screenshots/raw/ipad/diff.png b/assets/store-screenshots/raw/ipad/diff.png new file mode 100644 index 0000000000..030946b7ae Binary files /dev/null and b/assets/store-screenshots/raw/ipad/diff.png differ diff --git a/assets/store-screenshots/raw/ipad/plugin-sheet.png b/assets/store-screenshots/raw/ipad/plugin-sheet.png new file mode 100644 index 0000000000..2550f0eecd Binary files /dev/null and b/assets/store-screenshots/raw/ipad/plugin-sheet.png differ diff --git a/assets/store-screenshots/raw/ipad/plugin.png b/assets/store-screenshots/raw/ipad/plugin.png new file mode 100644 index 0000000000..1fd3fbe2d8 Binary files /dev/null and b/assets/store-screenshots/raw/ipad/plugin.png differ diff --git a/assets/store-screenshots/raw/ipad/sidebar.png b/assets/store-screenshots/raw/ipad/sidebar.png new file mode 100644 index 0000000000..f348372425 Binary files /dev/null and b/assets/store-screenshots/raw/ipad/sidebar.png differ diff --git a/assets/store-screenshots/raw/ipad/terminal.png b/assets/store-screenshots/raw/ipad/terminal.png new file mode 100644 index 0000000000..3c65d7aabf Binary files /dev/null and b/assets/store-screenshots/raw/ipad/terminal.png differ diff --git a/assets/store-screenshots/raw/iphone/chat.png b/assets/store-screenshots/raw/iphone/chat.png new file mode 100644 index 0000000000..5ba23b3282 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/chat.png differ diff --git a/assets/store-screenshots/raw/iphone/diff.png b/assets/store-screenshots/raw/iphone/diff.png new file mode 100644 index 0000000000..eab57a68d5 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/diff.png differ diff --git a/assets/store-screenshots/raw/iphone/files.png b/assets/store-screenshots/raw/iphone/files.png new file mode 100644 index 0000000000..d2ebaf96b2 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/files.png differ diff --git a/assets/store-screenshots/raw/iphone/plugin-sheet.png b/assets/store-screenshots/raw/iphone/plugin-sheet.png new file mode 100644 index 0000000000..012e3877ea Binary files /dev/null and b/assets/store-screenshots/raw/iphone/plugin-sheet.png differ diff --git a/assets/store-screenshots/raw/iphone/plugin.png b/assets/store-screenshots/raw/iphone/plugin.png new file mode 100644 index 0000000000..95beb12d67 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/plugin.png differ diff --git a/assets/store-screenshots/raw/iphone/sidebar.png b/assets/store-screenshots/raw/iphone/sidebar.png new file mode 100644 index 0000000000..d0601f3f2f Binary files /dev/null and b/assets/store-screenshots/raw/iphone/sidebar.png differ diff --git a/assets/store-screenshots/raw/iphone/terminal.png b/assets/store-screenshots/raw/iphone/terminal.png new file mode 100644 index 0000000000..b96b192e17 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/terminal.png differ diff --git a/assets/store-screenshots/render.mjs b/assets/store-screenshots/render.mjs new file mode 100644 index 0000000000..6695eac559 --- /dev/null +++ b/assets/store-screenshots/render.mjs @@ -0,0 +1,413 @@ +// Renders the App Store and Google Play screenshots from the native captures in +// raw/. Every image is resized with Lanczos to the exact pixel size it occupies +// on the canvas, and the page renders at device scale 1, so the browser never +// resamples a capture. +// +// node assets/store-screenshots/render.mjs +// +// SF Pro is not redistributable. On macOS it comes from /System/Library/Fonts; +// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac. + +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { build } from "esbuild"; +import { chromium } from "playwright"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import sharp from "sharp"; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(root, "../.."); +const raw = (device, name) => path.join(root, "raw", device, `${name}.png`); +const output = path.join(root, "output"); +const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images"); + +// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at +// 2x. The frame's screen radius and Dynamic Island are in capture pixels. +const DEVICES = { + iphone: { + capture: { width: 1320, height: 2868 }, + radius: 186, + bezel: 22, + island: { width: 378, height: 112, top: 33 }, + }, + ipad: { capture: { width: 2064, height: 2752 }, radius: 44, bezel: 30, island: null }, +}; + +// Every title wraps to two lines and every subline fits on one; the renderer +// fails otherwise, because the device is placed from that fixed text height. +// +// Each scene names its capture per device, and a device entry may override the +// title or subline where the copy depends on the form factor. +// +// A callout lifts a region of a capture out of the device. `crop` is in native +// capture pixels, `zoom` is its size relative to the device screen (capped to +// the canvas width), and the callout is centred on the spot the crop comes +// from, moved by `shift` native pixels down the screen. +const SCENES = [ + { + id: "sidebar", + backdrop: "agents", + title: "Run many coding agents at once", + body: "See which agents are working and which need you.", + iphone: { capture: "sidebar" }, + ipad: { capture: "sidebar" }, + }, + { + id: "chat", + title: "Your full dev environment on your phone", + body: "Start agents and follow every edit and command.", + iphone: { capture: "chat" }, + ipad: { capture: "chat", title: "Your full dev environment on your tablet" }, + }, + { + id: "plugins", + title: "Ask an agent to change the app itself", + body: "This Deploys sheet is a plugin an agent wrote.", + iphone: { + capture: "plugin", + callout: { from: "plugin-sheet", crop: [0, 1340, 1320, 1400], zoom: 1.04, shift: -1400 }, + }, + ipad: { + capture: "plugin", + body: "This Deploys panel is a plugin an agent wrote.", + callout: { from: "plugin-sheet", crop: [670, 1486, 844, 970], zoom: 1.3, shift: -900 }, + }, + }, + { + id: "review", + title: "Review the diff before you merge", + body: "Every file the agent changed, on its own branch.", + iphone: { + capture: "diff", + callout: { from: "files", crop: [0, 1060, 1320, 870], zoom: 1.26, shift: 110 }, + }, + ipad: { capture: "diff" }, + }, + { + id: "terminal", + title: "A real terminal in every workspace", + body: "Run the tests yourself, on your own machine.", + iphone: { + capture: "terminal", + callout: { from: "terminal", crop: [0, 950, 1320, 460], zoom: 1.35, shift: 110 }, + }, + ipad: { + capture: "terminal", + callout: { from: "terminal", crop: [596, 640, 1010, 412], zoom: 1.35, shift: 650 }, + }, + }, +]; + +// `textTop` is where the text starts when the device is sized by the canvas +// height. A canvas too narrow for that, like a tablet on 9:16, sizes the device +// by its width and centres the text and device instead. +const STORES = { + apple: { + device: "iphone", + width: 1320, + height: 2868, + textTop: 230, + titleSize: 100, + bodySize: 46, + textWidth: 1120, + }, + google: { + device: "iphone", + androidScreenshots: "phoneScreenshots", + width: 1440, + height: 2560, + textTop: 150, + titleSize: 96, + bodySize: 44, + textWidth: 1200, + }, + "apple-ipad": { + device: "ipad", + width: 2064, + height: 2752, + textTop: 200, + titleSize: 124, + bodySize: 56, + textWidth: 1400, + }, + "google-tablet": { + device: "ipad", + androidScreenshots: "tenInchScreenshots", + width: 1800, + height: 3200, + textTop: 200, + titleSize: 136, + bodySize: 60, + textWidth: 1560, + }, +}; + +const TITLE_LINE = 1.06; +const BODY_LINE = 1.3; +const BODY_GAP = 30; +// The device starts this far below the text and runs BLEED off the bottom. +const GAP = 140; +const BLEED = 40; +const MARGIN = 56; +const COLORS = { canvas: "#101615", title: "#ffffff", body: "rgba(255, 255, 255, 0.58)" }; + +function findFont() { + const dirs = [process.env.SF_FONT_DIR, "/System/Library/Fonts", "/Library/Fonts"].filter(Boolean); + for (const dir of dirs) { + const file = path.join(dir, "SFNS.ttf"); + if (existsSync(file)) return file; + } + throw new Error("SF Pro not found. Set SF_FONT_DIR to a folder containing SFNS.ttf from macOS."); +} + +// The device fills the canvas from GAP below the text to just past the bottom +// edge, so a taller canvas gets a larger device rather than a larger gap. When +// that would be wider than the canvas, the device takes the full width and the +// text and the whole device sit centred, still GAP apart. +function deviceLayout(store, device) { + const { capture, bezel } = device; + const textHeight = 2 * store.titleSize * TITLE_LINE + BODY_GAP + store.bodySize * BODY_LINE; + const tallest = store.height + BLEED - (store.textTop + textHeight + GAP) - 2 * bezel; + const widest = store.width - 2 * MARGIN - 2 * bezel; + const screenWidth = Math.min(Math.round((tallest * capture.width) / capture.height), widest); + const screenHeight = Math.round((screenWidth * capture.height) / capture.width); + const scale = screenWidth / capture.width; + const width = screenWidth + 2 * bezel; + const height = screenHeight + 2 * bezel; + const textTop = + screenWidth < widest + ? store.height + BLEED - height - GAP - textHeight + : (store.height - height - GAP - textHeight) / 2; + const island = device.island && { + width: Math.round(device.island.width * scale), + height: Math.round(device.island.height * scale), + top: Math.round(device.island.top * scale), + }; + return { + textTop: Math.round(textTop), + left: Math.round((store.width - width) / 2), + top: Math.round(textTop + textHeight + GAP), + width, + height, + bezel, + screenWidth, + screenHeight, + scale, + radius: Math.round(device.radius * scale), + island, + }; +} + +function calloutLayout(callout, frame, store) { + const [cropX, cropY, cropWidth, cropHeight] = callout.crop; + const scale = Math.min(frame.scale * callout.zoom, (store.width - 2 * MARGIN) / cropWidth); + const width = Math.round(cropWidth * scale); + const height = Math.round(cropHeight * scale); + const centreX = frame.left + frame.bezel + (cropX + cropWidth / 2) * frame.scale; + const centreY = frame.top + frame.bezel + (cropY + cropHeight / 2 + callout.shift) * frame.scale; + const clamp = (value, max) => Math.round(Math.min(Math.max(value, MARGIN), max - MARGIN)); + return { + width, + height, + left: clamp(centreX - width / 2, store.width - width), + top: clamp(centreY - height / 2, store.height - height), + }; +} + +async function resize(source, crop, width, height, file) { + let image = sharp(source); + if (crop) image = image.extract({ left: crop[0], top: crop[1], width: crop[2], height: crop[3] }); + await image.resize(width, height, { kernel: "lanczos3" }).png().toFile(file); + return pathToFileURL(file).href; +} + +// Agent marks scattered behind the first panel, from the homepage's featured +// agents. Each entry is [mark, centre x, centre y, size, rotation], with the +// centre as a fraction of the canvas and the size as a fraction of its width. +const AGENT_MARKS = [ + ["ClaudeCodeIcon", 0.11, 0.075, 0.12, -14], + ["CodexIcon", 0.89, 0.1, 0.13, 12], + ["PiIcon", 0.07, 0.34, 0.1, 10], + ["OmpIcon", 0.93, 0.37, 0.11, -10], + ["OpenCodeIcon", 0.08, 0.6, 0.11, -8], + ["CursorIcon", 0.92, 0.63, 0.1, 16], + ["MuseCodeIcon", 0.07, 0.85, 0.11, 14], + ["AntigravityIcon", 0.92, 0.88, 0.12, -12], +]; + +async function loadAgentMarks(work) { + const bundle = path.join(work, "agent-icons.mjs"); + await build({ + entryPoints: [path.join(repo, "packages/website/src/components/agent-icons.tsx")], + outfile: bundle, + bundle: true, + format: "esm", + platform: "node", + jsx: "automatic", + }); + const icons = await import(pathToFileURL(bundle).href); + return Object.fromEntries( + AGENT_MARKS.map(([name]) => [name, renderToStaticMarkup(createElement(icons[name]))]), + ); +} + +function backdrop(store, marks) { + return AGENT_MARKS.map(([name, x, y, size, rotation]) => { + const side = Math.round(size * store.width); + const left = Math.round(x * store.width - side / 2); + const top = Math.round(y * store.height - side / 2); + return `
${marks[name]}
`; + }).join(""); +} + +function page({ store, shot, frame, screen, callout, calloutImage, font, marks }) { + const behind = shot.backdrop === "agents" ? backdrop(store, marks) : ""; + const { island } = frame; + const notch = island + ? `
` + : ""; + const lifted = callout + ? `` + : ""; + return ` + + +${behind} +

${shot.title}

${shot.body}

+
${notch}
+${lifted}`; +} + +async function renderStore(browser, name, store, font, marks, work) { + const dir = path.join(output, name); + await mkdir(dir, { recursive: true }); + const view = await browser.newPage({ + viewport: { width: store.width, height: store.height }, + deviceScaleFactor: 1, + }); + const device = DEVICES[store.device]; + const frame = deviceLayout(store, device); + const files = []; + for (const [index, scene] of SCENES.entries()) { + const stem = `${name}-${scene.id}`; + const shot = { ...scene, ...scene[store.device] }; + const screen = await resize( + raw(store.device, shot.capture), + null, + frame.screenWidth, + frame.screenHeight, + path.join(work, `${stem}-screen.png`), + ); + const callout = shot.callout ? calloutLayout(shot.callout, frame, store) : null; + const calloutImage = callout + ? await resize( + raw(store.device, shot.callout.from), + shot.callout.crop, + callout.width, + callout.height, + path.join(work, `${stem}-callout.png`), + ) + : null; + const html = path.join(work, `${stem}.html`); + await writeFile(html, page({ store, shot, frame, screen, callout, calloutImage, font, marks })); + await view.goto(pathToFileURL(html).href); + await view.evaluate(async () => { + await document.fonts.ready; + await Promise.all(Array.from(document.images, (image) => image.decode())); + }); + await expectLines(view, "h1", 2, `${stem} title`); + await expectLines(view, "p", 1, `${stem} subline`); + const file = path.join(dir, `${String(index + 1).padStart(2, "0")}-${scene.id}.png`); + await sharp(await view.screenshot()) + .removeAlpha() + .png() + .toFile(file); + files.push(file); + console.log(path.relative(repo, file)); + } + await view.close(); + return files; +} + +async function expectLines(view, selector, expected, label) { + const lines = await view.$eval(selector, (element) => + Math.round(element.clientHeight / parseFloat(getComputedStyle(element).lineHeight)), + ); + if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`); +} + +// Google Play and F-Droid read their screenshots from the Android metadata, +// which holds links to these renders. Rewriting the links on every render keeps +// added, removed and renamed panels in step with the App Store. +async function linkAndroidScreenshots(folder, files) { + const dir = path.join(androidImages, folder); + await rm(dir, { recursive: true, force: true }); + await mkdir(dir, { recursive: true }); + for (const [index, file] of files.entries()) { + await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`)); + } +} + +async function renderPreview(rows) { + const tile = 440; + const gap = 40; + const resized = []; + let top = gap; + let width = 0; + for (const files of rows) { + let left = gap; + let rowHeight = 0; + for (const file of files) { + const meta = await sharp(file).metadata(); + const height = Math.round((meta.height * tile) / meta.width); + const input = await sharp(file).resize(tile, height, { kernel: "lanczos3" }).toBuffer(); + resized.push({ input, left, top }); + left += tile + gap; + rowHeight = Math.max(rowHeight, height); + } + width = Math.max(width, left); + top += rowHeight + gap; + } + const file = path.join(output, "preview.png"); + await sharp({ create: { width, height: top, channels: 3, background: "#070a09" } }) + .composite(resized) + .png() + .toFile(file); + console.log(path.relative(repo, file)); +} + +const font = findFont(); +const work = await mkdtemp(path.join(os.tmpdir(), "paseo-store-")); +const browser = await chromium.launch({ headless: true }); +try { + const marks = await loadAgentMarks(work); + const rows = []; + for (const [name, store] of Object.entries(STORES)) { + const files = await renderStore(browser, name, store, font, marks, work); + if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files); + rows.push(files); + } + await renderPreview(rows); +} finally { + await browser.close(); + await rm(work, { recursive: true, force: true }); +} diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 535a6dd712..2c2ce29403 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -12,6 +12,26 @@ initializing → idle → running → idle (or error → closed) Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket. +## Daemon restart recovery + +Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon +reopens the saved provider conversation and settings, then sends `Resume working.`. Change +`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents. +Disabling recovery consumes pending intent; enabling it later does not revive old work. + +Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived +agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle. +Explicit creation environment overrides are saved with the agent and reapplied when reopening. + +Startup chooses candidates before anything else reads or repairs persisted running state, then +opens connections and resumes every candidate in the background. A provider that is slow to reopen +holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before +closing runtimes. + +This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose +or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry +loop or exactly-once turn guarantee. + ## Runtime residency An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider @@ -25,8 +45,54 @@ Reload releases the old runtime before resuming its durable session: an idle pro still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable. -Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle -action such as archive, replacement, reload, workspace teardown, or daemon shutdown. +Idle resumable runtimes are collected after one hour without activity, checked once a minute. +Collection releases the process and preserves the agent record, conversation, relationships, and +provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in +host config to disable automatic collection. The host setting applies immediately; config-file edits +apply on reload. Disabling invalidates pending safety checks and close admission. A close already +started finishes. Re-enabling keeps the existing idle timestamps. + +Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working +managed or native descendants prevent collection. Outstanding local or remote finish subscriptions +also protect their caller, including registration, recovery and notification dispatch acknowledgment. +An idle managed child can still own background work. + +The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false, +errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider +check, the baseline decides collection after the same one-hour idle period. Once protected work +finishes, a fresh idle hour starts. There is no deadline that overrides a veto. + +The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime. +AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown. +Finish subscriptions expose caller protection through their public capability; the collector never +reads their watches or durable records. + +The collector rechecks daemon state after asynchronous probes and event draining, in the existing +close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving +after closure starts waits for release and resumes the saved session. + +Provider checks follow the signals their runtimes expose: + +- Claude uses its process-local background roster and pending completion notifications. A fresh + process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must + demonstrate support by emitting one. Unknown versions stay resident until then. +- Codex queries background terminals for loaded threads, including native children, and protects + pending provider requests. App servers without the terminal-list RPC stay resident. +- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and + inbox inventories. The shared server remains leased while a session vetoes collection. +- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native + subagent signals also protect their parent through the daemon's subagent store. +- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`. + These protect jobs with unsuppressed completion delivery, pending deliveries, queued result + injection, and admitted or scheduled continuations that can re-wake the session. Older OMP + runtimes without either field stay resident. These signals are not an inventory of arbitrary + processes. +- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running + ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not + prevent collection. This does not inspect processes launched internally by the provider. + +These checks do not discover arbitrary detached processes or work hidden by third-party +extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup). A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and @@ -93,8 +159,10 @@ caller, child, and host into one watch. Bootstrap restores these watches after repairing stale running records to `closed` and before opening ingress. Reading a closed child's result does not load its provider. A finish notification reports that -the child's run has settled; the parent decides whether its task is complete. Recovery never resumes -child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop +the child's run has settled; the parent decides whether its task is complete. A child chosen for restart +recovery is observed only after its resume was sent, so the parent never hears that it was closed +in between. Archived or deleted parents suppress delivery; +parent-owned creation watches also stop reporting after detachment. Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure @@ -242,7 +310,7 @@ parentAgentId === thisAgent.id AND !archivedAt - **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. -Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. +Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) instead, replacing the subagents list in place (Back returns to the list); the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. diff --git a/docs/android.md b/docs/android.md index 5a59eedaa4..17bafd6b24 100644 --- a/docs/android.md +++ b/docs/android.md @@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted ## Local build + install +The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after +rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the +resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation +regression renders real gradient pixels across growing and shrinking layouts, including a nested +group. From `packages/app/android`, run it on your selected emulator: + +```bash +ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \ + --no-daemon --max-workers=2 -Dorg.gradle.parallel=false +``` + The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`. It backports [Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing @@ -98,7 +110,7 @@ The older `android`, `android:development`, and `ios` commands remain aliases fo `android:production` still builds and installs a release through Expo; use `build:apk` when you need an artifact. `android:clear` removes the generated Android project. -Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain. +Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests. ### Diagnostic logs without ADB @@ -215,7 +227,8 @@ fastlane/metadata/android/ │ ├── images/ │ │ ├── icon.png (512x512) │ │ ├── featureGraphic.png (1024x500) -│ │ └── phoneScreenshots/ (1.png, 2.png, ...) +│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders) +│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders) │ └── changelogs/ (generated — see below) ├── ja/ └── zh-CN/ @@ -223,6 +236,8 @@ fastlane/metadata/android/ Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`. +Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots). + F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result. One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes. diff --git a/docs/architecture.md b/docs/architecture.md index 29f9c7f817..a7ad69c14d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,6 +66,9 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf retaining the daemon's runtime and wire-schema allocations. The server export tests guard these dependency trees with tree-shaking disabled to match unbundled production imports. +Terminals do not live in the daemon process: their PTYs run in a detached terminal worker that +survives daemon restarts, crashes and upgrades. See [terminal-worker.md](terminal-worker.md). + Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's project Git observer keeps one non-recursive watch on each lexically equivalent active project root and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs @@ -490,6 +493,16 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear 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). +## Outbound networking + +The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY` +(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets +share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct. +A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes +are treated as unset, with one warning naming the variable in `daemon.log`. +Agent and terminal environments are unchanged. System proxy settings and SOCKS-only +`ALL_PROXY` configurations are not supported. + ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` diff --git a/docs/connected-hosts.md b/docs/connected-hosts.md index 20c86c1295..7e8b67ee62 100644 --- a/docs/connected-hosts.md +++ b/docs/connected-hosts.md @@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`. A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local -operations; requesting this feature gives update guidance. +operations; requesting this feature gives update guidance. The registry retries an older destination +with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and +authentication failures stay in `error` until an update changes the connection, `enabled`, or +`replaceIdentity`, or A restarts. Disposing a routed API releases its observations and session. It does not remove the saved host or affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore diff --git a/docs/custom-providers.md b/docs/custom-providers.md index a6c6cefbdc..1d6d67d453 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -65,6 +65,7 @@ per-agent examples and provider-specific keys. ## Table of Contents - [Extending a built-in provider](#extending-a-built-in-provider) +- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server) - [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan) - [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan) - [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) @@ -107,6 +108,35 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai --- +## OpenCode with an existing v2 server + +Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment: + +```json +{ + "agents": { + "providers": { + "opencode": { + "env": { + "PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096", + "PASEO_OPENCODE_SERVER_PASSWORD": "" + } + } + } + } +} +``` + +`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`. + +A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server. + +An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package. + +OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service. + +--- + ## Z.AI (Zhipu) coding plan [Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API. @@ -232,7 +262,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. -Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. +Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. The generated Codex provider key is `paseo:`, so a Paseo provider such as `ollama` does not override a Codex built-in. Existing custom sessions created with a non-built-in provider key remain available in Import session. Native Codex conversations using a built-in key stay separate from a custom provider of the same name. ### Setup @@ -260,9 +290,9 @@ Paseo passes those variables through to the Codex app-server process **and** map Under the hood, for each custom Codex provider Paseo injects this into Codex's config: ```toml -model_provider = "my-codex" +model_provider = "paseo:my-codex" -[model_providers.my-codex] +[model_providers."paseo:my-codex"] name = "My Codex" base_url = "https://custom-relay.example.com/v1" wire_api = "responses" diff --git a/docs/data-model.md b/docs/data-model.md index 8bed344aa1..dc65096b66 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -221,12 +221,6 @@ snapshot so a mixed edit can apply its live subset and still name the paths that } }, providers: { - openai: { - apiKey?: string, - baseUrl?: string, - stt?: { apiKey?: string, baseUrl?: string }, - tts?: { apiKey?: string, baseUrl?: string } - }, local: { modelsDir: string } }, agents: { @@ -244,8 +238,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that pluginsEnabled: boolean, plugins: Record, features: { - dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, - voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } } + dictation: { enabled, stt: SpeechSttConfig }, + voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig } }, log: { level, format, @@ -353,42 +347,27 @@ Environment variables override `config.json`: after changing `config.json`. Environment changes require a daemon restart; the launch environment remains authoritative during reload. -Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model. +### Speech configuration -Set these to select OpenAI instead of local speech: +Each use site owns its provider settings: `features.dictation.stt`, +`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict +`local` or `openai` variant. Both STT variants carry `language`; normalization preserves +it regardless of provider. `providers.local.modelsDir` remains shared storage. +See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence. -| Env var | Applies to | -| ------------------------------ | ------------------------------- | -| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider | -| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider | -| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider | +On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs +into OpenAI sites that lack their own values, using endpoint settings before shared +settings. A provider-less site becomes local; provider names are lower-cased, and +fields ignored by the selected speech provider are discarded. The next save writes only the +inline shape. A legacy key with no site to receive it is discarded with a warning +that names its path, without logging its value. -OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints: - -```json -{ - "providers": { - "openai": { - "stt": { - "apiKey": "sk-...", - "baseUrl": "https://stt.example.com/v1" - }, - "tts": { - "apiKey": "sk-...", - "baseUrl": "https://api.openai.com/v1" - } - } - } -} -``` - -`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools. - -Paseo uses these paths under the configured OpenAI base URL: - -- dictation STT: `/v1/audio/transcriptions` -- voice mode STT: `/v1/audio/transcriptions` -- voice mode TTS: `/v1/audio/speech` +`speech-settings.ts` owns mutable config reads and patches. The wire uses the same +`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config) +and report effective env sources in `envOverrides`. A site patch that changes +`provider` replaces the entire object. The same or omitted `provider` merges fields +into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and +options from the previous variant. --- diff --git a/docs/design.md b/docs/design.md index ce45577b69..a336b80a64 100644 --- a/docs/design.md +++ b/docs/design.md @@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs. -Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a ` +
+ ) : null} + {showEmptyState ? : null} + + ), + [showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle], + ); return ( - - {showFilter ? ( - - - {selectedProvider === ALL_FILTER_VALUE ? ( - - ) : ( - - )} - - {selectedProviderLabel} - - - - - - ) : null} - 0} - importErrored={importMutation.isError} - /> - {providerErrorRows.length > 0 ? ( - - ) : null} - {visibleEntries.length > 0 ? ( - - ) : null} - {showLoadMore ? ( - - - - ) : null} - {showEmptyState ? : null} - + listHeader={listHeader} + listFooter={listFooter} + serverId={serverId} + entries={visibleEntries} + disabled={importMutation.isPending} + importingSessionKey={importingSessionKey} + resolveFolder={resolveFolder} + onImportSession={handleImportSession} + /> ); } @@ -853,9 +973,31 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, }, + listBody: { + gap: 0, + }, list: { + flex: 1, + minHeight: 0, + marginHorizontal: -theme.spacing[2], + }, + listHeader: { + marginHorizontal: theme.spacing[2], + paddingBottom: theme.spacing[4], + }, + listHeaderContent: { + gap: theme.spacing[4], + }, + smallList: { gap: theme.spacing[1], }, + listFooter: { + marginHorizontal: theme.spacing[2], + }, + listRow: { + marginHorizontal: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, footer: { paddingTop: theme.spacing[2], alignItems: "center", diff --git a/packages/app/src/components/indent-rail.tsx b/packages/app/src/components/indent-rail.tsx new file mode 100644 index 0000000000..36fd84a0a9 --- /dev/null +++ b/packages/app/src/components/indent-rail.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * Align the rail with the expanded caret's left edge. Keep the content offset fixed so + * expanded tool cards stay in place, with a gap between their border and the rail. + */ +const RAIL_LEFT = 1; +const RAIL_WIDTH = 1; +export const INDENT_RAIL_CONTENT_OFFSET = 21; + +/** Indents opened chat rows under the summary that collapsed them, behind a vertical line. */ +export function IndentRail({ + children, + style, +}: { + children?: ReactNode; + style?: StyleProp; +}) { + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + rail: { + marginLeft: RAIL_LEFT, + paddingLeft: INDENT_RAIL_CONTENT_OFFSET - RAIL_LEFT - RAIL_WIDTH, + borderLeftWidth: RAIL_WIDTH, + borderLeftColor: theme.colors.border, + }, +})); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index dbc501b5c7..983d142beb 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -64,12 +64,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; -import { - UsageSidebarItem, - UsageSidebarRoot, - useHasUsageSummary, - useOpenSidebarUsage, -} from "@/usage"; +import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; @@ -480,10 +475,10 @@ function SidebarUsageIcon({ label }: { label: string }) { */ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) { const { items } = useSidebarNavItems("footer"); - const hasUsageSummary = useHasUsageSummary(); + const usageSummary = useUsageSummary(); const rowsRef = useRef(null); const visibleItems = items.filter( - (item) => item.visible && (item.kind === "plugin" || hasUsageSummary), + (item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0), ); if (visibleItems.length === 0) return null; return ( @@ -499,7 +494,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void onBeforeNavigate={onBeforeNavigate} /> ) : ( - + ), )} diff --git a/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx b/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx index 8b68ae61cb..02ca7108d8 100644 --- a/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/fullscreen-viewer.web.tsx @@ -1,5 +1,6 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Modal, View } from "react-native"; +import { View } from "react-native"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; diff --git a/packages/app/src/components/markdown/fence/mermaid/host.native.tsx b/packages/app/src/components/markdown/fence/mermaid/host.native.tsx index 001ec5cf1c..5f86efe05c 100644 --- a/packages/app/src/components/markdown/fence/mermaid/host.native.tsx +++ b/packages/app/src/components/markdown/fence/mermaid/host.native.tsx @@ -1,6 +1,6 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - Modal, Pressable, StyleSheet as RNStyleSheet, ScrollView, diff --git a/packages/app/src/components/markdown/images.ts b/packages/app/src/components/markdown/images.ts index 83b24b481b..e2f8d07992 100644 --- a/packages/app/src/components/markdown/images.ts +++ b/packages/app/src/components/markdown/images.ts @@ -35,11 +35,15 @@ export interface MarkdownImageContext { filePath?: string; } -export function useMarkdownSource(text: string, context: MarkdownImageContext) { +export function useMarkdownSource( + text: string, + context: MarkdownImageContext, + commandNames?: ReadonlySet, +) { const occurrenceKey = useId(); const prepared = useMemo( - () => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }), - [text, context.workspaceRoot], + () => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }), + [text, context.workspaceRoot, commandNames], ); const sources = [...new Set(prepared.images.map((image) => image.source))]; const acquisitions = sources.map((source) => { diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 95590470c6..4256685f9c 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -2,6 +2,8 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks"; +import { matchCommandLink } from "@/agent-commands"; + import { classifyAssistantFileLink } from "@/assistant-file-links"; import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; @@ -13,6 +15,28 @@ import { createMarkdownStyles } from "./styles"; import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source"; import { normalizeMarkdownHtml } from "./html"; +it("recognizes complete known commands in inline code", () => { + const names = new Set(["code-review", "review:fast"]); + const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, { + commandNames: names, + }); + expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true); + expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe( + prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"), + ); + const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names }); + expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true); + expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review"); +}); + +it("resolves authored root-relative links only when the target is a known command", () => { + const names = new Set(["code-review"]); + expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review"); + expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull(); + expect(matchCommandLink("/code-review", "text", names)).toBeNull(); + expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull(); +}); + describe("shared Markdown source adaptation", () => { it("keeps paragraphs, fences and quotes together for the library", () => { const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote"; @@ -804,3 +828,18 @@ it("makes the web Markdown container selectable in Safari as well as unprefixed WebkitUserSelect: "text", }); }); + +it.each([false, true])( + "scales inline code from theme tokens with its surrounding block (compact: %s)", + (compact) => { + const theme = { + ...darkTheme, + fontSize: { ...darkTheme.fontSize, code: 13, content: 17, base: 16 }, + }; + const { markdownStyle } = createMarkdownStyles(theme, { compact, blockGap: 16 }); + const bodySize = compact ? theme.fontSize.base : theme.fontSize.content; + expect(markdownStyle.code).not.toHaveProperty("fontSize"); + expect(markdownStyle.code).toHaveProperty("fontScale", theme.fontSize.code / bodySize); + expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code); + }, +); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 6d79beb01f..c14ecb5198 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -6,6 +6,7 @@ import { useAssistantFileLinkEnvironment, } from "@/assistant-file-links"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands"; import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import type { Theme } from "@/styles/theme"; import { isWeb } from "@/constants/platform"; @@ -23,16 +24,32 @@ const ThemedMarkdown = withUnistyles(EnrichedMarkdownText); const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true }; // Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it. -const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: string }) => - createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) }); +const markdownStyle = + (compact: boolean, muted = false) => + (theme: Theme, rt: { breakpoint?: string }) => + createMarkdownStyles(theme, { + compact, + muted, + blockGap: resolveDensity(rt, density.blockGap), + }); const regularStyle = markdownStyle(false); const compactStyle = markdownStyle(true); +const compactMutedStyle = markdownStyle(true, true); + +function pickMarkdownStyle(compact: boolean, muted: boolean) { + if (!compact) return regularStyle; + return muted ? compactMutedStyle : compactStyle; +} export interface MarkdownRendererProps extends MarkdownImageContext { text: string; compact?: boolean; + /** Muted text colour. Compact surfaces only. */ + muted?: boolean; + selectable?: boolean; streaming?: boolean; onLinkPress?: (url: string) => unknown; + agentCommands?: boolean; } /** All Markdown surfaces share the same engine and source adaptation. */ @@ -44,22 +61,32 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe () => splitStreamingMarkdown(completeLines, true).head.length, [completeLines], ); - if (!streaming || boundary === 0) return ; + const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart; + if (!streaming || boundary === 0) return ; return ( - - + + ); }); +const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) { + const commandHints = useAgentCommandHints(); + return ; +}); + const MarkdownPart = memo(function MarkdownPart({ text, compact = false, + muted = false, + selectable = true, streaming = false, onLinkPress, + agentCommands: _agentCommands, + commandHints = null, ...imageContext -}: MarkdownRendererProps) { +}: MarkdownRendererProps & { commandHints?: CommandHints | null }) { const environment = useAssistantFileLinkEnvironment(); const actions = useAssistantFileLinkActions(); const client = @@ -67,14 +94,23 @@ const MarkdownPart = memo(function MarkdownPart({ (environment.client?.readFile ? { readFile: environment.client.readFile.bind(environment.client) } : undefined); - const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, { - workspaceRoot: environment.workspaceRoot, - serverId: environment.serverId, - ...imageContext, - client, - }); + const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource( + text, + { + workspaceRoot: environment.workspaceRoot, + serverId: environment.serverId, + ...imageContext, + client, + }, + commandHints?.names, + ); const [image, setImage] = useState(null); const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => { + const command = commandHints && matchCommandLink(url, sourceType, commandHints.names); + if (command) { + commandHints.insert(command); + return; + } const source = linkSource(url, sourceType); if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url); else actions.open(source, "preferred"); @@ -98,9 +134,9 @@ const MarkdownPart = memo(function MarkdownPart({ ) { }; } -function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet { +function collectInlineCodePaths( + source: string, + workspaceRoot?: string, + commandNames?: ReadonlySet, +): ReadonlySet { const paths = new Set(); const previousEnds = new Map(); // Collect candidates between consecutive runs of equal length. The engine owns @@ -74,7 +78,11 @@ function collectInlineCodePaths(source: string, workspaceRoot?: string): Readonl value = value.slice(1, -1); } const classification = classifyAssistantFileLink(value, { workspaceRoot }); - if (classification && classification.kind !== "external") paths.add(value); + if ( + (classification && classification.kind !== "external") || + (value.startsWith("/") && commandNames?.has(value.slice(1))) + ) + paths.add(value); } return paths; } @@ -112,7 +120,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string) export function prepareMarkdownSource( source: string, workspaceRoot?: string, - options: { supportsSvg?: boolean } = {}, + options: { supportsSvg?: boolean; commandNames?: ReadonlySet } = {}, ) { source = normalizeMarkdownHtml(source); const images: MarkdownImage[] = []; @@ -122,7 +130,9 @@ export function prepareMarkdownSource( extensions: [gfmTable()], mdastExtensions: [gfmTableFromMarkdown()], })); - const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot)); + const metadata = linkMetadata( + collectInlineCodePaths(source, workspaceRoot, options.commandNames), + ); const edits: { start: number; end: number; text: string }[] = []; if ( !source.includes("![") && diff --git a/packages/app/src/components/markdown/styles.ts b/packages/app/src/components/markdown/styles.ts index faf388a9dc..a06104dd10 100644 --- a/packages/app/src/components/markdown/styles.ts +++ b/packages/app/src/components/markdown/styles.ts @@ -7,7 +7,11 @@ const WEB_CONTAINER = { userSelect: "text", WebkitUserSelect: "text" } as const; export function createMarkdownStyles( theme: Theme, - { compact = false, blockGap }: { compact?: boolean; blockGap: number }, + { + compact = false, + muted = false, + blockGap, + }: { compact?: boolean; muted?: boolean; blockGap: number }, ): { markdownStyle: MarkdownStyle; selectionColor: string; containerStyle?: typeof WEB_CONTAINER } { const fontSize = compact ? theme.fontSize.base : theme.fontSize.content; // Every block is followed by the same gap; the library drops it after the last block. @@ -16,7 +20,7 @@ export function createMarkdownStyles( fontSize, fontFamily: theme.fontFamily.ui, lineHeight: Math.round(fontSize * 1.4), - color: theme.colors.foreground, + color: muted ? theme.colors.foregroundMuted : theme.colors.foreground, ...block, }; const heading = (scale: number) => ({ @@ -68,7 +72,7 @@ export function createMarkdownStyles( }, code: { fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono, - fontSize: theme.fontSize.code, + fontScale: theme.fontSize.code / fontSize, color: theme.colors.foreground, backgroundColor: theme.colors.surface2, borderColor: "transparent", diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index d5a2244505..0ec5cbfb44 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,3 +1,4 @@ +import { MessagePreview, MessageSender } from "@/components/user-message"; import { useStableEvent } from "@/hooks/use-stable-event"; import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu"; @@ -26,6 +27,7 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; +import { useToolCallExpanded } from "@/tool-calls/expansion-context"; import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentMessage } from "@getpaseo/protocol/agent-message"; @@ -89,6 +91,7 @@ export type { InlinePathTarget } from "@/assistant-file-links"; export type { AssistantForkTarget }; interface UserMessageProps { + sender?: AgentMessage["sender"]; serverId?: string; agentId?: string; messageId?: string; @@ -387,6 +390,7 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage const MESSAGE_TEXT_DATASET = { messageText: "true" }; export const UserMessage = memo(function UserMessage({ + sender, serverId, agentId, messageId, @@ -473,42 +477,45 @@ export const UserMessage = memo(function UserMessage({ onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} > + {sender ? : null} - {hasImages ? ( - - {images.map((image) => ( - - ))} - - ) : null} - {hasAttachments ? ( - - {attachments.map((attachment, index) => { - const content = getAgentAttachmentPillContent(attachment, t); - return ( - - - - ); - })} - - ) : null} - {hasText ? ( - - {message} - - ) : null} + + {hasImages ? ( + + {images.map((image) => ( + + ))} + + ) : null} + {hasAttachments ? ( + + {attachments.map((attachment, index) => { + const content = getAgentAttachmentPillContent(attachment, t); + return ( + + + + ); + })} + + ) : null} + {hasText ? ( + + {message} + + ) : null} + {hasText ? ( string; + getContent?: () => string; completedAt?: Date; durationMs?: number | null; onFork?: (target: AssistantForkTarget) => Promise | void; @@ -641,10 +648,12 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ return ( - + {getContent ? ( + + ) : null} {canFork ? : null} {primaryLabel ? ( ({ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, + // Details that do not sit in a card leave the header as a lone highlighted pill. + pressableExpandedDetached: { + borderRadius: theme.borderRadius.full, + }, detailWrapperBorderless: { borderWidth: 0, }, @@ -1186,6 +1199,7 @@ export const AssistantMessage = memo(function AssistantMessage({ > !prev); + toggleExpanded(); } }, [ shouldRenderInline, + toggleExpanded, openToolCall, toolName, presentation.detail, @@ -2413,5 +2440,7 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) { if (previous.defaultExpanded !== next.defaultExpanded) return false; if (previous.forceInline !== next.forceInline) return false; if (previous.maxDetailHeight !== next.maxDetailHeight) return false; + if (previous.itemId !== next.itemId) return false; + if (previous.standalone !== next.standalone) return false; return true; } diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index 17924b233d..2ca6b6a9c2 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0, }, emptyState: { - paddingVertical: theme.spacing[4], + padding: theme.spacing[4], alignItems: "center", gap: theme.spacing[2], }, diff --git a/packages/app/src/components/question-form-card.browser.test.tsx b/packages/app/src/components/question-form-card.browser.test.tsx index 83120931d1..0b2a5df25d 100644 --- a/packages/app/src/components/question-form-card.browser.test.tsx +++ b/packages/app/src/components/question-form-card.browser.test.tsx @@ -10,6 +10,12 @@ import { QuestionFormCard } from "./question-form-card"; // Load translations so controls expose their real accessible names. void testI18n; +// The Markdown engine does not load in this harness; question and option text are covered by +// e2e/browser/question-prompt-pagination.spec.ts. Plain text keeps these tests on the answers. +vi.mock("@/components/markdown/renderer", () => ({ + MarkdownRenderer: ({ text }: { text: string }) => {text}, +})); + // App sources compile against the classic JSX runtime, which expects React on the global. beforeEach(() => vi.stubGlobal("React", React)); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 7014de89d1..8ff81963d1 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,7 +1,13 @@ import { Alert } from "@/components/ui/alert"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useState, useCallback, useMemo, useRef, type RefObject } from "react"; -import { View, Text, Pressable, type PressableStateCallbackType } from "react-native"; +import { + View, + Text, + Pressable, + type GestureResponderEvent, + type PressableStateCallbackType, +} from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, X } from "lucide-react-native"; @@ -12,6 +18,7 @@ import { isWeb } from "@/constants/platform"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input/types"; import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; import { areQuestionsAnswered, buildQuestionFormAnswers, @@ -36,6 +43,20 @@ const IS_WEB = isWeb; // drag to the preview's nested scroll, so lines past a cap would be unreachable. const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined; +// Web clicks that must not answer: a link in the label, anything in the description, and the +// second click of a double-click, which would otherwise land on the next question's options. +function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean { + if (!IS_WEB) return false; + const click = event.nativeEvent as unknown as MouseEvent; + if (click.detail > 1) return true; + const target = click.target as Element | null; + const descriptionElement = description as unknown as Element | null; + return Boolean(target?.closest("a") || descriptionElement?.contains(target)); +} + +// The description is selectable text, so a touch on it belongs to the text, not the option. +const claimResponder = () => true; + function getQuestionInputPlaceholder({ question, answerPlaceholder, @@ -71,9 +92,14 @@ function QuestionOptionRow({ }: QuestionOptionRowProps) { const { theme } = useUnistyles(); - const handlePress = useCallback(() => { - onToggle(qIndex, optIndex, multiSelect); - }, [onToggle, qIndex, optIndex, multiSelect]); + const descriptionRef = useRef(null); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + if (isNonAnsweringClick(event, descriptionRef.current)) return; + onToggle(qIndex, optIndex, multiSelect); + }, + [onToggle, qIndex, optIndex, multiSelect], + ); const pressableStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ @@ -86,17 +112,6 @@ function QuestionOptionRow({ [isSelected, theme.colors.surface2], ); - const optionLabelStyle = useMemo( - () => [ - styles.optionLabel, - { color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted }, - ], - [isSelected, theme.colors.foreground, theme.colors.foregroundMuted], - ); - const optionDescriptionStyle = useMemo( - () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]); // Static left-side control: square for multi-select, circle for single-select. @@ -135,9 +150,15 @@ function QuestionOptionRow({ {isSelected && !multiSelect ? : null} - {option.label} + {option.description ? ( - {option.description} + + + ) : null} {option.preview ? ( // Shown for every option at once so they can be compared before picking. @@ -522,10 +543,6 @@ export function QuestionFormCard({ ], [theme.colors.surface1, theme.colors.border], ); - const questionTextStyle = useMemo( - () => [styles.questionText, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); // Single-select radios need a group; checkboxes are valid standalone. const optionsGroupAccessibility = useMemo( () => @@ -570,9 +587,9 @@ export function QuestionFormCard({ onSelect={handleSelectQuestion} /> - - {activeQuestion?.question} - + + + {activeQuestion ? ( @@ -675,9 +692,6 @@ const styles = StyleSheet.create((theme) => ({ }, questionText: { flex: 1, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - lineHeight: 22, }, optionsWrap: { gap: theme.spacing[1], @@ -723,15 +737,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, gap: theme.spacing[1], }, - optionLabel: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - lineHeight: 22, - }, - optionDescription: { - fontSize: theme.fontSize.base, - lineHeight: 20, - }, optionPreviewText: { lineHeight: 20, ...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null), diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx index 6576728384..c3930688c2 100644 --- a/packages/app/src/components/resize-handle.tsx +++ b/packages/app/src/components/resize-handle.tsx @@ -49,7 +49,7 @@ export function ResizeHandle({ const { theme } = useUnistyles(); const finePointer = useHasFinePointer(); const pointerStatesRef = useRef(new Map()); - const touchDragRef = useRef(null); + const touchStateRef = useRef(null); const cursorBeforeDragRef = useRef(null); const hoverTimerRef = useRef | null>(null); const [active, setActive] = useState(false); @@ -151,23 +151,30 @@ export function ResizeHandle({ const touchGesture = useMemo(() => { const gesture = Gesture.Pan() .runOnJS(true) - .onBegin(() => setDragging(true)) - .onStart(() => { - touchDragRef.current = startResizeHandleDrag({ - sizes, - index, - preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes), - commit: (nextSizes) => onResizeSplit(groupId, nextSizes), - }); + .onBegin((event) => { + setDragging(true); + touchStateRef.current = { + containerSize, + pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY, + drag: startResizeHandleDrag({ + sizes, + index, + preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes), + commit: (nextSizes) => onResizeSplit(groupId, nextSizes), + }), + }; }) .onUpdate((event) => { - if (containerSize <= 0) return; - const translation = direction === "horizontal" ? event.translationX : event.translationY; - touchDragRef.current?.move(translation / containerSize); + const state = touchStateRef.current; + if (!state || state.containerSize <= 0) return; + // Gesture Handler resets web translation when the pan activates. Measure from + // the initial press so the activation distance is included on every platform. + const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY; + state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize); }) - .onEnd(() => touchDragRef.current?.finish()) + .onEnd(() => touchStateRef.current?.drag.finish()) .onFinalize(() => { - touchDragRef.current = null; + touchStateRef.current = null; setDragging(false); }); diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index d482bb2d42..f6d3d0325c 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -1,3 +1,4 @@ +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; @@ -42,8 +43,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { : undefined; if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable")); await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, { - direction: "tail", - projection: "projected", + ...planTimelineTailFetch(), ...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}), }); } diff --git a/packages/app/src/components/settings/collapsible-row.tsx b/packages/app/src/components/settings/collapsible-row.tsx index 219c19776c..d219c3abd5 100644 --- a/packages/app/src/components/settings/collapsible-row.tsx +++ b/packages/app/src/components/settings/collapsible-row.tsx @@ -1,20 +1,18 @@ -import { ChevronRight } from "lucide-react-native"; import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { mutedIconColorMapping } from "@/components/ui/icon-color"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; import { settingsStyles } from "@/styles/settings"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; - -const ThemedChevron = withUnistyles(ChevronRight); -const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +import { NestedSettingsRows } from "./row-density"; +import { SettingsRowHeader } from "./row-header"; interface SettingsCollapsibleRowProps { label: string; + /** A short status shown before the chevron, so the row says something while collapsed. */ + summary?: string; defaultExpanded?: boolean; testID?: string; - /** The rows revealed underneath, separated like the rows of a `SettingsCard`. */ + /** The rows revealed underneath, separated like the rows of a `SettingsCard` and laid out as nested rows. */ children: ReactNode; } @@ -25,6 +23,7 @@ interface SettingsCollapsibleRowProps { */ export function SettingsCollapsibleRow({ label, + summary, defaultExpanded = false, testID, children, @@ -37,66 +36,39 @@ export function SettingsCollapsibleRow({ () => (isWeb ? ({ "aria-expanded": expanded } as const) : null), [expanded], ); - const headerStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - settingsStyles.row, - hovered && styles.headerHovered, - pressed && styles.headerPressed, - ], - [], - ); - const chevronStyle = useMemo( - () => [styles.chevron, expanded && styles.chevronExpanded], - [expanded], - ); return ( - - {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( - <> - - {label} - - - - - - )} - - {expanded - ? Children.toArray(children).map((child, index) => ( - - {child} - - )) - : null} + /> + {expanded ? ( + + + {Children.toArray(children).map((child, index) => ( + + {child} + + ))} + + + ) : null} ); } const styles = StyleSheet.create((theme) => ({ - headerHovered: { - backgroundColor: theme.colors.surface2, - }, - headerPressed: { - backgroundColor: theme.colors.surface3, - }, - // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this - // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. - chevron: { marginRight: -theme.spacing[1] }, - chevronExpanded: { - transform: [{ rotate: "90deg" }], + // The revealed rows keep the card's rails; the tint is what groups them under their header. + children: { + backgroundColor: theme.colors.surface0, }, })); diff --git a/packages/app/src/components/settings/detail-row.tsx b/packages/app/src/components/settings/detail-row.tsx new file mode 100644 index 0000000000..f256babe50 --- /dev/null +++ b/packages/app/src/components/settings/detail-row.tsx @@ -0,0 +1,33 @@ +import { SettingsRowHeader } from "./row-header"; + +interface SettingsDetailRowProps { + label: string; + /** What the detail is set to, so the row says it without being opened. */ + summary?: string; + onPress: () => void; + /** A row that cannot be opened keeps its summary and drops the chevron. */ + disabled?: boolean; + testID?: string; +} + +/** + * A settings row that opens a detail, such as a sheet, for one setting. The label, a one-line + * summary of the current value and a chevron pointing right. Sits inside a `SettingsCard`. + */ +export function SettingsDetailRow({ + label, + summary, + onPress, + disabled, + testID, +}: SettingsDetailRowProps) { + return ( + + ); +} diff --git a/packages/app/src/components/settings/headings/settings-info-tip.tsx b/packages/app/src/components/settings/headings/settings-info-tip.tsx index c8d36df74f..7242247583 100644 --- a/packages/app/src/components/settings/headings/settings-info-tip.tsx +++ b/packages/app/src/components/settings/headings/settings-info-tip.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ComponentType, ReactNode } from "react"; import { Pressable, Text } from "react-native"; import { useTranslation } from "react-i18next"; import { Info } from "lucide-react-native"; @@ -6,7 +6,13 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -const ThemedInfo = withUnistyles(Info); +type TipIconComponent = ComponentType<{ size: number; color: string }>; + +function TipIcon({ icon: Icon, color }: { icon: TipIconComponent; color: string }) { + return ; +} + +const ThemedTipIcon = withUnistyles(TipIcon); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -14,27 +20,37 @@ interface SettingsInfoTipProps { /** Header the tip belongs to; used for the accessibility label. */ title: string; info: ReactNode; + /** Defaults to the info icon. */ + icon?: TipIconComponent; + /** Defaults to "About {title}". */ + accessibilityLabel?: string; testID?: string; } /** * The one way a settings group or section explains itself: an info icon on the * header that opens a tooltip. Never a paragraph under the header — see - * docs/design.md §7. + * docs/design.md §7. Rows use it the same way to keep secondary details on one line. */ -export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) { +export function SettingsInfoTip({ + title, + info, + icon = Info, + accessibilityLabel, + testID, +}: SettingsInfoTipProps) { const { t } = useTranslation(); return ( - + diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx index f31a0e7b38..567b13bd4e 100644 --- a/packages/app/src/components/settings/index.tsx +++ b/packages/app/src/components/settings/index.tsx @@ -24,15 +24,23 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/component import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; +import { ControlSizeProvider } from "@/components/ui/control-size"; +import { useSettingsRowLayout } from "./row-density"; export { SettingsGroup } from "./headings/settings-group"; export { SettingsSection } from "./headings/settings-section"; export { SettingsCollapsibleRow } from "./collapsible-row"; +export { SettingsDetailRow } from "./detail-row"; +export { SettingsInfoTip } from "./headings/settings-info-tip"; -interface AppSettingsRowProps extends Omit { - hint?: ReactNode; +/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */ +interface AppRowExtras { labelAccessory?: ReactNode; } +interface AppSettingsRowProps extends Omit, AppRowExtras { + hint?: ReactNode; +} + export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) { return ( @@ -57,26 +65,32 @@ export function SettingsRow({ testID, }: AppSettingsRowProps) { const compact = useIsCompactFormFactor(); - const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]); + const layout = useSettingsRowLayout(); + const rowStyle = useMemo(() => [layout.row, compact && styles.compactRow], [compact, layout]); + const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]); return ( {labelAccessory ? ( - {label} + {label} {labelAccessory} ) : ( - {label} + {label} )} - {typeof hint === "string" ? {hint} : hint} + {typeof hint === "string" ? {hint} : hint} {error ? ( {error} ) : null} - {children ? {children} : null} + {children ? ( + + {children} + + ) : null} ); } @@ -117,7 +131,7 @@ export function SettingsSelect({ onValueChange, disabled, ...row -}: SettingsSelectProps) { +}: SettingsSelectProps & AppRowExtras) { const selectedLabel = options.find((option) => option.value === value)?.label ?? value; return ( @@ -152,7 +166,7 @@ export function SettingsInput({ secureTextEntry, ref, ...row -}: SettingsInputProps) { +}: SettingsInputProps & AppRowExtras) { const compact = useIsCompactFormFactor(); const input = useRef(null); useImperativeHandle( @@ -175,7 +189,7 @@ export function SettingsInput({ editable={!disabled} secureTextEntry={secureTextEntry} accessibilityLabel={row.label} - size={compact ? "md" : "sm"} + size={compact ? "md" : undefined} style={styles.input} /> @@ -185,7 +199,7 @@ export function SettingsInput({ export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) { return ( - diff --git a/packages/app/src/components/settings/row-density.browser.test.tsx b/packages/app/src/components/settings/row-density.browser.test.tsx new file mode 100644 index 0000000000..d2742e3172 --- /dev/null +++ b/packages/app/src/components/settings/row-density.browser.test.tsx @@ -0,0 +1,77 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { Button } from "@/components/ui/button"; +import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings"; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +function mountRows() { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root?.render( + + + + + + + + + + + , + ), + ); +} + +function element(testID: string): HTMLElement { + const found = document.querySelector(`[data-testid="${testID}"]`); + if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`); + return found; +} + +function heightOf(testID: string): number { + return element(testID).getBoundingClientRect().height; +} + +function titleFontSize(rowTestID: string, label: string): number { + const title = [...element(rowTestID).querySelectorAll("div")].find( + (node) => node.textContent === label && node.children.length === 0, + ); + if (!title) throw new Error(`No title ${label}`); + return Number.parseFloat(getComputedStyle(title).fontSize); +} + +describe("settings row density", () => { + it("lines up nested rows with and without a button, tighter than card rows", () => { + mountRows(); + + expect(heightOf("nested-with-button")).toBe(heightOf("nested-without-button")); + expect(heightOf("nested-with-button")).toBeLessThan(heightOf("card-row")); + expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button")); + }); + + it("sizes nested text and controls one step below card rows", () => { + mountRows(); + + expect(heightOf("nested-button")).toBeLessThan(heightOf("card-button")); + expect(titleFontSize("nested-with-button", "Nested with a button")).toBeLessThan( + titleFontSize("card-row", "Card row"), + ); + }); +}); diff --git a/packages/app/src/components/settings/row-density.tsx b/packages/app/src/components/settings/row-density.tsx new file mode 100644 index 0000000000..d4bcdce33e --- /dev/null +++ b/packages/app/src/components/settings/row-density.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry"; +import type { ContainedControlSize } from "@/components/ui/control-size"; +import { settingsStyles } from "@/styles/settings"; + +// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows +// of the card: tighter padding, smaller text, smaller controls. The container decides; a row +// never knows on its own that it is nested. +const NestedRowsContext = createContext(false); + +export function NestedSettingsRows({ children }: { children: ReactNode }) { + return {children}; +} + +export interface SettingsRowLayout { + row: StyleProp; + title: StyleProp; + hint: StyleProp; + /** The size of the controls in the row's trailing slot. */ + controlSize: ContainedControlSize; +} + +/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */ +export function useSettingsRowLayout(): SettingsRowLayout { + const nested = useContext(NestedRowsContext); + return useMemo( + () => + nested + ? { + row: [settingsStyles.row, styles.nestedRow], + title: [settingsStyles.rowTitle, styles.nestedText], + hint: [settingsStyles.rowHint, styles.nestedText], + controlSize: "xs", + } + : { + row: settingsStyles.row, + title: settingsStyles.rowTitle, + hint: settingsStyles.rowHint, + controlSize: "sm", + }, + [nested], + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Every nested row is as tall as one holding an `xs` control, so rows with and without a + // trailing button line up. + nestedRow: { + paddingVertical: theme.spacing[2], + minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2, + }, + nestedText: { + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/components/settings/row-header.tsx b/packages/app/src/components/settings/row-header.tsx new file mode 100644 index 0000000000..9c3c415c34 --- /dev/null +++ b/packages/app/src/components/settings/row-header.tsx @@ -0,0 +1,113 @@ +import { ChevronRight } from "lucide-react-native"; +import { useCallback, useMemo } from "react"; +import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { mutedIconColorMapping } from "@/components/ui/icon-color"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { settingsStyles } from "@/styles/settings"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { useSettingsRowLayout } from "./row-density"; + +const ThemedChevron = withUnistyles(ChevronRight); +const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); + +type HoverState = PressableStateCallbackType & { hovered?: boolean }; + +export interface SettingsRowHeaderProps { + label: string; + /** A short status shown before the chevron. */ + summary?: string; + onPress: () => void; + disabled?: boolean; + /** Turns the chevron down, for a row whose children are revealed in place. */ + expanded?: boolean; + accessibilityState?: AccessibilityState; + /** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */ + webAccessibilityProps?: Record | null; + testID?: string; +} + +/** + * The pressable line shared by settings rows that lead somewhere: a label, an optional summary and + * a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it + * pointing right. + */ +export function SettingsRowHeader({ + label, + summary, + onPress, + disabled, + expanded = false, + accessibilityState, + webAccessibilityProps, + testID, +}: SettingsRowHeaderProps) { + const layout = useSettingsRowLayout(); + const rowStyle = useCallback( + ({ hovered, pressed }: HoverState) => [ + layout.row, + !disabled && hovered && styles.hovered, + !disabled && pressed && styles.pressed, + ], + [disabled, layout], + ); + const chevronStyle = useMemo( + () => [styles.chevron, expanded && styles.chevronExpanded], + [expanded], + ); + return ( + + {({ hovered }: HoverState) => ( + <> + + {label} + + {summary ? ( + + {summary} + + ) : null} + {disabled ? null : ( + + + + )} + + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + summary: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + marginRight: theme.spacing[2], + }, + hovered: { + backgroundColor: theme.colors.surface2, + }, + pressed: { + backgroundColor: theme.colors.surface3, + }, + // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this + // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. + chevron: { marginRight: -theme.spacing[1] }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 0e1d7d28ab..9532c422dd 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ import { act, renderHook } from "@testing-library/react"; +import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; @@ -461,7 +462,7 @@ describe("sidebar group visibility", () => { it.each([0, 7, 8])("limits %i Done rows to seven", (count) => { const items = entries(Array(count).fill("done")); - const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy })); expect(result.current.visibleItems).toEqual(items.slice(0, 7)); expect(result.current.canToggle).toBe(count > 7); }); @@ -470,7 +471,9 @@ describe("sidebar group visibility", () => { "keeps row twelve visible for %s without filling gaps", (status) => { const items = entries([...Array(11).fill("done"), status]); - const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + const { result } = renderHook(() => + useLimitedSidebarGroup({ items, selection: null, policy }), + ); expect(names(result.current.visibleItems)).toEqual([ "row-1", "row-2", @@ -486,6 +489,24 @@ describe("sidebar group visibility", () => { }, ); + it("treats placements without a hydrated entry as Done", () => { + const items = entries([...Array(8).fill("done"), "running", "done"]); + const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item])); + for (const entriesByKey of [new Map(), hydrated]) { + const { result } = renderHook(() => + useLimitedSidebarGroup({ + items, + selection: null, + policy: createWorkspaceVisibilityPolicy(entriesByKey), + }), + ); + expect(result.current.canToggle).toBe(true); + expect(names(result.current.visibleItems)).toEqual( + entriesByKey.size === 0 ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-9"], + ); + } + }); + it("shows eight active rows once with no redundant toggle", () => { const items = entries([ "running", @@ -497,7 +518,7 @@ describe("sidebar group visibility", () => { "needs_input", "failed", ]); - const { result } = renderHook(() => useLimitedSidebarGroup(items, policy)); + const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy })); expect(result.current.visibleItems).toEqual(items); expect(result.current.canToggle).toBe(false); }); @@ -512,7 +533,7 @@ describe("sidebar group visibility", () => { "attention", ]); const { result, rerender, unmount } = renderHook( - ({ rows }) => useLimitedSidebarGroup(rows, policy), + ({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }), { initialProps: { rows: items } }, ); expect(names(result.current.visibleItems)).toEqual([ @@ -553,7 +574,7 @@ describe("sidebar group visibility", () => { expect(result.current.expanded).toBe(false); act(() => result.current.toggleExpanded()); unmount(); - const remounted = renderHook(() => useLimitedSidebarGroup(items, policy)); + const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy })); expect(remounted.result.current.expanded).toBe(false); expect(remounted.result.current.visibleItems).toHaveLength(10); }); @@ -577,7 +598,12 @@ describe("sidebar group visibility", () => { lifecycle: "active" as const, }; const { result, rerender } = renderHook( - ({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy), + ({ last }) => + useLimitedSidebarGroup({ + items: [...items.slice(0, 11), last], + selection: null, + policy, + }), { initialProps: { last: items[11] } }, ); expect(result.current.visibleItems).toHaveLength(7); @@ -615,7 +641,7 @@ describe("sidebar group visibility", () => { it("preserves the twenty-row pinned policy regardless of status", () => { const items = entries(Array(21).fill("running")); - const { result } = renderHook(() => useLimitedSidebarGroup(items)); + const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null })); expect(result.current.visibleItems).toEqual(items.slice(0, 20)); expect(result.current.canToggle).toBe(true); act(() => result.current.toggleExpanded()); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c3048e6d9a..8697a4b27b 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -30,7 +30,7 @@ import { type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { density, densityIconBox } from "@/styles/density"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; @@ -99,6 +99,7 @@ import { import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; +import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { SidebarWorkspaceRowFrame, @@ -462,12 +463,12 @@ const openInNewWindowLeadingIcon = ( ); +const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); +const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); + function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( - + ); } @@ -1593,19 +1594,20 @@ function ProjectBlock({ onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { const visibilityPolicy = useMemo( - () => ({ - kind: "workspaces" as const, - getStatusBucket: (workspace: SidebarWorkspacePlacement) => - workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket, - }), + () => createWorkspaceVisibilityPolicy(workspaceEntriesByKey), [workspaceEntriesByKey], ); const { + leadingItem: leadingWorkspace, visibleItems: visibleWorkspaces, expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(project.workspaces, visibilityPolicy); + } = useLimitedSidebarGroup({ + items: project.workspaces, + selection: selectionEnabled ? activeWorkspaceSelection : null, + policy: visibilityPolicy, + }); const activeServerId = activeWorkspaceSelection?.serverId ?? null; const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? ""); const preferredServerId = activeHostStatus === "online" ? activeServerId : null; @@ -1764,28 +1766,33 @@ function ProjectBlock({ let projectChildren = null; if (!collapsed) { if (project.workspaces.length > 0) { + const leadingWorkspaceRow = leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null; projectChildren = ( <> {manualSort ? ( - + <> + {leadingWorkspaceRow} + + ) : ( + {leadingWorkspaceRow} {visibleWorkspaces.map((item) => renderWorkspaceRow(item))} )} @@ -2191,11 +2198,15 @@ function ProjectModeList({ const activeWorkspaceSelection = useActiveWorkspaceSelection(); const { pinnedChats, unpinnedProjects } = pinnedGroups; const { + leadingItem: leadingPinnedChat, visibleItems: visiblePinnedChats, expanded: pinnedChatsExpanded, canToggle: canTogglePinnedChats, toggleExpanded: togglePinnedChatsExpanded, - } = useLimitedSidebarGroup(pinnedChats); + } = useLimitedSidebarGroup({ + items: pinnedChats, + selection: selectionEnabled ? activeWorkspaceSelection : null, + }); const nativeScrollGestureProps = useMemo( () => parentGestureRef @@ -2398,37 +2409,38 @@ function ProjectModeList({ [renderProjectBlock], ); - const renderPinnedChat = useCallback( - ({ - item: workspace, - drag, - isActive, - dragHandleProps, - }: DraggableRenderItemInfo) => { - return ( - - ); - }, + const renderPinnedChatRow = useCallback( + ( + workspace: SidebarWorkspacePlacement, + input?: { + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + }, + ) => ( + + ), [ activeWorkspaceSelection, creatingWorkspaceIds, @@ -2444,6 +2456,22 @@ function ProjectModeList({ ], ); + const renderPinnedChat = useCallback( + ({ + item: workspace, + drag, + isActive, + dragHandleProps, + }: DraggableRenderItemInfo) => { + return renderPinnedChatRow(workspace, { + drag, + isDragging: isActive, + dragHandleProps, + }); + }, + [renderPinnedChatRow], + ); + const projectBody = projects.length === 0 ? ( @@ -2476,6 +2504,7 @@ function ProjectModeList({ /> {pinnedCollapsed ? null : ( <> + {leadingPinnedChat ? renderPinnedChatRow(leadingPinnedChat) : null} ({ marginRight: -6, }, projectKebabButton: { - width: 24, - height: 24, + // Larger on compact so the kebab is an easy tap target. The negative margins keep its + // dots on the same rail as the 24px desktop box and the row at its 44px height. + width: { xs: 32, md: 24 }, + height: { xs: 32, md: 24 }, + marginRight: { xs: -4, md: 0 }, + marginVertical: { xs: -4, md: 0 }, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.test.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.test.ts new file mode 100644 index 0000000000..acd36cd808 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; +import { + selectSidebarGroupVisibility, + type SidebarGroupItem, + type SidebarGroupVisibilityPolicy, +} from "./sidebar-group-visibility"; + +interface Item extends SidebarGroupItem { + id: number; + statusBucket: SidebarStateBucket; +} + +const serverId = "host-a"; + +function rows(statuses: readonly SidebarStateBucket[], rowServerId = serverId): Item[] { + return statuses.map((statusBucket, index) => ({ + id: index + 1, + serverId: rowServerId, + workspaceId: `workspace-${index + 1}`, + statusBucket, + })); +} + +const workspacePolicy: SidebarGroupVisibilityPolicy = { + kind: "workspaces", + getStatusBucket: (item) => item.statusBucket, +}; + +function selectionFor(item: SidebarGroupItem): ActiveWorkspaceSelection { + return { serverId: item.serverId, workspaceId: item.workspaceId }; +} + +describe("selectSidebarGroupVisibility", () => { + it("leads with a hidden open row while keeping the baseline order", () => { + const items = rows(Array(9).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: selectionFor(items[8]!), + }); + + expect(result.leadingItem).toBe(items[8]); + expect(result.visibleItems).toEqual(items.slice(0, 7)); + expect(result.canToggle).toBe(true); + }); + + it("leaves selected rows in place when they are already in the baseline", () => { + const items = rows([...Array(8).fill("done"), "running", "done"]); + const expected = [...items.slice(0, 7), items[8]]; + + for (const selectedIndex of [2, 8]) { + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: selectionFor(items[selectedIndex]!), + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(expected); + expect(result.canToggle).toBe(true); + } + }); + + it("shows the whole group without a toggle when the open row is the only hidden row", () => { + const items = rows(Array(8).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: selectionFor(items[7]!), + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items); + expect(result.canToggle).toBe(false); + }); + + it("keeps expanded groups in their original order", () => { + const items = rows(Array(9).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: true, + policy: workspacePolicy, + selection: selectionFor(items[8]!), + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items); + expect(result.canToggle).toBe(true); + }); + + it("applies the twenty-row limit to pinned groups", () => { + const items = rows(Array(22).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: { kind: "pinned" }, + selection: selectionFor(items[21]!), + }); + + expect(result.leadingItem).toBe(items[21]); + expect(result.visibleItems).toEqual(items.slice(0, 20)); + expect(result.canToggle).toBe(true); + }); + + it("preserves the current limit when selection is null", () => { + const items = rows(Array(8).fill("done")); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: null, + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items.slice(0, 7)); + expect(result.canToggle).toBe(true); + }); + + it("matches the open row by server and workspace identity", () => { + const items = rows(Array(9).fill("done"), "host-b"); + const result = selectSidebarGroupVisibility({ + items, + expanded: false, + policy: workspacePolicy, + selection: { serverId: serverId, workspaceId: items[8]!.workspaceId }, + }); + + expect(result.leadingItem).toBeNull(); + expect(result.visibleItems).toEqual(items.slice(0, 7)); + expect(result.canToggle).toBe(true); + }); +}); diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.ts index ce9f47671c..f109f0114a 100644 --- a/packages/app/src/components/sidebar/sidebar-group-visibility.ts +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.ts @@ -1,21 +1,84 @@ import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; + +export interface SidebarGroupItem { + serverId: string; + workspaceId: string; +} + +export interface SidebarGroupVisibilityInput { + items: readonly T[]; + expanded: boolean; + policy: SidebarGroupVisibilityPolicy; + /** The workspace the group's rows highlight as selected. Null when no row can be selected. */ + selection: ActiveWorkspaceSelection | null; +} + +export interface SidebarGroupVisibility { + /** + * The open workspace when the collapsed limit would hide it. Rendered before `visibleItems`, + * out of group order, outside any drag list. Never also present in `visibleItems`. + */ + leadingItem: T | null; + visibleItems: T[]; + canToggle: boolean; +} export type SidebarGroupVisibilityPolicy = | { kind: "pinned" } | { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket }; -export function selectSidebarGroupVisibility( - items: readonly T[], - expanded: boolean, - policy: SidebarGroupVisibilityPolicy, -) { - const limit = policy.kind === "pinned" ? 20 : 7; - const baseline = items.filter( - (item, index) => - index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"), - ); +// A placement can outrun its entry: a retained sidebar that is inactive (compact layout with the +// sidebar closed) or still waiting for sessions keeps an empty entry map. A missing entry renders +// no row, so it counts as Done and claims no slot past the limit. +export function createWorkspaceVisibilityPolicy( + entriesByKey: ReadonlyMap, +): SidebarGroupVisibilityPolicy { return { - visibleItems: expanded ? items.slice() : baseline, - canToggle: baseline.length < items.length, + kind: "workspaces", + getStatusBucket: (item) => entriesByKey.get(item.workspaceKey)?.statusBucket ?? "done", + }; +} + +function isOpenWorkspace( + item: SidebarGroupItem, + selection: ActiveWorkspaceSelection | null, +): boolean { + return ( + selection !== null && + item.serverId === selection.serverId && + item.workspaceId === selection.workspaceId + ); +} + +export function selectSidebarGroupVisibility( + input: SidebarGroupVisibilityInput, +): SidebarGroupVisibility { + const { items, expanded, policy, selection } = input; + const limit = policy.kind === "pinned" ? 20 : 7; + const baseline: T[] = []; + let leadingItem: T | null = null; + let canToggle = false; + + items.forEach((item, index) => { + const inBaseline = + index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"); + if (inBaseline) { + baseline.push(item); + } else if (leadingItem === null && isOpenWorkspace(item, selection)) { + leadingItem = item; + } else { + canToggle = true; + } + }); + + if (expanded || !canToggle) { + return { leadingItem: null, visibleItems: items.slice(), canToggle }; + } + + return { + leadingItem, + visibleItems: baseline, + canToggle, }; } diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 44622385a4..ce8ad535c4 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -147,6 +147,7 @@ export function SidebarStatusWorkspaceList({ dragGestureHostActive, }: StatusWorkspaceListProps) { const { t } = useTranslation(); + const activeWorkspaceSelection = useActiveWorkspaceSelection(); const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedWorkspaceGroupKeys, ); @@ -155,23 +156,27 @@ export function SidebarStatusWorkspaceList({ (state) => state.togglePinnedCollapsed, ); const { + leadingItem: leadingPinnedWorkspace, visibleItems: visiblePinnedWorkspaces, expanded: pinnedWorkspacesExpanded, canToggle: canTogglePinnedWorkspaces, toggleExpanded: togglePinnedWorkspacesExpanded, - } = useLimitedSidebarGroup(pinnedWorkspaces); + } = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection }); const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : EMPTY_SHORTCUT_INDEX; - const renderPinnedWorkspace = useCallback( - ({ - item: workspace, - drag, - isActive, - dragHandleProps, - }: DraggableRenderItemInfo) => ( + const renderPinnedWorkspaceRow = useCallback( + ( + workspace: SidebarWorkspaceEntry, + input?: { + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + }, + ) => ( ), [ @@ -199,6 +204,20 @@ export function SidebarStatusWorkspaceList({ supportsPinningByServerId, ], ); + const renderPinnedWorkspace = useCallback( + ({ + item: workspace, + drag, + isActive, + dragHandleProps, + }: DraggableRenderItemInfo) => + renderPinnedWorkspaceRow(workspace, { + drag, + isDragging: isActive, + dragHandleProps, + }), + [renderPinnedWorkspaceRow], + ); const content = ( <> {pinnedWorkspaces.length > 0 ? ( @@ -211,6 +230,7 @@ export function SidebarStatusWorkspaceList({ /> {pinnedCollapsed ? null : ( <> + {leadingPinnedWorkspace ? renderPinnedWorkspaceRow(leadingPinnedWorkspace) : null} ; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { + const activeWorkspaceSelection = useActiveWorkspaceSelection(); const { + leadingItem: leadingWorkspace, visibleItems: visibleWorkspaces, expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, - } = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY); + } = useLimitedSidebarGroup({ + items: group.rows, + selection: activeWorkspaceSelection, + policy: STATUS_GROUP_VISIBILITY, + }); + + function renderWorkspaceRow(workspace: SidebarWorkspaceEntry) { + return ( + + ); + } return ( @@ -360,22 +405,8 @@ function StatusGroupRows({ style={styles.statusWorkspaceListContainer} testID={`sidebar-status-group-rows-${group.key}`} > - {visibleWorkspaces.map((workspace) => ( - - ))} + {leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null} + {visibleWorkspaces.map(renderWorkspaceRow)} {canToggleWorkspaces ? ( ; const unpinLeadingIcon = ; +const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); +const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); + function renderTriggerIcon({ hovered }: { hovered?: boolean }) { - return ( - - ); + return ; } export interface SidebarWorkspaceMenuProps { @@ -439,12 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere const styles = StyleSheet.create((theme) => ({ trigger: { - padding: 2, + // Larger on compact so the kebab is an easy tap target. + width: { xs: 32, md: 18 }, + height: { xs: 32, md: 18 }, + alignItems: "center", + justifyContent: "center", borderRadius: 4, marginLeft: 2, + // Center the compact box on the 20px title line it is anchored to. + marginTop: { xs: -6, md: 0 }, // MoreVertical paints only around the center of its SVG. Keep the padded hit box, but // pull the painted dots through that unused view-box space onto the trailing-content rail. - marginRight: -7, + marginRight: { xs: -14, md: -7 }, }, triggerHovered: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/components/sidebar/use-limited-sidebar-group.ts b/packages/app/src/components/sidebar/use-limited-sidebar-group.ts index 99e4476dfb..c7b77bfc16 100644 --- a/packages/app/src/components/sidebar/use-limited-sidebar-group.ts +++ b/packages/app/src/components/sidebar/use-limited-sidebar-group.ts @@ -1,19 +1,32 @@ import { useCallback, useMemo, useState } from "react"; import { selectSidebarGroupVisibility, + type SidebarGroupItem, + type SidebarGroupVisibility, type SidebarGroupVisibilityPolicy, } from "./sidebar-group-visibility"; +import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; const PINNED_POLICY = { kind: "pinned" } as const; -export function useLimitedSidebarGroup( - items: readonly T[], - policy: SidebarGroupVisibilityPolicy = PINNED_POLICY, -) { +interface LimitedSidebarGroupInput { + items: readonly T[]; + selection: ActiveWorkspaceSelection | null; + policy?: SidebarGroupVisibilityPolicy; +} + +export function useLimitedSidebarGroup({ + items, + selection, + policy = PINNED_POLICY, +}: LimitedSidebarGroupInput): SidebarGroupVisibility & { + expanded: boolean; + toggleExpanded: () => void; +} { const [expanded, setExpanded] = useState(false); const visibility = useMemo( - () => selectSidebarGroupVisibility(items, expanded, policy), - [expanded, items, policy], + () => selectSidebarGroupVisibility({ items, expanded, policy, selection }), + [expanded, items, policy, selection], ); const toggleExpanded = useCallback(() => setExpanded((current) => !current), []); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 660b5f4e6b..278317243a 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -48,6 +48,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs- import { collectAllPanes, createDefaultLayout, + selectExplorerSidebarPaneId, useWorkspaceLayoutStore, type SplitNode, type SplitPane, @@ -183,8 +184,8 @@ export function SplitContainer({ } | null>(null); const maximizedPaneId = maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null; - const explorerSidebarPaneId = useWorkspaceLayoutStore( - (state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null, + const explorerSidebarPaneId = useWorkspaceLayoutStore((state) => + selectExplorerSidebarPaneId(state, workspaceKey), ); const mainRoot = useMemo( diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts index c4c7080b83..b4e3dd93e1 100644 --- a/packages/app/src/components/terminal-emulator-contract.ts +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -4,7 +4,6 @@ import type { } from "../terminal/runtime/terminal-emulator-runtime"; import type { Ref } from "react"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; import type { @@ -13,14 +12,14 @@ import type { } from "../terminal/local-links/terminal-local-link-provider"; import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection"; import type { TerminalFile } from "../terminal/file-paste"; +import type { TerminalCharacterWidths } from "../terminal/character-widths"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; export interface TerminalEmulatorHandle { find?: TerminalFindHandle; writeOutput: (data: TerminalOutputData) => void; - restoreOutput: (data: TerminalOutputData) => void; - renderSnapshot: (state: TerminalState | null) => void; + replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void; paste: (text: string) => void; copySelection: (clipboard: TerminalClipboardWriter) => Promise; clear: () => void; @@ -34,6 +33,7 @@ export interface TerminalEmulatorProps { ref: Ref; streamKey: string; supportsTerminalInputModeReplay: boolean; + characterWidths: TerminalCharacterWidths; testId?: string; xtermTheme?: ITheme; scrollbackLines: number; @@ -44,7 +44,6 @@ export interface TerminalEmulatorProps { swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; - initialSnapshot?: TerminalState | null; onFindRequest?: () => void; onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; diff --git a/packages/app/src/components/terminal-emulator-native-grid.native.tsx b/packages/app/src/components/terminal-emulator-native-grid.native.tsx index dcda257116..ad71316f3c 100644 --- a/packages/app/src/components/terminal-emulator-native-grid.native.tsx +++ b/packages/app/src/components/terminal-emulator-native-grid.native.tsx @@ -1,19 +1,17 @@ +import Svg, { Path } from "react-native-svg"; import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { Keyboard, type LayoutChangeEvent, PanResponder, - type PanResponderGestureState, Pressable, StyleSheet, Text, View, - type GestureResponderEvent, type StyleProp, type ViewStyle, } from "react-native"; import type { ITheme } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import { TerminalInputModeTracker, terminalInputModeStatesEqual, @@ -48,13 +46,10 @@ import { type TerminalSelectionViewport, } from "../terminal/native-renderer/terminal-selection"; import { - TERMINAL_GESTURE_LONG_PRESS_MS, - TERMINAL_GESTURE_TAP_TOLERANCE_PX, - advanceTerminalScrollGesture, - classifyTerminalGestureIntent, - resolveTerminalGestureReleaseAction, - type TerminalGestureIntent, -} from "../terminal/native-renderer/terminal-selection-gesture"; + createTerminalSelectionController, + terminalSelectionHandles, + TERMINAL_SELECTION_GRIP_SIZE, +} from "../terminal/native-renderer/terminal-selection-controller"; import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy"; import { forwardNativeTerminalKey, @@ -88,75 +83,8 @@ const DEFAULT_XTERM_THEME: ITheme = { foreground: "#e6e6e6", cursor: "#e6e6e6", }; -interface NativeTerminalPanState { - lastDy: number; - rowRemainder: number; - didScroll: boolean; - didNavigate: boolean; - movedBeyondTapTolerance: boolean; - intent: TerminalGestureIntent | null; -} - -interface NativeTerminalSelectionPress { - startX: number; - startY: number; - startedAt: number; - startedWithSelection: boolean; - status: "pressing" | "selecting"; -} - -function resolvePanResponderPoint( - press: NativeTerminalSelectionPress, - event: GestureResponderEvent, - gesture: PanResponderGestureState, -): { x: number; y: number } { - const eventDx = event.nativeEvent.locationX - press.startX; - const eventDy = event.nativeEvent.locationY - press.startY; - const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx; - const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy; - return { - x: press.startX + dx, - y: press.startY + dy, - }; -} - -function initialNativeTerminalSize(snapshot: TerminalState | null): TerminalMeasuredSize { - return { - rows: snapshot?.rows ?? MIN_NATIVE_TERMINAL_ROWS, - cols: snapshot?.cols ?? MIN_NATIVE_TERMINAL_COLS, - }; -} - -function screenStateFromViewport(viewport: TerminalViewportState): TerminalScreenState { - const visibleRows = viewport.grid.length; - const currentViewport = { - firstRow: viewport.firstRow, - lastRow: Math.min(viewport.newestRow, viewport.firstRow + Math.max(1, visibleRows) - 1), - }; - return { - viewport, - scroll: { - mode: "following", - firstRow: viewport.firstRow, - visibleRows, - oldestRow: viewport.oldestRow, - newestRow: viewport.newestRow, - currentViewport, - bottomViewport: currentViewport, - }, - }; -} - -function viewportStateFromSnapshot(state: TerminalState): TerminalViewportState { - return { - rows: state.grid.length, - cols: state.cols, - firstRow: 0, - oldestRow: 0, - newestRow: Math.max(0, state.grid.length - 1), - grid: state.grid, - cursor: state.cursor, - }; +function initialNativeTerminalSize(): TerminalMeasuredSize { + return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS }; } function NativeTerminalEmulator({ @@ -165,11 +93,11 @@ function NativeTerminalEmulator({ testId = "terminal-surface", xtermTheme = DEFAULT_XTERM_THEME, scrollbackLines, + characterWidths, fontFamily, fontSize, keyboardInset = 0, isKeyboardVisible = false, - initialSnapshot = null, onInput, onTerminalKey, onResize, @@ -181,20 +109,18 @@ function NativeTerminalEmulator({ resizeRequestToken = 0, }: TerminalEmulatorProps) { const terminalRef = useRef(null); - const mountInputRef = useRef({ streamKey, initialSnapshot }); + const mountInputRef = useRef({ streamKey }); if ( resolveNativeTerminalMountAction({ mountedStreamKey: mountInputRef.current.streamKey, nextStreamKey: streamKey, }) === "mount" ) { - mountInputRef.current = { streamKey, initialSnapshot }; + mountInputRef.current = { streamKey }; } const inputRef = useRef(null); - const terminalSizeRef = useRef(initialNativeTerminalSize(initialSnapshot)); - const resizePolicyRef = useRef( - createTerminalResizePolicy(initialNativeTerminalSize(initialSnapshot)), - ); + const terminalSizeRef = useRef(initialNativeTerminalSize()); + const resizePolicyRef = useRef(createTerminalResizePolicy(initialNativeTerminalSize())); const activeResizeClaimTokenRef = useRef(0); const layoutRef = useRef(null); const metricsRef = useRef(null); @@ -202,29 +128,20 @@ function NativeTerminalEmulator({ const outputDrainRef = useRef(null); const screenModelRef = useRef(null); const selectionModelRef = useRef(createTerminalSelectionModel()); - const selectionLongPressTimeoutRef = useRef | null>(null); - const selectionPressRef = useRef(null); + const selectionControllerRef = useRef | null>(null); + const gestureStartRef = useRef({ x: 0, y: 0 }); + const [cellMetrics, setCellMetrics] = useState(null); const latestResolvedScreenRef = useRef(null); const paintFrameRef = useRef(null); const outputDecoderRef = useRef(new TextDecoder()); - const panStateRef = useRef({ - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }); const inputModeTrackerRef = useRef(new TerminalInputModeTracker()); const lastInputModeStateRef = useRef({ ...inputModeTrackerRef.current.getState(), applicationCursorKeys: false, }); - const [terminalScreen, setTerminalScreen] = useState(() => { - return initialSnapshot - ? screenStateFromViewport(viewportStateFromSnapshot(initialSnapshot)) - : null; - }); + const [terminalScreen, setTerminalScreen] = useState(null); const [selectionRange, setSelectionRange] = useState(null); const selectionRangeRef = useRef(null); const hasNotifiedSelectionRef = useRef(false); @@ -288,6 +205,7 @@ function NativeTerminalEmulator({ }, []); const clearSelection = useCallback(() => { + selectionControllerRef.current?.cancel(); const snapshot = selectionModelRef.current.clear(); commitSelectionRange(snapshot.range); }, [commitSelectionRange]); @@ -298,20 +216,14 @@ function NativeTerminalEmulator({ const syncSelectionWithTerminal = useCallback( (terminal: NativeHeadlessTerminal) => { + const previous = selectionModelRef.current.getSnapshot().range; const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() }); + if (previous && !snapshot.range) selectionControllerRef.current?.cancel(); commitSelectionRange(snapshot.range); }, [commitSelectionRange], ); - const clearSelectionLongPressTimeout = useCallback(() => { - if (selectionLongPressTimeoutRef.current === null) { - return; - } - clearTimeout(selectionLongPressTimeoutRef.current); - selectionLongPressTimeoutRef.current = null; - }, []); - const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => { return ( resolveMeasuredNativeTerminalSize({ @@ -361,10 +273,14 @@ function NativeTerminalEmulator({ (terminal: NativeHeadlessTerminal): NativeTerminalOutputDrain => createNativeTerminalOutputDrain({ write: async (chunk) => { + inputModeTrackerRef.current.feed(chunk); await terminal.write(chunk); emitInputModeChange(); }, - reset: () => { + restoreSize: () => terminal.resize(terminalSizeRef.current), + reset: (size) => { + inputModeTrackerRef.current.reset(); + if (size) terminal.resize(size); terminal.reset(); emitInputModeChange(); }, @@ -415,11 +331,11 @@ function NativeTerminalEmulator({ return; } + const isFirstMeasuredSize = !hasNotifiedMeasuredSizeRef.current; hasNotifiedMeasuredSizeRef.current = true; terminalSizeRef.current = policyResult.measuredSize; if (policyResult.measuredSizeChanged) { - terminalRef.current?.resize(policyResult.measuredSize); - schedulePaint(); + outputDrainRef.current?.resize(); } callbacksRef.current.onResize?.({ rows: policyResult.measuredSize.rows, @@ -427,69 +343,62 @@ function NativeTerminalEmulator({ shouldClaim: policyResult.resizeClaim !== null, forceClaim: policyResult.resizeClaim?.force, }); - }, - [schedulePaint], - ); - - const resetTerminal = useCallback( - (snapshot: TerminalState | null) => { - outputDrainRef.current?.dispose(); - outputDrainRef.current = null; - terminalRef.current?.dispose(); - screenModelRef.current = null; - latestResolvedScreenRef.current = null; - clearSelection(); - const measuredSize = resolveMeasuredNativeTerminalSize({ - layout: layoutRef.current, - metrics: metricsRef.current, - }); - const size = - measuredSize ?? - (snapshot ? { rows: snapshot.rows, cols: snapshot.cols } : terminalSizeRef.current); - terminalSizeRef.current = size; - resizePolicyRef.current = createTerminalResizePolicy(size); - outputDecoderRef.current.decode(); - const terminal = createNativeHeadlessTerminal({ - rows: size.rows, - cols: size.cols, - scrollbackLines, - }); - terminalRef.current = terminal; - screenModelRef.current = createNativeTerminalScreenModel({ terminal }); - const outputDrain = createOutputDrain(terminal); - outputDrainRef.current = outputDrain; - const screen = snapshot - ? screenStateFromViewport(viewportStateFromSnapshot(snapshot)) - : resolveTerminalScreen(terminal); - commitTerminalScreen(screen); - resetInputModeTracker(); - if (snapshot) { - outputDrain.restoreSnapshot(snapshot); + // Ready waits for the grid size, so the pane attaches with it and the daemon replays the + // terminal at that size instead of the PTY's. + if (isFirstMeasuredSize) { + callbacksRef.current.onRendererReadyChange?.({ + streamKey: mountInputRef.current.streamKey, + isReady: true, + }); } }, - [ - commitTerminalScreen, - createOutputDrain, - resetInputModeTracker, - resolveTerminalScreen, + [], + ); + + const resetTerminal = useCallback(() => { + outputDrainRef.current?.dispose(); + outputDrainRef.current = null; + terminalRef.current?.dispose(); + screenModelRef.current = null; + latestResolvedScreenRef.current = null; + clearSelection(); + const measuredSize = resolveMeasuredNativeTerminalSize({ + layout: layoutRef.current, + metrics: metricsRef.current, + }); + const size = measuredSize ?? terminalSizeRef.current; + terminalSizeRef.current = size; + resizePolicyRef.current = createTerminalResizePolicy(size); + outputDecoderRef.current.decode(); + const terminal = createNativeHeadlessTerminal({ + rows: size.rows, + cols: size.cols, scrollbackLines, - clearSelection, - ], - ); + characterWidths, + }); + terminalRef.current = terminal; + screenModelRef.current = createNativeTerminalScreenModel({ terminal }); + const outputDrain = createOutputDrain(terminal); + outputDrainRef.current = outputDrain; + const screen = resolveTerminalScreen(terminal); + commitTerminalScreen(screen); + resetInputModeTracker(); + }, [ + commitTerminalScreen, + createOutputDrain, + resetInputModeTracker, + resolveTerminalScreen, + scrollbackLines, + characterWidths, + clearSelection, + ]); - const enqueueOutputText = useCallback( - (text: string) => { - if (text.length === 0) { - return; - } - const inputModeUpdate = inputModeTrackerRef.current.feed(text); - if (inputModeUpdate.changed) { - emitInputModeChange(); - } - outputDrainRef.current?.enqueueText(text); - }, - [emitInputModeChange], - ); + const enqueueOutputText = useCallback((text: string) => { + if (text.length === 0) { + return; + } + outputDrainRef.current?.enqueueText(text); + }, []); const claimActiveTerminalSize = useCallback(() => { activeResizeClaimTokenRef.current += 1; @@ -512,27 +421,7 @@ function NativeTerminalEmulator({ const text = outputDecoderRef.current.decode(data, { stream: true }); enqueueOutputText(text); }, - restoreOutput: (data: TerminalOutputData) => { - outputDecoderRef.current.decode(); - resetInputModeTracker(); - const text = outputDecoderRef.current.decode(data, { stream: false }); - outputDrainRef.current?.restoreText(text); - }, - renderSnapshot: (state: TerminalState | null) => { - outputDecoderRef.current.decode(); - resetInputModeTracker(); - if (!state) { - resetTerminal(null); - return; - } - clearSelection(); - const outputDrain = outputDrainRef.current; - if (!outputDrain) { - resetTerminal(state); - return; - } - outputDrain.restoreSnapshot(state); - }, + replay: (event) => outputDrainRef.current?.replay(event), paste: (text: string) => { if (text.length === 0) { return; @@ -563,7 +452,6 @@ function NativeTerminalEmulator({ screenModelRef.current?.reset(); clearSelection(); resetInputModeTracker(); - terminalRef.current?.reset(); schedulePaint(); }, claimSize: claimActiveTerminalSize, @@ -582,19 +470,17 @@ function NativeTerminalEmulator({ claimActiveTerminalSizeForAction, clearSelection, resetInputModeTracker, - resetTerminal, schedulePaint, ], ); useEffect(() => { hasNotifiedMeasuredSizeRef.current = false; - resetTerminal(mountInputRef.current.initialSnapshot); - callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: true }); + resetTerminal(); + emitMeasuredSize({ source: "measure" }); return () => { callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); - clearSelectionLongPressTimeout(); - selectionPressRef.current = null; + selectionControllerRef.current?.cancel(); if (paintFrameRef.current !== null) { cancelAnimationFrame(paintFrameRef.current); paintFrameRef.current = null; @@ -604,7 +490,7 @@ function NativeTerminalEmulator({ terminalRef.current?.dispose(); terminalRef.current = null; }; - }, [clearSelectionLongPressTimeout, resetTerminal, streamKey]); + }, [emitMeasuredSize, resetTerminal, streamKey]); useEffect(() => { if (focusRequestToken <= 0) return; @@ -661,6 +547,7 @@ function NativeTerminalEmulator({ const handleCellMetricsChange = useCallback( (metrics: TerminalGridCellMetrics) => { metricsRef.current = metrics; + setCellMetrics(metrics); emitMeasuredSize({ source: "measure" }); }, [emitMeasuredSize], @@ -733,14 +620,6 @@ function NativeTerminalEmulator({ if (!coordinate) { return; } - const existingPress = selectionPressRef.current; - selectionPressRef.current = { - startX: point.x, - startY: point.y, - startedAt: existingPress?.startedAt ?? Date.now(), - startedWithSelection: existingPress?.startedWithSelection ?? false, - status: "selecting", - }; const bounds = terminal.getBufferBounds(); const wordSelection = resolveTerminalWordSelection({ terminal, coordinate }); let snapshot = selectionModelRef.current.begin({ @@ -755,222 +634,61 @@ function NativeTerminalEmulator({ [commitSelectionRange, resolveSelectionCoordinate], ); - const updateSelectionAtPoint = useCallback( - (point: { x: number; y: number }) => { - const terminal = terminalRef.current; - if (!terminal || selectionPressRef.current?.status !== "selecting") { - return; - } - const coordinate = resolveSelectionCoordinate(point); - if (!coordinate) { - return; - } - const snapshot = selectionModelRef.current.update({ - coordinate, - bounds: terminal.getBufferBounds(), - }); - commitSelectionRange(snapshot.range); - }, - [commitSelectionRange, resolveSelectionCoordinate], - ); - - const handlePanResponderScroll = useCallback( - (currentDy: number) => { - const cellHeight = metricsRef.current?.cellHeight; - if (!cellHeight || cellHeight <= 0) { - return; - } - const panState = panStateRef.current; - const update = advanceTerminalScrollGesture({ - state: panState, - currentDy, - cellHeight, - }); - panState.lastDy = update.state.lastDy; - panState.rowRemainder = update.state.rowRemainder; - panState.didScroll = update.state.didScroll; - const { rowDelta } = update; - if (rowDelta === 0) { - return; - } - if (rowDelta > 0) { - applyScroll("up", rowDelta); - return; - } - applyScroll("down", Math.abs(rowDelta)); - }, - [applyScroll], + const selectionController = useMemo( + () => + createTerminalSelectionController({ + getGeometry: () => { + const viewport = resolveSelectionViewport(); + const metrics = metricsRef.current; + return viewport && metrics + ? { range: selectionRangeRef.current, viewport, metrics } + : null; + }, + onBeginHandle: (endpoint, cols) => + selectionModelRef.current.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + const terminal = terminalRef.current; + if (!terminal) return; + commitSelectionRange( + selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() }) + .range, + ); + }, + onLongPress: beginSelectionAtPoint, + onTap: focusTerminalFromTap, + onScroll: applyScroll, + schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number, + cancel: (timer) => clearTimeout(timer), + }), + [ + applyScroll, + beginSelectionAtPoint, + commitSelectionRange, + focusTerminalFromTap, + resolveSelectionViewport, + ], ); + selectionControllerRef.current = selectionController; const panResponder = useMemo( () => PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true, - onMoveShouldSetPanResponder: (_event, gesture) => { - const intent = classifyTerminalGestureIntent({ - status: selectionRangeRef.current ? "selecting" : "pressing", - dx: gesture.dx, - dy: gesture.dy, - vx: gesture.vx, - vy: gesture.vy, - }); - return intent === "scroll" || intent === "select"; - }, - onMoveShouldSetPanResponderCapture: (_event, gesture) => { - const intent = classifyTerminalGestureIntent({ - status: selectionRangeRef.current ? "selecting" : "pressing", - dx: gesture.dx, - dy: gesture.dy, - vx: gesture.vx, - vy: gesture.vy, - }); - return intent === "scroll" || intent === "select"; - }, onPanResponderGrant: (event) => { - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; - const point = { - x: event.nativeEvent.locationX, - y: event.nativeEvent.locationY, - }; - const pressStatus = selectionRangeRef.current ? "selecting" : "pressing"; - selectionPressRef.current = { - startX: point.x, - startY: point.y, - startedAt: Date.now(), - startedWithSelection: pressStatus === "selecting", - status: pressStatus, - }; - clearSelectionLongPressTimeout(); - if (pressStatus === "selecting") { - return; - } - selectionLongPressTimeoutRef.current = setTimeout(() => { - selectionLongPressTimeoutRef.current = null; - const press = selectionPressRef.current; - if (press?.status !== "pressing") { - return; - } - beginSelectionAtPoint({ x: press.startX, y: press.startY }); - }, TERMINAL_GESTURE_LONG_PRESS_MS); - }, - onPanResponderMove: (event, gesture) => { - const selectionPress = selectionPressRef.current; - const panState = panStateRef.current; - if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { - panState.movedBeyondTapTolerance = true; - } - if (selectionPress?.status === "selecting") { - if (panState.movedBeyondTapTolerance) { - updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); - } - return; - } - - if (panState.intent === "scroll") { - handlePanResponderScroll(gesture.dy); - return; - } - if (panState.didNavigate) { - return; - } - - if (selectionPress?.status === "pressing") { - const point = resolvePanResponderPoint(selectionPress, event, gesture); - const dx = point.x - selectionPress.startX; - const dy = point.y - selectionPress.startY; - const intent = classifyTerminalGestureIntent({ - status: "pressing", - dx, - dy, - vx: gesture.vx, - vy: gesture.vy, - elapsedMs: Date.now() - selectionPress.startedAt, - }); - if (intent === "tap" || intent === "pending") { - return; - } - - clearSelectionLongPressTimeout(); - panState.intent = intent; - - if (intent === "scroll") { - selectionPressRef.current = null; - handlePanResponderScroll(gesture.dy); - return; - } - - if (intent === "select") { - beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); - updateSelectionAtPoint(point); - return; - } - } - }, - onPanResponderRelease: (event, gesture) => { - clearSelectionLongPressTimeout(); - const selectionPress = selectionPressRef.current; - if ( - selectionPress?.status === "selecting" && - panStateRef.current.movedBeyondTapTolerance - ) { - updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); - } - selectionPressRef.current = null; - const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current; - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; - const releaseAction = resolveTerminalGestureReleaseAction({ - status: selectionPress?.status ?? "idle", - startedWithSelection: selectionPress?.startedWithSelection, - didScroll, - didNavigate, - movedBeyondTapTolerance, - pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: terminalScreenRef.current?.scroll.mode ?? "following", - }); - if (releaseAction === "select" && selectionPress) { - beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY }); - return; - } - if (releaseAction === "focus" || releaseAction === "clear") { - focusTerminalFromTap(); - } - }, - onPanResponderTerminate: () => { - clearSelectionLongPressTimeout(); - selectionPressRef.current = null; - panStateRef.current = { - lastDy: 0, - rowRemainder: 0, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - intent: null, - }; + const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY }; + gestureStartRef.current = point; + selectionController.press(point); }, + onPanResponderMove: (_event, gesture) => + selectionController.move({ + x: gestureStartRef.current.x + gesture.dx, + y: gestureStartRef.current.y + gesture.dy, + }), + onPanResponderRelease: () => selectionController.release(), + onPanResponderTerminate: () => selectionController.cancel(), }), - [ - beginSelectionAtPoint, - clearSelectionLongPressTimeout, - focusTerminalFromTap, - handlePanResponderScroll, - updateSelectionAtPoint, - ], + [selectionController], ); const backgroundColor = xtermTheme.background ?? "#0b0b0b"; @@ -981,6 +699,11 @@ function NativeTerminalEmulator({ const terminalState = terminalScreen?.viewport ?? null; const isScrolled = terminalScreen?.scroll.mode === "scrolled"; + const viewport = resolveSelectionViewport(); + const handles = + viewport && cellMetrics + ? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics }) + : []; const terminalGrid = terminalState ? ( {terminalGrid} + + {handles.map((handle) => ( + + + + + + + ))} + void; - restoreOutput: (data: TerminalOutputData) => void; - renderSnapshot: (state: TerminalState | null) => void; + replay: (event: import("@getpaseo/client").TerminalReplayEvent) => void; paste: (text: string) => void; copySelection: (clipboard: TerminalClipboardWriter) => Promise; clear: () => void; @@ -98,6 +97,7 @@ interface TerminalEmulatorProps { ref: Ref; streamKey: string; supportsTerminalInputModeReplay: boolean; + characterWidths: TerminalCharacterWidths; testId?: string; xtermTheme?: ITheme; scrollbackLines: number; @@ -108,7 +108,6 @@ interface TerminalEmulatorProps { swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; - initialSnapshot?: TerminalState | null; onFindRequest?: () => void; onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; @@ -147,16 +146,6 @@ declare global { interface Window {} } -function isTerminalState(value: unknown): value is TerminalState { - return ( - typeof value === "object" && - value !== null && - "rows" in value && - "cols" in value && - "grid" in value - ); -} - export default function TerminalEmulator({ ref, streamKey, @@ -167,12 +156,12 @@ export default function TerminalEmulator({ cursor: "#e6e6e6", }, scrollbackLines, + characterWidths, fontFamily, fontSize, swipeGesturesEnabled = false, onSwipeLeft, onSwipeRight, - initialSnapshot = null, onFindRequest, onFindResult, onInput, @@ -197,6 +186,8 @@ export default function TerminalEmulator({ const fontSizeRef = useRef(fontSize); const scrollbackLinesRef = useRef(scrollbackLines); scrollbackLinesRef.current = scrollbackLines; + const characterWidthsRef = useRef(characterWidths); + characterWidthsRef.current = characterWidths; fontFamilyRef.current = fontFamily; fontSizeRef.current = fontSize; const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); @@ -228,8 +219,6 @@ export default function TerminalEmulator({ onOpenLocalFileLink, onFilePaste, }; - const initialSnapshotRef = useRef(initialSnapshot); - initialSnapshotRef.current = initialSnapshot; const pendingModifiersRef = useRef(pendingModifiers); pendingModifiersRef.current = pendingModifiers; @@ -253,17 +242,17 @@ export default function TerminalEmulator({ const data = args[0]; if (data instanceof Uint8Array) runtimeRef.current?.write({ data }); }, - restoreOutput: (...args) => { - const data = args[0]; - if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data }); - }, - renderSnapshot: (...args) => { - const state = args[0]; - if (state === null) { - runtimeRef.current?.renderSnapshot({ state: null }); - } else if (isTerminalState(state)) { - runtimeRef.current?.renderSnapshot({ state }); - } + replay: (event) => { + if (!event || typeof event !== "object" || Array.isArray(event)) return; + if (event.type === "end") runtimeRef.current?.replay({ type: "end" }); + if (event.type === "data" && event.data instanceof Uint8Array) + runtimeRef.current?.replay({ type: "data", data: event.data }); + if (event.type === "begin") + runtimeRef.current?.replay({ + type: "begin", + rows: typeof event.rows === "number" ? event.rows : undefined, + cols: typeof event.cols === "number" ? event.cols : undefined, + }); }, paste: (...args) => { const text = args[0]; @@ -299,12 +288,7 @@ export default function TerminalEmulator({ writeOutput: (data: TerminalOutputData) => { runtimeRef.current?.write({ data }); }, - restoreOutput: (data: TerminalOutputData) => { - runtimeRef.current?.restoreOutput({ data }); - }, - renderSnapshot: (state: TerminalState | null) => { - runtimeRef.current?.renderSnapshot({ state }); - }, + replay: (event) => runtimeRef.current?.replay(event), paste: (text: string) => { pasteText(text); }, @@ -336,6 +320,10 @@ export default function TerminalEmulator({ runtimeRef.current?.setScrollback({ lines: scrollbackLines }); }, [scrollbackLines]); + useEffect(() => { + runtimeRef.current?.setCharacterWidths({ widths: characterWidths }); + }, [characterWidths]); + useEffect(() => { const root = rootRef.current; if (!root || !swipeGesturesEnabled) { @@ -468,10 +456,10 @@ export default function TerminalEmulator({ }, }); runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current }); + runtime.setCharacterWidths({ widths: characterWidthsRef.current }); runtime.mount({ root, host, - initialSnapshot: initialSnapshotRef.current, scrollback: scrollbackLinesRef.current, theme: mountedThemeRef.current, fontFamily: fontFamilyRef.current, diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 6c1561bc5b..4c5e74ef6d 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,5 +1,6 @@ import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find"; import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime"; +import type { TerminalCharacterWidths } from "@/terminal/character-widths"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -15,7 +16,6 @@ import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated" import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native"; import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import { DEFAULT_TERMINAL_INPUT_MODE_STATE, type TerminalInputModeState, @@ -60,7 +60,6 @@ import { getDesktopHost } from "@/desktop/host"; import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop"; import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path"; import { toErrorMessage } from "@/utils/error-messages"; -import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; import { EMPTY_FOCUS_CLAIM_STATE, canRequestFocusClaim, @@ -88,7 +87,6 @@ import { isNative } from "@/constants/platform"; import { applyTerminalRendererReadyChange, resolveTerminalStreamTarget, - shouldReplayTerminalSnapshotForRenderer, shouldShowTerminalLoadingOverlay, type TerminalRendererReadyChange, } from "@/utils/terminal-renderer-readiness"; @@ -307,6 +305,11 @@ export function TerminalPane({ const supportsTerminalSizeOwnership = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true, ); + const characterWidths: TerminalCharacterWidths = useSessionStore((state) => + state.sessions[serverId]?.serverInfo?.features?.["terminal-grapheme-widths"] === true + ? "graphemes" + : "legacy", + ); const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); @@ -316,10 +319,6 @@ export function TerminalPane({ const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); - const workspaceTerminalSession = useMemo( - () => getWorkspaceTerminalSession({ scopeKey }), - [scopeKey], - ); const [isAttaching, setIsAttaching] = useState(false); const [streamError, setStreamError] = useState(null); const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState(null); @@ -383,7 +382,6 @@ export function TerminalPane({ const keyboardRefitTimeoutsRef = useRef>>([]); const lastAutoFocusKeyRef = useRef(null); const paneFocusResizeClaimRef = useRef(EMPTY_FOCUS_CLAIM_STATE); - const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId }); useEffect(() => { terminalIdRef.current = terminalId; @@ -441,16 +439,8 @@ export function TerminalPane({ (change: TerminalRendererReadyChange) => { if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset(); setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change)); - if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) { - return; - } - - const snapshot = workspaceTerminalSession.snapshots.get({ terminalId }); - if (snapshot) { - emulatorRef.current?.renderSnapshot(snapshot); - } }, - [terminalId, terminalStreamKey, workspaceTerminalSession.snapshots], + [terminalStreamKey], ); useEffect(() => { @@ -564,7 +554,6 @@ export function TerminalPane({ ); const handleStreamExit = useStableEvent((exitedTerminalId: string) => { - workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId }); if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear(); setModifiers({ ...EMPTY_MODIFIERS }); }); @@ -604,29 +593,10 @@ export function TerminalPane({ }, ); - const handleStreamRestore = useStableEvent( - ({ terminalId: restoreTerminalId, data }: { terminalId: string; data: Uint8Array }) => { - workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId }); - if (terminalIdRef.current !== restoreTerminalId) { - return; - } - emulatorRef.current?.restoreOutput(data); - }, - ); - - const handleStreamSnapshot = useStableEvent( - ({ terminalId: snapshotTerminalId, state }: { terminalId: string; state: TerminalState }) => { - workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state }); - if (terminalIdRef.current !== snapshotTerminalId) { - return; - } - emulatorRef.current?.renderSnapshot(state); - }, - ); - const getStreamRestoreOptions = useStableEvent(() => resolveTerminalRestoreOptions({ supportsTerminalRestoreModes, + scrollbackLines: settings.terminalScrollbackLines, canClaimSize: canRequestFocusClaim({ isWorkspaceFocused: terminalPresentedRef.current, isPaneFocused, @@ -653,8 +623,9 @@ export function TerminalPane({ client, getPreferredSize: getPreferredStreamSize, onOutput: handleStreamOutput, - onRestore: handleStreamRestore, - onSnapshot: handleStreamSnapshot, + onReplay: ({ terminalId: id, replay }) => { + if (terminalIdRef.current === id) emulatorRef.current?.replay(replay); + }, onExit: handleStreamExit, getRestoreOptions: getStreamRestoreOptions, onStatusChange: handleStreamControllerStatus, @@ -674,8 +645,6 @@ export function TerminalPane({ getStreamRestoreOptions, handleStreamControllerStatus, handleStreamOutput, - handleStreamRestore, - handleStreamSnapshot, handleStreamExit, isConnected, ]); @@ -1208,6 +1177,7 @@ export function TerminalPane({ dom={TERMINAL_EMULATOR_DOM_PROPS} streamKey={terminalStreamKey} supportsTerminalInputModeReplay={supportsTerminalInputModeReplay} + characterWidths={characterWidths} testId="terminal-surface" xtermTheme={xtermTheme} scrollbackLines={settings.terminalScrollbackLines} @@ -1216,7 +1186,6 @@ export function TerminalPane({ keyboardInset={keyboardInset} isKeyboardVisible={isKeyboardVisible} swipeGesturesEnabled={swipeGesturesEnabled} - initialSnapshot={initialSnapshot} onRendererReadyChange={handleRendererReadyChange} onSwipeRight={handleSwipeRight} onSwipeLeft={handleSwipeLeft} diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index a10bd7b0c6..5e08f944df 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -64,7 +64,7 @@ function resolveShouldFill( ): boolean { if (!fillAvailableHeight) return false; const t = detail?.type; - return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent"; + return t === "shell" || t === "edit" || t === "write" || t === "read"; } function useDetailStyles( @@ -186,184 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) { ); } -function resolveSubAgentFallbackHeader( - subAgentType: string | null | undefined, - description: string | null | undefined, - fallbackText: string, -): string { - if (subAgentType && description) { - return `${subAgentType}: ${description}`; - } - return subAgentType ?? description ?? fallbackText; -} - -interface SubAgentDetailProps { - log: string; - childSessionId: string | null | undefined; - subAgentType: string | null | undefined; - description: string | null | undefined; - ds: DetailStyles; -} - -interface SubAgentActivityRow { - index: number; - toolName: string; - summary?: string; -} - -interface ParsedSubAgentLog { - actions: SubAgentActivityRow[]; - remainingLog: string; -} - -function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null { - const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/); - if (!match) { - return null; - } - const toolName = match[1]?.trim(); - if (!toolName) { - return null; - } - const summary = match[2]?.trim(); - return { - index, - toolName, - ...(summary ? { summary } : {}), - }; -} - -function parseSubAgentLog(log: string): ParsedSubAgentLog { - const actions: SubAgentActivityRow[] = []; - const remainingLines: string[] = []; - for (const line of log.replace(/^\n+/, "").split("\n")) { - const normalizedLine = line.trim(); - if (!normalizedLine) { - continue; - } - const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1); - if (parsedAction) { - actions.push(parsedAction); - } else { - remainingLines.push(line); - } - } - return { - actions, - remainingLog: remainingLines.join("\n").replace(/^\n+/, ""), - }; -} - -function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) { - return ( - - - {formatSubAgentToolName(action.toolName)} - - {action.summary ? ( - - {action.summary} - - ) : null} - - ); -} - -function formatSubAgentToolName(toolName: string): string { - const trimmed = toolName.trim(); - if (!trimmed) { - return toolName; - } - return trimmed - .replace(/[._-]+/g, " ") - .split(" ") - .filter((segment) => segment.length > 0) - .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`) - .join(" "); -} - -function SubAgentLogText({ - activityLog, - fallbackHeader, - hasActions, -}: { - activityLog: string; - fallbackHeader: string; - hasActions: boolean; -}) { - if (activityLog.length > 0) { - return ( - - {activityLog} - - ); - } - if (!hasActions) { - return ( - - {fallbackHeader} - - ); - } - return null; -} - -function SubAgentDetailSection({ - log, - childSessionId, - subAgentType, - description, - ds, -}: SubAgentDetailProps) { - const { t } = useTranslation(); - const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]); - const fallbackHeader = resolveSubAgentFallbackHeader( - subAgentType, - description, - t("toolCallDetails.subAgentActivity"), - ); - const hasActions = actions.length > 0; - return ( - - - - - - {childSessionId ? ( - - session {childSessionId} - - ) : null} - {hasActions ? ( - - {actions.map((action) => ( - - ))} - - ) : null} - - - - - - - ); -} - interface EditDetailProps { diffLines: DiffLine[] | undefined; ds: DetailStyles; @@ -636,19 +458,7 @@ function buildDetailSections( , ]; } - if (detail.type === "worktree_setup") return []; - if (detail.type === "sub_agent") { - return [ - , - ]; - } + if (detail.type === "worktree_setup" || detail.type === "sub_agent") return []; if (detail.type === "edit") { return []; } @@ -900,34 +710,6 @@ const styles = StyleSheet.create((theme) => { shellPrompt: { color: theme.colors.foregroundMuted, }, - subAgentSessionText: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foregroundMuted, - lineHeight: 18, - marginBottom: theme.spacing[2], - }, - subAgentActions: { - gap: theme.spacing[1], - marginBottom: theme.spacing[2], - }, - subAgentActionRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - subAgentActionTool: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foregroundMuted, - lineHeight: 18, - }, - subAgentActionSummary: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foreground, - lineHeight: 18, - }, jsonScroll: { borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index fa5e724944..e74c068658 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -5,9 +5,9 @@ import { useRef, useState, type ReactElement, - type RefObject, + type ReactNode, } from "react"; -import { View, useWindowDimensions } from "react-native"; +import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native"; import { Portal } from "@gorhom/portal"; import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; @@ -32,7 +32,8 @@ interface RelativeAnchorRect { interface AutocompletePopoverProps { visible: boolean; - anchorRef: RefObject; + children: ReactNode; + anchorStyle?: StyleProp; options: readonly AutocompleteOption[]; selectedIndex: number; onSelect: (option: AutocompleteOption) => void; @@ -44,7 +45,8 @@ interface AutocompletePopoverProps { export function AutocompletePopover({ visible, - anchorRef, + children, + anchorStyle, options, selectedIndex, onSelect, @@ -55,6 +57,7 @@ export function AutocompletePopover({ }: AutocompletePopoverProps): ReactElement | null { "use no memo"; // React Compiler memoizes effect captures by reading SharedValue.value during render. + const anchorRef = useRef(null); const [relativeAnchorRect, setRelativeAnchorRect] = useState(null); const windowDimensions = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); @@ -137,25 +140,29 @@ export function AutocompletePopover({ [anchorY, reservedLift, safeAreaInsets.top], ); - if (!visible || !relativeAnchorRect || !baseStyle) return null; - if (options.length > 0 && selectedIndex < 0) return null; + const showPopover = canMeasure && relativeAnchorRect && baseStyle; // The translate view is the popover's own frame, so it lifts only the popover. return ( - - - - - - - + + {children} + {showPopover ? ( + + + + + + + + ) : null} + ); } diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 7b5b99b316..a9cb10f33e 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -247,14 +247,16 @@ export function Autocomplete({ {selectedOption?.kind === "command" && selectedOption.description ? ( - + {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label} - + {removeBoltGlyphs(selectedOption.description)} {selectedOption.detail ? ( - {removeBoltGlyphs(selectedOption.detail)} + + {removeBoltGlyphs(selectedOption.detail)} + ) : null} ) : null} @@ -293,6 +295,9 @@ const styles = StyleSheet.create((theme: Theme) => ({ gap: theme.spacing[1], }, detailCard: { + flexShrink: 1, + minHeight: 0, + overflow: "hidden", backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, @@ -318,7 +323,8 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, container: { flexShrink: 1, - minHeight: 0, + // Keep a complete selectable row (36), list padding (8), and borders (2). + minHeight: 46, backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 707ce60648..e4a9a13b2b 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -1,4 +1,5 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useContainedControlSize } from "@/components/ui/control-size"; import { default as React, useCallback, @@ -162,11 +163,12 @@ const styles = StyleSheet.create((theme) => { export function Button({ children, variant = "secondary", - size = "md", + size: sizeProp, leftIcon, trailing, style, textStyle, + numberOfLines, disabled, loading = false, accessibilityRole, @@ -181,10 +183,13 @@ export function Button({ trailing?: ReactNode; style?: StyleProp; textStyle?: StyleProp; + numberOfLines?: number; loading?: boolean; } >) { const [hovered, setHovered] = useState(false); + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const isDisabled = disabled || loading; let variantStyle: ViewStyle; @@ -274,7 +279,11 @@ export function Button({ iconSize={buttonIconSize[size]} uniProps={resolveIconMapping()} /> - {children != null ? {children} : null} + {children != null ? ( + + {children} + + ) : null} {trailing} ); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 84d76c1083..adcd91afcb 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,3 +1,4 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, @@ -12,7 +13,6 @@ import { View, Text, Pressable, - Modal, ScrollView, Platform, StatusBar, @@ -938,7 +938,6 @@ interface MobileBodyProps { handleSelect: (id: string) => void; renderOption: RenderOptionFn | undefined; children: ReactNode; - safeAreaBottom: number; } function MobileComboboxBody(props: MobileBodyProps): ReactElement | null { @@ -961,21 +960,6 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement { () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], ); - const { footer, safeAreaBottom } = props; - const frameStyle = useMemo( - () => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }], - [footer, safeAreaBottom], - ); - const footerStyle = useMemo( - () => [styles.footer, { paddingBottom: safeAreaBottom }], - [safeAreaBottom], - ); - - const footerView = useMemo( - () => (footer ? {footer} : undefined), - [footer, footerStyle], - ); - const body = props.hasChildren ? ( props.children ) : ( @@ -1006,51 +990,74 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement { keyboardBlurBehavior="none" presentation={props.presentation} > - - - {props.header ? ( - - ) : ( - <> - - - {props.title} - - - {props.stickyHeader} - {!props.hasChildren && props.searchable ? ( - - ) : null} - - )} - {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( - body - ) : ( - - {body} - - )} - - + + {props.header ? ( + + ) : ( + <> + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + )} + {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( + body + ) : ( + + {body} + + )} + ); } +/** + * Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the + * one left where the combobox was opened: inside a sheet tab, none is. + */ +function ComboboxSheetFrame({ footer, children }: { footer?: ReactNode; children: ReactNode }) { + const { bottom: safeAreaBottom } = useSafeAreaInsets(); + const frameStyle = useMemo( + () => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }], + [footer, safeAreaBottom], + ); + const footerStyle = useMemo( + () => [styles.footer, { paddingBottom: safeAreaBottom }], + [safeAreaBottom], + ); + const footerView = useMemo( + () => (footer ? {footer} : undefined), + [footer, footerStyle], + ); + return ( + + {children} + + ); +} + interface DesktopBodyProps { overlayLayer: number; isOpen: boolean; @@ -1327,7 +1334,6 @@ export function Combobox({ const isMobile = useIsCompactFormFactor(); const active = useRetainedPanelActive(); const floatingLayer = useOverlayLayer("floating"); - const safeAreaInsets = useSafeAreaInsets(); const titleColor = theme.colors.foreground; const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition); const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition); @@ -1608,7 +1614,6 @@ export function Combobox({ emptyText={resolvedEmptyText} handleSelect={handleSelect} renderOption={renderOption} - safeAreaBottom={safeAreaInsets.bottom} > {children} diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 0b0675596f..3544d9f940 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -81,6 +81,8 @@ describe("control geometry", () => { it("keeps field text sizing tied to control size", () => { const geometry = createControlGeometry(theme); + expect(geometry.fieldTextXs.fontSize).toBe(12); + expect(geometry.fieldTextXs.lineHeight).toBe(17); expect(geometry.fieldTextSm.fontSize).toBe(14); expect(geometry.fieldTextSm.lineHeight).toBe(20); expect(geometry.fieldTextMd.fontSize).toBe(14); @@ -95,6 +97,13 @@ describe("control geometry", () => { const geometry = createControlGeometry(theme); const borderWidth = theme.borderWidth[1]; + expect(geometry.fieldControlXs.minHeight).toBe(28); + expect( + geometry.fieldTextXs.lineHeight + + geometry.fieldControlXs.paddingVertical * 2 + + borderWidth * 2, + ).toBe(geometry.fieldControlXs.minHeight); + expect(geometry.fieldControlSm.minHeight).toBe(32); expect(geometry.fieldControlSm.paddingVertical).toBe(5); expect( diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index d56200887e..0a41f80039 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -2,7 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native"; import { ICON_SIZE, type Theme } from "@/styles/theme"; export type ButtonControlSize = "xs" | "sm" | "md" | "lg"; -export type FieldControlSize = "sm" | "md"; +export type FieldControlSize = "xs" | "sm" | "md"; export type SegmentedControlSize = "xs" | "sm" | "md"; export type ControlInteractionPhase = "rest" | "hover" | "active"; @@ -188,8 +188,19 @@ export function resolveControlInteractionStyles( export function createControlGeometry(theme: Theme) { const controlBorderWidth = theme.borderWidth[1]; + const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm); const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base); const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base); + const fieldControlXs = { + minHeight: CONTROL_HEIGHTS.tight, + paddingHorizontal: theme.spacing[2], + paddingVertical: fieldVerticalPadding( + CONTROL_HEIGHTS.tight, + fieldTextXsLineHeight, + controlBorderWidth, + ), + borderRadius: theme.borderRadius.md, + }; const fieldControlSm = { minHeight: CONTROL_HEIGHTS.compact, paddingHorizontal: theme.spacing[3], @@ -210,6 +221,10 @@ export function createControlGeometry(theme: Theme) { ), borderRadius: theme.borderRadius.lg, }; + const fieldTextXs = { + fontSize: theme.fontSize.sm, + lineHeight: fieldTextXsLineHeight, + }; const fieldTextSm = { fontSize: theme.fontSize.base, lineHeight: fieldTextSmLineHeight, @@ -252,6 +267,10 @@ export function createControlGeometry(theme: Theme) { fontSize: theme.fontSize.sm, }, alert, + formTextInputXs: { + ...fieldControlXs, + ...fieldTextXs, + }, formTextInputSm: { ...fieldControlSm, ...fieldTextSm, @@ -264,8 +283,10 @@ export function createControlGeometry(theme: Theme) { ...fieldControlMd, ...fieldTextMd, }, + fieldControlXs, fieldControlSm, fieldControlMd, + fieldTextXs, fieldTextSm, fieldTextMd, controlRest: { diff --git a/packages/app/src/components/ui/control-size.tsx b/packages/app/src/components/ui/control-size.tsx new file mode 100644 index 0000000000..c6bcbbea78 --- /dev/null +++ b/packages/app/src/components/ui/control-size.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext, type ReactNode } from "react"; + +/** The control sizes a container can ask of the controls placed in it. */ +export type ContainedControlSize = "xs" | "sm"; + +// A row decides how big its controls are, so a button or field placed in it needs no size of +// its own. A size passed to the control still wins. +const ControlSizeContext = createContext(null); + +export function ControlSizeProvider({ + size, + children, +}: { + size: ContainedControlSize; + children: ReactNode; +}) { + return {children}; +} + +/** The size the surrounding container asks for, or null outside one. */ +export function useContainedControlSize(): ContainedControlSize | null { + return useContext(ControlSizeContext); +} diff --git a/packages/app/src/components/ui/dropdown-trigger.tsx b/packages/app/src/components/ui/dropdown-trigger.tsx index 2fc1eb26ac..a276e40652 100644 --- a/packages/app/src/components/ui/dropdown-trigger.tsx +++ b/packages/app/src/components/ui/dropdown-trigger.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu"; +import { useContainedControlSize } from "@/components/ui/control-size"; const ThemedChevronDown = withUnistyles(ChevronDown); @@ -32,10 +33,12 @@ interface DropdownTriggerProps extends Omit { color: string }> = { + surface3: (theme) => ({ color: theme.colors.surface3 }), surface0: (theme) => ({ color: theme.colors.surface0 }), surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }), }; diff --git a/packages/app/src/components/ui/form-field.tsx b/packages/app/src/components/ui/form-field.tsx index f55b10bb10..349d658e4f 100644 --- a/packages/app/src/components/ui/form-field.tsx +++ b/packages/app/src/components/ui/form-field.tsx @@ -23,6 +23,16 @@ import { resolveControlInteractionStyles, type FieldControlSize, } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; + +const FIELD_CHROME = { xs: "chromeXs", sm: "chromeSm", md: "chromeMd" } as const satisfies Record< + FieldControlSize, + string +>; +const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record< + FieldControlSize, + string +>; interface FieldProps { label: string; @@ -159,11 +169,13 @@ function assignTextInputRef( } export const FormTextInput = forwardRef( - function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) { + function FormTextInput({ size: sizeProp, style, onFocus, onBlur, editable, ...props }, ref) { const [focused, setFocused] = useState(false); + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const isDisabled = editable === false; - const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd; - const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd; + const chromeSizeStyle = formInputStyles[FIELD_CHROME[size]]; + const inputSizeStyle = formInputStyles[FIELD_TEXT[size]]; const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]); const setInputRef = useCallback( (node: EditingTextInputHandle | null) => { @@ -262,6 +274,9 @@ const formInputStyles = StyleSheet.create((theme) => { chrome: { backgroundColor: theme.colors.surface2, }, + chromeXs: { + ...geometry.fieldControlXs, + }, chromeSm: { ...geometry.fieldControlSm, }, @@ -289,6 +304,9 @@ const formInputStyles = StyleSheet.create((theme) => { outlineColor: "transparent", outlineWidth: 0, }, + inputXs: { + ...geometry.fieldTextXs, + }, inputSm: { ...geometry.fieldTextSm, }, diff --git a/packages/app/src/components/ui/icon-color.ts b/packages/app/src/components/ui/icon-color.ts index 1093064423..f7891dabc4 100644 --- a/packages/app/src/components/ui/icon-color.ts +++ b/packages/app/src/components/ui/icon-color.ts @@ -1,5 +1,9 @@ import type { Theme } from "@/styles/theme"; +export const foregroundIconColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); + export const mutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx index cf820ac86c..6637c5e339 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx @@ -58,6 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => { }); import { IsolatedBottomSheetModal, type ContextBridge } from "."; +import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout"; import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope"; /** Renders whatever the portal is currently holding, from its own place in the tree. */ @@ -200,3 +201,35 @@ describe("IsolatedBottomSheetModal context bridging", () => { ).toEqual(["false", "true"]); }); }); + +describe("bottom sheet layout subscription", () => { + it("stops updating a dismissed sheet when the window resizes", () => { + const container = document.createElement("div"); + const root = createRoot(container); + let layout: ReturnType | undefined; + const viewport = document.documentElement; + const originalWidth = Object.getOwnPropertyDescriptor(viewport, "clientWidth"); + function resize(width: number) { + Object.defineProperty(viewport, "clientWidth", { configurable: true, value: width }); + act(() => window.dispatchEvent(new Event("resize"))); + } + function LayoutProbe() { + layout = useAnimatedLayout(undefined, 0, 0); + return null; + } + try { + act(() => root.render()); + if (!layout) throw new Error("Sheet layout did not mount"); + const mountedLayout = layout; + resize(640); + expect(mountedLayout.get().window.width).toBe(640); + act(() => root.render(null)); + resize(960); + expect(mountedLayout.get().window.width).toBe(640); + } finally { + act(() => root.unmount()); + if (originalWidth) Object.defineProperty(viewport, "clientWidth", originalWidth); + else Reflect.deleteProperty(viewport, "clientWidth"); + } + }); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 457181c53f..639804bd53 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -123,19 +123,25 @@ export function useIsolatedBottomSheetVisibility({ visible, isEnabled, onClose, + onBack, }: { visible: boolean; isEnabled?: boolean; onClose: () => void; + /** Offered Android Back before it closes the sheet; returns true when it handled the press. */ + onBack?: () => boolean; }) { const onCloseRef = useRef(onClose); onCloseRef.current = onClose; + const onBackRef = useRef(onBack); + onBackRef.current = onBack; const tracker = useMemo( () => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current(), backPress: systemBackPress, + onBack: () => onBackRef.current?.() ?? false, }), [], ); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts index 60b386bd45..5205fb2486 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts @@ -204,6 +204,31 @@ describe("bottom sheet visibility tracker", () => { expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]); }); + it("lets the sheet step back through its own pages before Back dismisses it", () => { + const sheet = new FakeBottomSheet(); + const backPress = new FakeBackPress(); + let openPages = 1; + const tracker = createBottomSheetVisibilityTracker({ + onClose: () => {}, + onBack: () => { + if (openPages === 0) return false; + openPages -= 1; + return true; + }, + backPress, + }); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + tracker.handleSheetIndexChange(0); + + expect(backPress.press()).toBe(true); + expect(openPages).toBe(0); + expect(sheet.events).toEqual([{ type: "present" }]); + + expect(backPress.press()).toBe(true); + expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]); + }); + it("leaves Back to the navigator before the sheet is on screen and after it closes", () => { const context = setup(); context.tracker.attachController(context.sheet); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts index dec2dd535c..167ed2ebe6 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts @@ -36,6 +36,11 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing"; export function createBottomSheetVisibilityTracker(opts: { onClose: () => void; backPress?: BackPressSource; + /** + * Offered the Back press before the sheet takes it as a close. Returns true when it handled + * the press itself, such as stepping back a page inside the sheet. + */ + onBack?: () => boolean; }): BottomSheetVisibilityTracker { let controller: BottomSheetController | null = null; let visible = false; @@ -60,6 +65,7 @@ export function createBottomSheetVisibilityTracker(opts: { } releaseBackPress = opts.backPress?.subscribe(() => { + if (opts.onBack?.()) return true; dismiss(); return true; }) ?? null; diff --git a/packages/app/src/components/ui/menu/index.ts b/packages/app/src/components/ui/menu/index.ts index 5668556899..437090d146 100644 --- a/packages/app/src/components/ui/menu/index.ts +++ b/packages/app/src/components/ui/menu/index.ts @@ -12,6 +12,8 @@ export { MenuRoot, MenuTrigger, type MenuTriggerProps, type MenuTriggerState } f export { MenuSurface, useMenuSurface, + type MenuFillPageDefinition, + type MenuListPageDefinition, type MenuPageDefinition, type MenuSurfaceProps, } from "./menu-surface"; diff --git a/packages/app/src/components/ui/menu/menu-context.test.tsx b/packages/app/src/components/ui/menu/menu-context.test.tsx index 40b45b9343..5640b7ef82 100644 --- a/packages/app/src/components/ui/menu/menu-context.test.tsx +++ b/packages/app/src/components/ui/menu/menu-context.test.tsx @@ -61,3 +61,38 @@ describe("useMenuState selectItem elsewhere", () => { expect(onSelect).toHaveBeenCalledTimes(1); }); }); + +describe("useMenuState pages", () => { + it("pops the open page on Back and leaves Back at the root to the surface", () => { + const { result } = renderHook(() => useMenuState({ defaultOpen: true })); + act(() => result.current.openSub({ id: "row", depth: 0 })); + + let handled = false; + act(() => { + handled = result.current.goBack(); + }); + expect(handled).toBe(true); + expect(result.current.path).toEqual([]); + + act(() => { + handled = result.current.goBack(); + }); + expect(handled).toBe(false); + expect(result.current.open).toBe(true); + }); + + it("falls back to the list when the open page's definition goes away", () => { + const { result } = renderHook(() => useMenuState({ defaultOpen: true })); + act(() => result.current.openSub({ id: "row", depth: 0 })); + + const keepNoPage = () => false; + act(() => result.current.prunePages(keepNoPage)); + + expect(result.current.path).toEqual([]); + let handled = true; + act(() => { + handled = result.current.goBack(); + }); + expect(handled).toBe(false); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-context.ts b/packages/app/src/components/ui/menu/menu-context.ts index 9635f09c9f..502580c308 100644 --- a/packages/app/src/components/ui/menu/menu-context.ts +++ b/packages/app/src/components/ui/menu/menu-context.ts @@ -7,6 +7,7 @@ import { goBack as goBackPath, MENU_ROOT_PATH, openSubPage, + pruneMissingPages, type MenuPath, } from "./menu-navigation"; import type { Rect } from "../anchor"; @@ -40,7 +41,10 @@ export interface MenuContextValue { path: MenuPath; openSub: (sub: { id: string; depth: number }) => void; closeSub: (depth: number) => void; - goBack: () => void; + /** Pops the open page. False at the root, where Back is the surface's to answer. */ + goBack: () => boolean; + /** Closes any open page that no longer has a definition, falling back to its parent. */ + prunePages: (hasPage: (id: string) => boolean) => void; } const MenuContext = createContext(null); @@ -160,7 +164,13 @@ export function useMenuState({ }, []); const goBack = useCallback(() => { + if (path.length === 0) return false; setPath((current) => goBackPath(current)); + return true; + }, [path.length]); + + const prunePages = useCallback((hasPage: (id: string) => boolean) => { + setPath((current) => pruneMissingPages(current, hasPage)); }, []); return useMemo( @@ -176,7 +186,19 @@ export function useMenuState({ openSub, closeSub, goBack, + prunePages, }), - [isOpen, setOpen, selectItem, anchorRect, presentation, path, openSub, closeSub, goBack], + [ + isOpen, + setOpen, + selectItem, + anchorRect, + presentation, + path, + openSub, + closeSub, + goBack, + prunePages, + ], ); } diff --git a/packages/app/src/components/ui/menu/menu-navigation.test.ts b/packages/app/src/components/ui/menu/menu-navigation.test.ts index 4b447ef3f2..9740f3df52 100644 --- a/packages/app/src/components/ui/menu/menu-navigation.test.ts +++ b/packages/app/src/components/ui/menu/menu-navigation.test.ts @@ -6,6 +6,7 @@ import { isSubPageOpen, MENU_ROOT_PATH, openSubPage, + pruneMissingPages, } from "./menu-navigation"; describe("openSubPage", () => { @@ -73,3 +74,15 @@ describe("isSubPageOpen", () => { expect(isSubPageOpen(path, { id: "grouping", depth: 0 })).toBe(false); }); }); + +describe("pruneMissingPages", () => { + it("falls back to the deepest page that still exists", () => { + expect(pruneMissingPages(["list", "row"], (id) => id === "list")).toEqual(["list"]); + expect(pruneMissingPages(["list", "row"], (id) => id === "row")).toEqual([]); + }); + + it("returns the same path when every page exists", () => { + const path = ["list", "row"]; + expect(pruneMissingPages(path, () => true)).toBe(path); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-navigation.ts b/packages/app/src/components/ui/menu/menu-navigation.ts index 2cccca9ed1..b38f9acf13 100644 --- a/packages/app/src/components/ui/menu/menu-navigation.ts +++ b/packages/app/src/components/ui/menu/menu-navigation.ts @@ -33,6 +33,16 @@ export function closeSubPage(path: MenuPath, depth: number): MenuPath { return path.slice(0, depth); } +/** + * Cuts the path back to the deepest page that still has a definition. A page can lose its + * definition while it is open (a subagent's page goes with its row when the subagent is + * archived), and the pages it opened go with it. + */ +export function pruneMissingPages(path: MenuPath, hasPage: (id: string) => boolean): MenuPath { + const missing = path.findIndex((id) => !hasPage(id)); + return missing === -1 ? path : path.slice(0, missing); +} + export function goBack(path: MenuPath): MenuPath { if (path.length === 0) return path; return path.slice(0, -1); diff --git a/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx b/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx new file mode 100644 index 0000000000..9d60353a52 --- /dev/null +++ b/packages/app/src/components/ui/menu/menu-overlay.browser.test.tsx @@ -0,0 +1,124 @@ +import React, { act, useEffect, useState, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Text, View } from "react-native"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { page } from "vitest/browser"; +import { MenuRoot, MenuSurface, MenuTrigger, type Size } from "./index"; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +/** + * A real browser, because the regression lives in Reanimated's web entering path: it snapshots + * the surface's rect when a custom keyframe ends and writes that rect back as inline + * top/left/width/height when it cleans the keyframe up, 750ms after the animation starts. + * Anything that moved or resized the surface in between is undone by that write. + */ + +interface Mounted { + root: Root; + container: HTMLDivElement; +} + +const mounted: Mounted[] = []; + +function mount(node: ReactNode): void { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(node)); + mounted.push({ root, container }); +} + +afterEach(() => { + for (const entry of mounted.splice(0)) { + act(() => entry.root.unmount()); + entry.container.remove(); + } +}); + +const OFFSET = 12; +const PILL_POSITION = { position: "absolute", top: 700, left: 40 } as const; +const SHORT: Size = { width: 200, height: 40 }; +const TALL: Size = { width: 200, height: 240 }; +const WIDE: Size = { width: 480, height: 40 }; +/** Inside the window between the entering animation ending and Reanimated's cleanup. */ +const GROWS_BEFORE_CLEANUP_MS = 400; +/** Past Reanimated's cleanup, where the surface used to keep a fixed height. */ +const GROWS_AFTER_CLEANUP_MS = 1_000; +/** Long enough after the content grew for anything that would write to the surface to land. */ +const SETTLE_MS = 1_200; + +/** Plain timers, not `act`: an async `act` holds every update until it resolves. */ +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function element(testID: string): HTMLElement { + const found = document.querySelector(`[data-testid="${testID}"]`); + if (!(found instanceof HTMLElement)) throw new Error(`${testID} did not render`); + return found; +} + +/** Stands in for a popover whose data lands after it opened, like a plugin's first RPC. */ +function GrowsAfterOpen({ afterMs, from, to }: { afterMs: number; from: Size; to: Size }) { + const [grown, setGrown] = useState(false); + useEffect(() => { + const timer = setTimeout(() => setGrown(true), afterMs); + return () => clearTimeout(timer); + }, [afterMs]); + return ; +} + +function mountMenuAbovePill(body: ReactNode): void { + mount( + + + + Open panel + + + {body} + + + , + ); + act(() => element("pill").click()); +} + +describe("anchored menu surface on web", () => { + beforeEach(async () => { + await page.viewport(1280, 900); + }); + + it.each([GROWS_BEFORE_CLEANUP_MS, GROWS_AFTER_CLEANUP_MS])( + "stays on its anchor when its content grows %ims after opening", + async (afterMs) => { + mountMenuAbovePill(); + + await wait(afterMs + SETTLE_MS); + + const pill = element("pill").getBoundingClientRect(); + const panel = element("panel").getBoundingClientRect(); + expect(panel.height).toBeGreaterThan(240); + expect(panel.bottom).toBeLessThanOrEqual(pill.top); + }, + ); + + it("widens when its content widens after the entering animation", async () => { + mountMenuAbovePill(); + + await wait(GROWS_BEFORE_CLEANUP_MS + SETTLE_MS); + + const body = element("body").getBoundingClientRect(); + const panel = element("panel").getBoundingClientRect(); + expect(panel.width).toBeGreaterThanOrEqual(body.width); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index 9631dbbe9a..3979c5b633 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -1,7 +1,9 @@ +import { Modal } from "@/components/ui/modal"; import { useCallback, useEffect, useId, + useLayoutEffect, useMemo, useState, type ReactElement, @@ -11,7 +13,6 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Dimensions, - Modal, Platform, Pressable, View, @@ -47,56 +48,41 @@ const contentEntering = new Keyframe({ 100: { opacity: 1, transform: [{ scale: 1 }] }, }).duration(CONTENT_ENTERING_DURATION_MS); +/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */ +const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = { + opacity: [0, 1], + transform: ["scale(0.97)", "scale(1)"], +}; + +/** Web plays the same keyframes itself; see `useWebContentEntering`. */ +const nativeContentEntering = isWeb ? undefined : contentEntering; + const contentExiting = new Keyframe({ 0: { opacity: 1, transform: [{ scale: 1 }] }, 100: { opacity: 0, transform: [{ scale: 0.97 }] }, }).duration(100); -function releaseFixedMenuHeight(surfaceNativeID: string): void { - if (!isWeb) return; - document.getElementById(surfaceNativeID)?.style.removeProperty("height"); -} - /** - * Reanimated's web entering animation leaves an inline height snapshot on the measured surface. - * Once the menu is open, height must return to content-sized so rows can grow in place — and so - * a submenu page taller than the page it replaced is not clipped to the old height. - * - * `revision` is what makes that second case work: bump it whenever the rendered content changes - * identity (a page push, for instance) and the snapshot is released again. + * On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's + * web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe + * up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that + * moved or resized in between — content that grew once its data arrived — snapped back to the old + * rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style. */ -function useReleaseFixedMenuHeight({ - contentSize, - enabled, +function useWebContentEntering({ + placed, surfaceNativeID, - revision, }: { - contentSize: Size | null; - enabled: boolean; + placed: boolean; surfaceNativeID: string; - revision?: string | number; }): void { - useEffect(() => { - if (!enabled) return undefined; - - const release = () => { - releaseFixedMenuHeight(surfaceNativeID); - if (typeof requestAnimationFrame === "function") { - requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID)); - } - }; - const timers: ReturnType[] = [ - setTimeout(release, CONTENT_ENTERING_DURATION_MS), - ]; - - if (contentSize) { - timers.push(setTimeout(release, 0)); - } - - return () => { - for (const timer of timers) clearTimeout(timer); - }; - }, [contentSize, enabled, surfaceNativeID, revision]); + useLayoutEffect(() => { + if (!isWeb || !placed || typeof document === "undefined") return undefined; + const animation = document + .getElementById(surfaceNativeID) + ?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS); + return () => animation?.cancel(); + }, [placed, surfaceNativeID]); } /** @@ -224,8 +210,6 @@ export interface AnchoredSurfaceProps { fullWidth?: boolean; horizontalPadding?: number; scrollable?: boolean; - /** Bump when the rendered content changes identity, so the height snapshot is released. */ - revision?: string | number; /** A submenu sits inside its parent's overlay and must not paint a second backdrop. */ backdrop?: boolean; /** @@ -259,7 +243,6 @@ export function AnchoredSurface({ fullWidth = false, horizontalPadding = 16, scrollable = false, - revision, backdrop = true, onPointerEnter, onPointerLeave, @@ -269,33 +252,25 @@ export function AnchoredSurface({ }: AnchoredSurfaceProps): ReactElement | null { const { t } = useTranslation(); const surfaceNativeID = useId(); - const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } = - useAnchoredPosition({ - open, - anchorRect, - anchorRef, - side, - align, - offset, - scrollable, - maxHeight, - }); - - useReleaseFixedMenuHeight({ - contentSize, - enabled: open, - surfaceNativeID, - revision, + const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({ + open, + anchorRect, + anchorRef, + side, + align, + offset, + scrollable, + maxHeight, }); // The surface is placed once both measurements land. Until then it sits off-screen so it can - // be measured, and it must not animate: Reanimated's web entering path snapshots the element's - // rect when the animation ends and writes that rect back as inline top/left when it cleans the - // keyframe up 750ms later. If the animation ends before placement, the snapshot is the - // off-screen rect and the menu jumps back off-screen. Remounting on placement starts the - // animation from the final position. + // be measured, and it must not animate: an entering animation that ran there would be over + // before anyone saw the menu. Remounting on placement starts the native animation from the + // final position; web starts its own animation on placement. const placed = position !== null; + useWebContentEntering({ placed, surfaceNativeID }); + useEffect(() => { if (!isWeb || !open || !placed || typeof document === "undefined") return undefined; const frame = requestAnimationFrame(() => { @@ -383,7 +358,7 @@ export function AnchoredSurface({ dataSet={surfaceDataSet} style={styles.content} frameStyle={frameStyle} - entering={placed ? contentEntering : undefined} + entering={placed ? nativeContentEntering : undefined} exiting={ !placed || isWeb || !onExited ? undefined diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 2f73149244..581b620499 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -19,6 +19,11 @@ import { useIsolatedBottomSheetVisibility, type ContextBridge, } from "@/components/ui/isolated-bottom-sheet-modal"; +import { + FULL_SHEET_SNAP_POINTS, + SheetBackground, + SheetHandle, +} from "@/components/adaptive-modal-sheet"; import { SPACING, type Theme } from "@/styles/theme"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useMenuContext, MenuContextProvider } from "./menu-context"; @@ -32,24 +37,6 @@ import type { KeyboardFocusScope } from "@/keyboard/actions"; const ThemedChevronLeft = withUnistyles(ChevronLeft); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not -// track, so the sheet is wrapped rather than reading the theme through a hook. -// See docs/unistyles.md. -const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({ - backgroundStyle: { - borderTopLeftRadius: theme.borderRadius.xl, - borderTopRightRadius: theme.borderRadius.xl, - borderWidth: 1, - backgroundColor: theme.colors.surface0, - borderColor: theme.colors.border, - }, - handleIndicatorStyle: { - width: 36, - height: 4, - backgroundColor: theme.colors.surface2, - }, -})); - /** How long the pointer must rest on a submenu row before its flyout opens. */ const HOVER_OPEN_DELAY_MS = 90; /** @@ -65,7 +52,7 @@ const HOVER_CLOSE_GRACE_MS = 260; */ const SUBMENU_OVERLAP = 5; -export interface MenuPageDefinition { +export interface MenuListPageDefinition { id: string; title: string; content: ReactNode; @@ -78,6 +65,24 @@ export interface MenuPageDefinition { * surface stops closing on hover, since its parent leads back to the same dismissal. */ hoverIntent?: boolean; + fill?: false; +} + +/** + * A sheet page that takes the sheet's full height and draws everything itself, header included: + * no back header, no scroller, no content panning. Only the sheet presentation opens one; a + * popover never has a trigger for it. + */ +export interface MenuFillPageDefinition { + id: string; + fill: true; + content: ReactNode; +} + +export type MenuPageDefinition = MenuListPageDefinition | MenuFillPageDefinition; + +function pageHoverIntent(page: MenuPageDefinition | undefined): boolean { + return !page || page.fill === true || page.hoverIntent !== false; } interface MenuSurfaceContextValue { @@ -126,8 +131,15 @@ export interface MenuSurfaceProps { * popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time. */ export function MenuSurface(props: MenuSurfaceProps): ReactElement | null { - const { presentation, open, setOpen } = useMenuContext("MenuSurface"); + const { presentation, open, setOpen, path, prunePages } = useMenuContext("MenuSurface"); const active = useRetainedPanelActive(); + // An open page whose definition went away (its row was removed) falls back to its parent, so + // neither a render nor a later Back walks a path to a page that no longer exists. + const { pages } = props; + useEffect(() => { + if (path.length === 0) return; + prunePages((id) => pages?.some((page) => page.id === id) ?? false); + }, [pages, path, prunePages]); // A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes // for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet // and promoted it to a tab, say, would otherwise bring the list back on the next tab switch. @@ -234,11 +246,11 @@ function MenuPopoverSurface({ // `hoverIntent: false` takes a page off the pointer entirely — it is not opened by resting on // its trigger, and while it is open nothing on this surface closes on a pointer leaving it. const hoverValue = useMemo(() => { - const locked = openPages.some(({ page }) => page.hoverIntent === false); + const locked = openPages.some(({ page }) => !pageHoverIntent(page)); return { ...surfaceValue, hoverOpen: (sub) => { - if (pages.find((page) => page.id === sub.id)?.hoverIntent === false) return; + if (!pageHoverIntent(pages.find((page) => page.id === sub.id))) return; surfaceValue.hoverOpen(sub); }, hoverClose: (depth) => { @@ -332,7 +344,6 @@ function MenuFlyout({ maxHeight={maxHeight} scrollable={scrollable} backdrop={false} - revision={page.id} onPointerEnter={cancelHoverClose} onPointerLeave={handleHoverOut} testID={testID} @@ -353,17 +364,6 @@ function MenuSheetSurface({ const { height: windowHeight } = useWindowDimensions(); const menu = useMenuContext("MenuSurface"); const { value: surfaceValue } = useSubAnchors(); - const safeAreaInsets = useSafeAreaInsets(); - - const sheetScrollContentStyle = useMemo( - () => ({ - paddingBottom: getMenuSheetBottomPadding({ - safeAreaBottom: safeAreaInsets.bottom, - breathingRoom: SPACING[1], - }), - }), - [safeAreaInsets.bottom], - ); const sheetDataSet = useMemo( () => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined), [keyboardFocusScope], @@ -374,11 +374,15 @@ function MenuSheetSurface({ visible: menu.open, isEnabled: true, onClose: handleClose, + // Android Back steps back through the pages before it closes the sheet. + onBack: menu.goBack, }); const openPageId = currentPageId(menu.path); const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null; const depth = menu.path.length; + const fillPage = openPage?.fill === true ? openPage : null; + const listPage = openPage?.fill === true ? null : openPage; // The sheet's content is teleported out of this subtree, so both menu contexts have to be // rebuilt on the other side. Providing them around the modal instead would put them on the @@ -393,12 +397,18 @@ function MenuSheetSurface({ ); return ( - - - {openPage ? ( - <> - - {openPage.content} - - ) : ( - <> - {sheetTitle ? ( - - ) : null} - {children} - - )} - - + {fillPage ? ( + + {fillPage.content} + + ) : ( + + {listPage ? ( + <> + + {listPage.content} + + ) : ( + <> + {sheetTitle ? ( + + ) : null} + {children} + + )} + + )} + + ); +} + +/** + * Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the + * one left where the menu was opened: inside a sheet tab, none is. + */ +function MenuSheetScrollView({ + dataSet, + testID, + children, +}: { + dataSet: { keyboardScope: KeyboardFocusScope } | undefined; + testID?: string; + children: ReactNode; +}) { + const safeAreaInsets = useSafeAreaInsets(); + const contentStyle = useMemo( + () => ({ + paddingBottom: getMenuSheetBottomPadding({ + safeAreaBottom: safeAreaInsets.bottom, + breathingRoom: SPACING[1], + }), + }), + [safeAreaInsets.bottom], + ); + return ( + + {children} + ); } @@ -470,6 +516,10 @@ function MenuSheetHeader({ } const styles = StyleSheet.create((theme) => ({ + fillPage: { + flex: 1, + minHeight: 0, + }, sheetHeader: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/ui/modal.tsx b/packages/app/src/components/ui/modal.tsx new file mode 100644 index 0000000000..884377fd8c --- /dev/null +++ b/packages/app/src/components/ui/modal.tsx @@ -0,0 +1,13 @@ +import { Modal as NativeModal, type ModalProps } from "react-native"; + +// React Native defaults iPhone modals to portrait even when the app supports rotation. +const orientations: NonNullable = [ + "portrait", + "portrait-upside-down", + "landscape-left", + "landscape-right", +]; + +export function Modal(props: ModalProps) { + return ; +} diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index e0bbdf3f7b..d828c26c4d 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -7,6 +7,7 @@ import { segmentedIconSize, type SegmentedControlSize, } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; import type { Theme } from "@/styles/theme"; type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode; @@ -48,11 +49,13 @@ export function SegmentedControl({ options, value, onValueChange, - size = "md", + size: sizeProp, hideLabels = false, style, testID, }: SegmentedControlProps) { + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const sizeStyles = { xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs }, sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm }, diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index ae00768a34..99f446001f 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -1,3 +1,4 @@ +import { Modal } from "@/components/ui/modal"; import { Children, cloneElement, @@ -16,7 +17,6 @@ import { createPortal } from "react-dom"; import { Dimensions, Platform, - Modal, Pressable, View, type PressableProps, @@ -27,6 +27,7 @@ import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { FloatingSurface } from "@/components/ui/floating"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { isNative, isWeb } from "@/constants/platform"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; @@ -256,8 +257,15 @@ export function Tooltip({ }); const isCompact = useIsCompactFormFactor(); + const active = useRetainedPanelActive(); const opensOnPress = isNative || isCompact; - const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop; + const enabled = active && (opensOnPress ? enabledOnMobile : enabledOnDesktop); + + // Remove the portal in the inactive commit, before the retained chat freezes. + // Closing also prevents the tooltip from returning when its panel is shown again. + useEffect(() => { + if (!active && isOpen) setIsOpen(false); + }, [active, isOpen, setIsOpen]); const value = useMemo( () => ({ @@ -329,6 +337,10 @@ export function TooltipTrigger({ }; }, [clearOpenTimer]); + useEffect(() => { + if (!ctx.enabled) clearOpenTimer(); + }, [ctx.enabled, clearOpenTimer]); + const handleHoverIn = useCallback( (e?: unknown) => { if (isCallable(onHoverIn)) onHoverIn(e); diff --git a/packages/app/src/components/user-message/index.tsx b/packages/app/src/components/user-message/index.tsx new file mode 100644 index 0000000000..e7bef2d177 --- /dev/null +++ b/packages/app/src/components/user-message/index.tsx @@ -0,0 +1,100 @@ +import { useState, useCallback, useMemo, type ReactNode } from "react"; +import { View, type LayoutChangeEvent, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; +import { Button } from "@/components/ui/button"; +import { EdgeFade } from "@/components/ui/edge-fade"; +import { useProviderIcon } from "@/components/provider-icons"; +import { useSessionStore } from "@/stores/session-store"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { isNative } from "@/constants/platform"; + +const PREVIEW_HEIGHT = 240; +// Native View still clips with "scroll", but Yoga measures its children without +// the height cap. "hidden" caps the measured body too, hiding the expand control. +const previewStyle: ViewStyle = { + maxHeight: PREVIEW_HEIGHT, + overflow: isNative ? "scroll" : "hidden", +}; + +export function MessagePreview({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const [overflowing, setOverflowing] = useState(false); + const measure = useCallback( + (event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT), + [], + ); + const toggle = useCallback(() => setExpanded((value) => !value), []); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + return ( + <> + + + {children} + + {overflowing && !expanded ? ( + + ) : null} + + {overflowing ? ( + + ) : null} + + ); +} + +export function MessageSender({ + sender, + serverId, +}: { + sender: AgentMessage["sender"]; + serverId?: string; +}) { + const { t } = useTranslation(); + const host = sender.serverId ?? serverId; + const agent = useSessionStore((state) => { + const session = host ? state.sessions[host] : undefined; + return session?.agents.get(sender.id) ?? session?.agentDetails.get(sender.id); + }); + const ProviderIcon = useProviderIcon(agent?.provider ?? "", host); + const renderProviderIcon = useCallback( + (color: string) => , + [ProviderIcon], + ); + const openSender = useCallback(() => { + if (host) navigateToAgent({ serverId: host, agentId: sender.id }); + }, [host, sender.id]); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { flexShrink: 0 }, + sender: { paddingRight: 0, borderRightWidth: 0, maxWidth: "100%" }, + senderText: { flexShrink: 1 }, + toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 }, + expandedToggle: { marginTop: theme.spacing[2] }, + fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] }, +})); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 8bc31bb176..82de9d3d73 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -2,7 +2,7 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useDraftStore } from "@/stores/draft-store"; import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; @@ -126,17 +126,13 @@ afterEach(async () => { }); }); -let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft; type DraftRecordForTest = ReturnType["drafts"][string]; -beforeAll(async () => { - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - - ({ useAgentInputDraft } = await import("./input-draft")); +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, }); +const { useAgentInputDraft } = await import("./input-draft"); describe("useAgentInputDraft live contract", () => { beforeEach(() => { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c14db9a119..2eb606a7f7 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { UserComposerAttachment } from "@/attachments/types"; import type { TextReplacement } from "@/composer/types"; import type { DraftAgentControlsProps } from "@/composer/agent-controls"; -import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { useAgentFormState, type CreateAgentInitialValues, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index d00b6f7790..c4ef45cb3f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -41,7 +41,7 @@ import { useWorkspaceAttachmentsStore, } from "@/attachments/workspace-attachments-store"; import type { UserMessageImageAttachment } from "@/types/stream"; -import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { buildWorkspaceTabPersistenceKey, @@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({ const editingSession = useComposerEditingSession("chat"); const isComposerFullscreen = useComposerFullscreen(editingSession); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( - COMPACT_FORM_FACTOR_WIDTH, + COMPACT_CONTENT_WIDTH, { initialIsBelow: isCompactFormFactor }, ); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ diff --git a/packages/app/src/composer/editing-session/index.test.ts b/packages/app/src/composer/editing-session/index.test.ts index 31c3765426..7dfcb0d438 100644 --- a/packages/app/src/composer/editing-session/index.test.ts +++ b/packages/app/src/composer/editing-session/index.test.ts @@ -2,6 +2,31 @@ import { describe, expect, it } from "vitest"; import { ComposerEditingSession, ComposerEditingSessions } from "./index"; describe("composer editing session", () => { + it("appends commands to the current editor buffer and focuses it", () => { + const session = new ComposerEditingSession(); + let text = "draft typed since publication"; + let focuses = 0; + let selection = { start: 0, end: 0 }; + const detach = session.attachEditor({ + getText: () => text, + replaceText: (next, nextSelection) => { + text = next; + selection = nextSelection; + }, + focus: () => { + focuses += 1; + }, + }); + session.insertCommand("/review"); + expect(selection).toEqual({ start: text.length, end: text.length }); + session.insertCommand("/test"); + expect(text).toBe("draft typed since publication /review /test "); + expect(focuses).toBe(2); + expect(selection).toEqual({ start: text.length, end: text.length }); + detach(); + session.insertCommand("/ignored"); + expect(text).toBe("draft typed since publication /review /test "); + }); it("publishes fullscreen changes once per transition", () => { const session = new ComposerEditingSession(); const changes: boolean[] = []; diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx index 4776b5af3d..237b15251a 100644 --- a/packages/app/src/composer/editing-session/index.tsx +++ b/packages/app/src/composer/editing-session/index.tsx @@ -30,10 +30,29 @@ export interface ComposerRetainedEditor { */ export class ComposerEditingSession { private fullscreen = false; + private editor: { + getText(): string; + replaceText(text: string, selection: ComposerSelection): void; + focus(): void; + } | null = null; private retained: ComposerRetainedEditor | null = null; private listeners = new Set<() => void>(); constructor(private readonly onChange?: () => void) {} getFullscreen = () => this.fullscreen; + attachEditor(editor: NonNullable) { + this.editor = editor; + return () => { + if (this.editor === editor) this.editor = null; + }; + } + insertCommand(command: string) { + const editor = this.editor; + if (!editor) return; + const text = editor.getText(); + const nextText = `${text}${text && !/\s$/.test(text) ? " " : ""}${command} `; + editor.replaceText(nextText, { start: nextText.length, end: nextText.length }); + editor.focus(); + } /** Called by the unmounting editor; the next mount of this session takes it. */ retainEditor(retained: ComposerRetainedEditor) { this.retained = retained; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index ed9ccca4f7..d6b4bae63f 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -55,7 +55,11 @@ import { } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { + selectAgentSentMessages, + selectAgentTurnPresentation, + useSessionStore, +} from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; import { useFileDrop } from "@/components/file-drop/use-file-drop"; import type { DroppedItem } from "@/components/file-drop/types"; @@ -67,7 +71,7 @@ import { } from "./input/input"; import type { ImageAttachment, MessagePayload, TextReplacement } from "./types"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { encodeImages } from "@/utils/encode-images"; import { focusWithRetries } from "@/utils/web-focus"; import { @@ -118,6 +122,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { submitAgentInput } from "@/composer/submit"; +import { ComposerRecall } from "@/composer/recall"; import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; @@ -995,20 +1000,24 @@ interface ComposerAutocompleteHandle { function ComposerAutocompleteBinding({ text, cursor, + recall, configuration, inputRef, - anchorRef, + children, + anchorStyle, show, ref, }: { text: ComposerTextSource; cursor: StoreApi; + recall: ComposerRecall; configuration: Omit< Parameters[0], "userInput" | "cursorIndex" | "onAutocompleteApplied" >; inputRef: React.RefObject; - anchorRef: React.RefObject; + children: React.ReactNode; + anchorStyle: React.ComponentProps["style"]; show: boolean; ref: React.Ref; }) { @@ -1020,9 +1029,11 @@ function ComposerAutocompleteBinding({ cursorIndex: Math.min(cursorIndex, userInput.length), onAutocompleteApplied: () => inputRef.current?.focus(), }); + // A recalled message is sent as is; its leading `/command` must not open the picker. + const isShown = show && !recall.isShowing(userInput); const handleKeyPress = autocomplete.onKeyPress; - useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [ - show, + useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [ + isShown, handleKeyPress, ]); const selectOption = autocomplete.onSelectOption; @@ -1032,8 +1043,8 @@ function ComposerAutocompleteBinding({ ); return ( + > + {children} + ); } @@ -1362,6 +1375,7 @@ function ComposerContentImpl({ ); useEffect(() => () => cursorPublication.cancel(), [cursorPublication]); const autocompleteRef = useRef(null); + const recall = useMemo(() => new ComposerRecall(), []); const [isProcessing, setIsProcessing] = useState(false); const [pendingFiles, setPendingFiles] = useState([]); const nextPendingFileId = useRef(0); @@ -1981,10 +1995,19 @@ function ComposerContentImpl({ const hasSendableContent = hasText || selectedAttachments.length > 0; - // Handle keyboard navigation for command autocomplete. + // An open autocomplete owns the arrows; otherwise they may recall sent messages. const handleCommandKeyPress = useCallback( - (event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false, - [], + (event: ComposerKeyPressEvent) => { + if (autocompleteRef.current?.onKeyPress(event)) return true; + const recalledText = recall.handleKeyPress(event, () => + selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId), + ); + if (recalledText === null) return false; + event.preventDefault(); + replaceUserInput(recalledText); + return true; + }, + [agentId, recall, replaceUserInput, serverId], ); const cancelButtonStyle = useMemo( @@ -2360,7 +2383,6 @@ function ComposerContentImpl({ pluginClientSlashCommands, ], ); - const messageInputContainerRef = useRef(null); const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; @@ -2419,16 +2441,16 @@ function ComposerContentImpl({ {isFullscreen ? null : queueList} {!isFullscreen && sendErrorNode} - - + {pluginAttachments.picker} - + diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 2c104924cc..8cb8cd4c32 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,4 @@ +import type { SendShortcut } from "@/hooks/use-settings"; import { useComposerEditingSession, useComposerFullscreen, @@ -27,6 +28,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; +import * as Haptics from "expo-haptics"; import { useDictation } from "@/hooks/use-dictation"; import { hasFinePointer } from "@/hooks/use-fine-pointer"; import { DictationOverlay } from "@/components/dictation-controls"; @@ -57,7 +59,7 @@ import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; -import { isWeb } from "@/constants/platform"; +import { isNative, isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { RenderProfile } from "@/utils/render-profiler"; @@ -76,6 +78,7 @@ const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({ })); import { resolveSendTooltipLabel, + resolveSubmitAccessibilityHint, resolveSubmitAccessibilityLabel, resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, @@ -83,8 +86,10 @@ import { import { applyDictationTranscript, computeCanStartDictation, + resolveAlternateSendAction, resolveComposerSurfacePresentation, resolveComposerSurfaceText, + resolveMessageInputEnterAction, resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, @@ -93,6 +98,11 @@ import { } from "./state"; const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; +const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]]; + +function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] { + return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS; +} const COMPOSER_INPUT_DATASET = { composerInput: "" } as const; export interface AttachmentMenuItem { @@ -110,6 +120,8 @@ export interface ComposerInputSnapshot { export interface ComposerKeyPressEvent { key: string; + /** Shift, Alt, Ctrl, or Meta was held. */ + hasModifier: boolean; preventDefault: () => void; input: ComposerInputSnapshot; } @@ -162,6 +174,7 @@ export interface MessageInputProps { * running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default * lives only in DEFAULT_CLIENT_SETTINGS. */ defaultSendBehavior: "interrupt" | "steer" | "queue"; + sendShortcut: SendShortcut; /** Callback for queue button when agent is running */ onQueue?: (payload: MessagePayload) => void; /** Optional handler used when submit button is in loading state. */ @@ -210,6 +223,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent< metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; + altKey?: boolean; // Web-only: present on DOM KeyboardEvent during IME composition (CJK input). isComposing?: boolean; keyCode?: number; @@ -396,6 +410,7 @@ interface DesktopKeyPressContext { onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; input: ComposerKeyPressEvent["input"]; submitOnEnter: boolean; + sendShortcut: SendShortcut; isAgentRunning: boolean; onQueue: ((payload: MessagePayload) => void) | undefined; isSubmitDisabled: boolean; @@ -411,30 +426,37 @@ function handleDesktopKeyPressImpl( ): void { if (isImeComposingKeyboardEvent(event.nativeEvent)) return; + const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent; + if (ctx.onKeyPressCallback) { const handled = ctx.onKeyPressCallback({ key: event.nativeEvent.key, + hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey), preventDefault: () => event.preventDefault(), input: ctx.input, }); if (handled) return; } - const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; - if (event.nativeEvent.key !== "Enter") return; - if (!ctx.submitOnEnter) return; - if (shiftKey) return; - if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) { - if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; - event.preventDefault(); - ctx.handleAlternateSendAction(); - return; - } + const action = resolveMessageInputEnterAction({ + submitOnEnter: ctx.submitOnEnter, + sendShortcut: ctx.sendShortcut, + shiftKey: shiftKey ?? false, + metaKey: metaKey ?? false, + ctrlKey: ctrlKey ?? false, + isAgentRunning: ctx.isAgentRunning, + onQueue: ctx.onQueue, + }); + if (action === null) return; if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; event.preventDefault(); + if (action === "alternate-send") { + ctx.handleAlternateSendAction(); + return; + } ctx.handleDefaultSendAction(); } @@ -765,8 +787,10 @@ function SendButtonTooltip({ canPressLoadingButton, onSubmitLoadingPress, onDefaultSendAction, + onLongPressSend, isSendButtonDisabled, submitAccessibilityLabel, + submitAccessibilityHint, sendButtonCombinedStyle, isSubmitLoading, submitIcon, @@ -780,8 +804,10 @@ function SendButtonTooltip({ canPressLoadingButton: boolean; onSubmitLoadingPress: (() => void) | undefined; onDefaultSendAction: () => void; + onLongPressSend: () => void; isSendButtonDisabled: boolean; submitAccessibilityLabel: string; + submitAccessibilityHint: string | undefined; sendButtonCombinedStyle: React.ComponentProps["style"]; isSubmitLoading: boolean; submitIcon: "arrow" | "return"; @@ -796,8 +822,10 @@ function SendButtonTooltip({ void) | undefined; onSubmitLoadingPress: (() => void) | undefined; onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; @@ -1151,6 +1180,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu voiceAgentId: props.voiceAgentId, isAgentRunning: props.isAgentRunning ?? false, defaultSendBehavior: props.defaultSendBehavior, + sendShortcut: props.sendShortcut, onQueue: props.onQueue, onSubmitLoadingPress: props.onSubmitLoadingPress, onKeyPressCallback: props.onKeyPress, @@ -1228,6 +1258,7 @@ export const MessageInput = forwardRef( voiceAgentId, isAgentRunning, defaultSendBehavior, + sendShortcut, onQueue, onSubmitLoadingPress, onKeyPressCallback, @@ -1376,6 +1407,16 @@ export const MessageInput = forwardRef( [onChangeText, updateComposerHeightForText, updateLiveTextPresence], ); + useLayoutEffect( + () => + editingSession.attachEditor({ + getText: () => textInputRef.current?.getText() ?? valueRef.current, + replaceText, + focus: () => textInputRef.current?.focus(), + }), + [editingSession, replaceText], + ); + useImperativeHandle(ref, () => ({ focus: () => { textInputRef.current?.focus(); @@ -1701,6 +1742,19 @@ export const MessageInput = forwardRef( }); }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); + const alternateSendAction = resolveAlternateSendAction({ + defaultSendBehavior, + isAgentRunning, + onQueue, + }); + + const handleLongPressSend = useCallback(() => { + if (alternateSendAction && isNative) { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + } + handleAlternateSendAction(); + }, [alternateSendAction, handleAlternateSendAction]); + usePasteImagesEffect({ textarea: webTextArea, isConnected, @@ -1734,6 +1788,7 @@ export const MessageInput = forwardRef( selectionRef.current, ), submitOnEnter: shouldSubmitOnEnter, + sendShortcut, isAgentRunning, onQueue, isSubmitDisabled, @@ -1747,6 +1802,7 @@ export const MessageInput = forwardRef( shouldHandleWebKeyPress, onKeyPressCallback, shouldSubmitOnEnter, + sendShortcut, isAgentRunning, onQueue, isSubmitDisabled, @@ -1788,6 +1844,11 @@ export const MessageInput = forwardRef( isAgentRunning, t, }); + const submitAccessibilityHint = resolveSubmitAccessibilityHint({ + canPressLoadingButton, + alternateSendAction, + t, + }); const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ isRealtimeVoiceForCurrentAgent, @@ -1948,15 +2009,17 @@ export const MessageInput = forwardRef( canPressLoadingButton={canPressLoadingButton} onSubmitLoadingPress={onSubmitLoadingPress} onDefaultSendAction={handleDefaultSendAction} + onLongPressSend={handleLongPressSend} isSendButtonDisabled={isPresentedSendDisabled} submitAccessibilityLabel={submitAccessibilityLabel} + submitAccessibilityHint={submitAccessibilityHint} sendButtonCombinedStyle={sendButtonCombinedStyle} isSubmitLoading={isSubmitLoading} submitIcon={submitIcon} submitLabel={submitLabel} submitButtonTestID={submitButtonTestID} buttonIconSize={buttonIconSize} - sendKeys={DEFAULT_SEND_KEYS} + sendKeys={resolveSendKeys(sendShortcut)} sendTooltipLabel={sendTooltipLabel} /> ), @@ -1967,14 +2030,17 @@ export const MessageInput = forwardRef( canPressLoadingButton, onSubmitLoadingPress, handleDefaultSendAction, + handleLongPressSend, isPresentedSendDisabled, submitAccessibilityLabel, + submitAccessibilityHint, sendButtonCombinedStyle, isSubmitLoading, submitIcon, submitLabel, submitButtonTestID, buttonIconSize, + sendShortcut, sendTooltipLabel, ], ); diff --git a/packages/app/src/composer/input/labels.test.ts b/packages/app/src/composer/input/labels.test.ts index 15aa16b8e0..90c60d2781 100644 --- a/packages/app/src/composer/input/labels.test.ts +++ b/packages/app/src/composer/input/labels.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveSendTooltipLabel, + resolveSubmitAccessibilityHint, resolveSubmitAccessibilityLabel, resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, @@ -12,6 +13,8 @@ const translations: Record = { "composer.input.sendAndInterrupt": "Send and interrupt", "composer.input.sendAndSteer": "Send and steer", "composer.input.sendMessage": "Send message", + "composer.input.longPressToQueue": "Long press to queue", + "composer.input.longPressToSendNow": "Long press to send now", "composer.input.queue": "Queue", "composer.input.send": "Send", "composer.voice.unmuteVoiceMode": "Unmute Voice mode", @@ -26,6 +29,37 @@ const translations: Record = { const t = ((key: string) => translations[key] ?? key) as never; describe("composer input labels", () => { + it("names the long-press action in the submit hint only when one exists", () => { + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: "queue", + t, + }), + ).toBe("Long press to queue"); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: "send", + t, + }), + ).toBe("Long press to send now"); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: null, + t, + }), + ).toBeUndefined(); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: true, + alternateSendAction: "queue", + t, + }), + ).toBeUndefined(); + }); + it("resolves submit accessibility labels from translations", () => { expect( resolveSubmitAccessibilityLabel({ diff --git a/packages/app/src/composer/input/labels.ts b/packages/app/src/composer/input/labels.ts index 0820cd7ecb..50ac3eb8bf 100644 --- a/packages/app/src/composer/input/labels.ts +++ b/packages/app/src/composer/input/labels.ts @@ -1,5 +1,6 @@ import type { TFunction } from "i18next"; import type { SendBehavior } from "@/hooks/use-settings/storage"; +import type { AlternateSendAction } from "./state"; export function resolveSubmitAccessibilityLabel(input: { submitButtonAccessibilityLabel: string | undefined; @@ -22,6 +23,19 @@ export function resolveSubmitAccessibilityLabel(input: { return input.t("composer.input.sendMessage"); } +export function resolveSubmitAccessibilityHint(input: { + canPressLoadingButton: boolean; + alternateSendAction: AlternateSendAction | null; + t: TFunction; +}): string | undefined { + if (input.canPressLoadingButton || !input.alternateSendAction) return undefined; + return input.t( + input.alternateSendAction === "queue" + ? "composer.input.longPressToQueue" + : "composer.input.longPressToSendNow", + ); +} + export function resolveVoiceAccessibilityLabel(input: { isRealtimeVoiceForCurrentAgent: boolean; isMuted: boolean; diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 6f2cdef74d..ad492976b3 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import { applyDictationTranscript, computeCanStartDictation, + type MessageInputEnterContext, resolveActiveSendBehavior, resolveComposerSurfacePresentation, resolveComposerSurfaceText, resolveGlyphRailOffset, + resolveMessageInputEnterAction, resolveRetainedSelection, + resolveAlternateSendAction, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -276,6 +279,253 @@ describe("composer send behavior", () => { expect(defaultAction.calls).toEqual(["queue"]); expect(alternateAction.calls).toEqual(["send"]); }); + + it("sends with the alternate action while no turn is running", () => { + for (const defaultSendBehavior of ["steer", "interrupt", "queue"] as const) { + const alternateAction = actions(); + runAlternateSendAction({ + defaultSendBehavior, + isAgentRunning: false, + onQueue: alternateAction.onQueue, + handleSendMessage: alternateAction.handleSendMessage, + handleQueueMessage: alternateAction.handleQueueMessage, + }); + expect(alternateAction.calls).toEqual(["send"]); + } + }); + + it("offers an alternate action only while a turn runs and the composer can queue", () => { + const onQueue = () => undefined; + expect( + resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: true, onQueue }), + ).toBe("queue"); + expect( + resolveAlternateSendAction({ + defaultSendBehavior: "interrupt", + isAgentRunning: true, + onQueue, + }), + ).toBe("queue"); + expect( + resolveAlternateSendAction({ defaultSendBehavior: "queue", isAgentRunning: true, onQueue }), + ).toBe("send"); + expect( + resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: false, onQueue }), + ).toBeNull(); + expect( + resolveAlternateSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: undefined, + }), + ).toBeNull(); + }); +}); + +describe("resolveMessageInputEnterAction", () => { + function enterContext( + overrides: Partial = {}, + ): MessageInputEnterContext { + return { + submitOnEnter: true, + sendShortcut: "enter", + shiftKey: false, + metaKey: false, + ctrlKey: false, + isAgentRunning: false, + onQueue: () => undefined, + ...overrides, + }; + } + + it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => { + expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })), + ).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })), + ).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })), + ).toBeNull(); + }); + + it("still returns null when modifier Enter sends and submit on enter is disabled", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }), + ), + ).toBeNull(); + }); + + it("sends on plain Enter when Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send"); + }); + + it("returns null for Shift+Enter when Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull(); + }); + + it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }), + ), + ).toBeNull(); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => { + expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send"); + expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send"); + }); + + it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => { + expect( + resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })), + ).toBe("alternate-send"); + expect( + resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })), + ).toBe("alternate-send"); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }), + ), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction( + enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }), + ), + ).toBe("default-send"); + }); + + it("returns null for plain and Shift+Enter when modifier Enter sends", () => { + expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull(); + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })), + ).toBeNull(); + }); + + it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }), + ), + ).toBeNull(); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }), + ), + ).toBeNull(); + }); + + it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })), + ).toBe("default-send"); + }); + + it("uses the default action on modifier Enter while running when modifier Enter sends", () => { + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }), + ), + ).toBe("default-send"); + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }), + ), + ).toBe("default-send"); + }); + + describe("composed with the send actions", () => { + function actions() { + const calls: string[] = []; + return { + calls, + handleSendMessage: () => calls.push("send"), + handleQueueMessage: () => calls.push("queue"), + onQueue: () => undefined, + }; + } + + function resolveCommandEnterToSendModifierEnter() { + return resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }), + ); + } + + it("queues through the default send action when queue behavior is selected and the agent is running", () => { + const sendAction = actions(); + + expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["queue"]); + }); + + it("sends through the default send action when queue behavior is selected and the agent is idle", () => { + const sendAction = actions(); + + expect( + resolveMessageInputEnterAction( + enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }), + ), + ).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: false, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["send"]); + }); + + it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)( + "sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running", + ({ defaultSendBehavior }) => { + const sendAction = actions(); + + expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send"); + + runDefaultSendAction({ + defaultSendBehavior, + isAgentRunning: true, + onQueue: sendAction.onQueue, + handleSendMessage: sendAction.handleSendMessage, + handleQueueMessage: sendAction.handleQueueMessage, + }); + + expect(sendAction.calls).toEqual(["send"]); + }, + ); + }); }); describe("stopRealtimeVoice", () => { diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 698965b5ac..67c8030745 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,3 +1,4 @@ +import type { SendShortcut } from "@/hooks/use-settings"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { MessagePayload, TextReplacement } from "@/composer/types"; @@ -125,14 +126,53 @@ export function runDefaultSendAction(ctx: SendActionContext): void { ctx.handleSendMessage(); } +export type AlternateSendAction = "queue" | "send"; + +/** + * What Mod+Enter and a long-press on Send do instead of the default send. Null while + * no turn is running, where both fall back to the default send. + */ +export function resolveAlternateSendAction( + ctx: Pick, +): AlternateSendAction | null { + if (!ctx.isAgentRunning || !ctx.onQueue) return null; + return ctx.defaultSendBehavior === "queue" ? "send" : "queue"; +} + export function runAlternateSendAction(ctx: SendActionContext): void { - if (ctx.defaultSendBehavior === "queue") { + const action = resolveAlternateSendAction(ctx); + if (action === "queue") { + ctx.handleQueueMessage(); + return; + } + if (action === "send") { ctx.handleSendMessage(); return; } - if (ctx.isAgentRunning && ctx.onQueue) { - ctx.handleQueueMessage(); - } + runDefaultSendAction(ctx); +} + +export type MessageInputEnterAction = "alternate-send" | "default-send"; + +export interface MessageInputEnterContext { + submitOnEnter: boolean; + sendShortcut: SendShortcut; + shiftKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + isAgentRunning: boolean; + onQueue: ((payload: MessagePayload) => void) | undefined; +} + +export function resolveMessageInputEnterAction( + ctx: MessageInputEnterContext, +): MessageInputEnterAction | null { + if (!ctx.submitOnEnter) return null; + if (ctx.shiftKey) return null; + const hasModifier = ctx.metaKey || ctx.ctrlKey; + if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null; + if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send"; + return "default-send"; } export function runMessageInputKeyboardAction( diff --git a/packages/app/src/composer/recall.test.ts b/packages/app/src/composer/recall.test.ts new file mode 100644 index 0000000000..a1c6b618fc --- /dev/null +++ b/packages/app/src/composer/recall.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { ComposerKeyPressEvent } from "@/composer/input/input"; +import { ComposerRecall } from "@/composer/recall"; + +const SENT = ["add tests", "fix the auth bug"]; + +function press( + key: string, + text: string, + options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {}, +): ComposerKeyPressEvent { + const caret = options.caret ?? text.length; + return { + key, + hasModifier: options.hasModifier ?? false, + preventDefault: () => undefined, + input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } }, + }; +} + +/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */ +function composerWithRecall(sent: readonly string[] = SENT) { + const recall = new ComposerRecall(); + let text = ""; + return { + recall, + get text() { + return text; + }, + type(next: string) { + text = next; + }, + press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) { + const result = recall.handleKeyPress(press(key, text, options), () => sent); + if (result !== null) text = result; + return result; + }, + }; +} + +describe("ComposerRecall", () => { + it("walks back through sent messages and forward to an empty composer", () => { + const composer = composerWithRecall(); + + expect(composer.press("ArrowUp")).toBe("add tests"); + expect(composer.press("ArrowUp")).toBe("fix the auth bug"); + expect(composer.press("ArrowDown")).toBe("add tests"); + expect(composer.press("ArrowDown")).toBe(""); + expect(composer.recall.isShowing(composer.text)).toBe(false); + }); + + it("leaves the caret to the input past the oldest message", () => { + const composer = composerWithRecall(); + composer.press("ArrowUp"); + composer.press("ArrowUp"); + + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.text).toBe("fix the auth bug"); + }); + + it("only starts from an empty composer", () => { + const composer = composerWithRecall(); + composer.type("half written"); + + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.press("ArrowDown")).toBeNull(); + }); + + it("ignores Down from an empty composer and Up with no history", () => { + expect(composerWithRecall().press("ArrowDown")).toBeNull(); + expect(composerWithRecall([]).press("ArrowUp")).toBeNull(); + }); + + it("hands the arrows back to the caret once the recalled message is edited", () => { + const composer = composerWithRecall(); + composer.press("ArrowUp"); + composer.type("add tests for login"); + + expect(composer.recall.isShowing(composer.text)).toBe(false); + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.press("ArrowDown")).toBeNull(); + }); + + it("moves between lines of a multi-line message before changing message", () => { + const composer = composerWithRecall(["first line\nsecond line", "older"]); + composer.press("ArrowUp"); + + expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull(); + expect(composer.press("ArrowUp", { caret: 3 })).toBe("older"); + expect(composer.press("ArrowDown")).toBe("first line\nsecond line"); + expect(composer.press("ArrowDown", { caret: 3 })).toBeNull(); + expect(composer.press("ArrowDown")).toBe(""); + }); + + it("ignores modified arrows, other keys, and a selection", () => { + const composer = composerWithRecall(); + + expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull(); + expect(composer.press("Enter")).toBeNull(); + composer.press("ArrowUp"); + const recall = composer.recall; + expect( + recall.handleKeyPress( + press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }), + () => SENT, + ), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/composer/recall.ts b/packages/app/src/composer/recall.ts new file mode 100644 index 0000000000..24984d39e1 --- /dev/null +++ b/packages/app/src/composer/recall.ts @@ -0,0 +1,68 @@ +import type { ComposerKeyPressEvent } from "@/composer/input/input"; + +interface RecalledMessage { + index: number; + text: string; +} + +/** + * Walks the messages already sent to an agent from an empty composer, like a + * shell's Up/Down history. A message stays recalled only while the composer + * still holds its exact text; the first edit hands the arrows back to the caret. + */ +export class ComposerRecall { + private recalled: RecalledMessage | null = null; + + /** True while the composer shows a recalled message the user has not edited. */ + isShowing(text: string): boolean { + return this.recalled !== null && this.recalled.text === text; + } + + /** + * Returns the text the composer should show for this key press, or null to + * leave the key to the input. `readSentMessages` is newest first and is only + * read for keys recall acts on. + */ + handleKeyPress( + event: ComposerKeyPressEvent, + readSentMessages: () => readonly string[], + ): string | null { + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return null; + if (event.hasModifier) return null; + const { text, selection } = event.input; + if (selection.start !== selection.end) return null; + + const recalled = this.isShowing(text) ? this.recalled : null; + if (!recalled) { + this.recalled = null; + if (event.key !== "ArrowUp" || text.length > 0) return null; + return this.show(readSentMessages(), 0); + } + + if (event.key === "ArrowUp") { + if (!isCaretOnFirstLine(text, selection.start)) return null; + return this.show(readSentMessages(), recalled.index + 1); + } + if (!isCaretOnLastLine(text, selection.start)) return null; + if (recalled.index === 0) { + this.recalled = null; + return ""; + } + return this.show(readSentMessages(), recalled.index - 1); + } + + private show(messages: readonly string[], index: number): string | null { + const text = messages[index]; + if (text === undefined) return null; + this.recalled = { index, text }; + return text; + } +} + +function isCaretOnFirstLine(text: string, caret: number): boolean { + return !text.slice(0, caret).includes("\n"); +} + +function isCaretOnLastLine(text: string, caret: number): boolean { + return !text.slice(caret).includes("\n"); +} diff --git a/packages/app/src/composer/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx index 9ebc92ed20..b02a1e5e28 100644 --- a/packages/app/src/composer/tracks-panel.browser.test.tsx +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -162,12 +162,34 @@ describe("composer track panel", () => { ); openPanel(); + expect(document.querySelector("button button")).toBeNull(); + expect(row("row")?.getAttribute("role")).toBe("menuitem"); click(row("row-archive") as Element); expect(onAction).toHaveBeenCalledTimes(1); expect(onPress).not.toHaveBeenCalled(); expect(isPanelOpen()).toBe(true); }); + + it.each(["Enter", " "])("selects a track row with %j", (key) => { + const select = vi.fn(); + mount( + + + Subagent one + + , + ); + openPanel(); + const target = row("row") as HTMLElement; + act(() => { + target.focus(); + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + target.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true })); + }); + expect(select).toHaveBeenCalledTimes(1); + expect(isPanelOpen()).toBe(false); + }); }); describe("composer track pill status mark", () => { diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 3a4541d285..341e7dc8e0 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -8,6 +8,8 @@ import { MenuSurface, MenuTrigger, useMenuContext, + useMenuDepth, + type MenuPageDefinition, type MenuTriggerState, } from "@/components/ui/menu"; import { StatusRing } from "@/components/status-ring"; @@ -61,6 +63,8 @@ export interface ComposerTrackPillProps { accessibilityLabel?: string; /** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */ children: ReactNode; + /** Pages a row can open in place of the list (`ComposerTrackRow` `opensPage`). */ + pages?: readonly MenuPageDefinition[]; } /** @@ -85,6 +89,7 @@ export function ComposerTrackPill({ testID, accessibilityLabel, children, + pages, }: ComposerTrackPillProps): ReactElement { return ( @@ -98,6 +103,7 @@ export function ComposerTrackPill({ align="start" offset={PANEL_OFFSET} sheetTitle={panelTitle} + pages={pages} minWidth={PANEL_MIN_WIDTH} maxWidth={PANEL_MAX_WIDTH} maxHeight={PANEL_MAX_HEIGHT} @@ -199,6 +205,11 @@ export interface ComposerTrackRowProps { * lands inside the panel is not. */ closeOnSelect?: boolean; + /** + * A page of the panel this row opens in place of the list, instead of running `onPress`. The + * panel stays open: Back returns to the list. + */ + opensPage?: string; disabled?: boolean; accessibilityLabel?: string; testID?: string; @@ -220,17 +231,27 @@ export function ComposerTrackRow({ children, onPress, closeOnSelect = true, + opensPage, disabled = false, accessibilityLabel, testID, }: ComposerTrackRowProps): ReactElement { - const { selectItem } = useMenuContext("ComposerTrackRow"); + const { selectItem, openSub } = useMenuContext("ComposerTrackRow"); + const depth = useMenuDepth(); const [hovered, setHovered] = useState(false); const handlePointerEnter = useCallback(() => setHovered(true), []); const handlePointerLeave = useCallback(() => setHovered(false), []); - const handleSelect = useCallback( - () => selectItem(onPress, closeOnSelect), - [closeOnSelect, onPress, selectItem], + const handleSelect = useCallback(() => { + if (opensPage) { + selectItem(() => openSub({ id: opensPage, depth }), false); + return; + } + selectItem(onPress, closeOnSelect); + }, [closeOnSelect, depth, onPress, openSub, opensPage, selectItem]); + + const itemDataSet = useMemo( + () => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }), + [disabled], ); const renderRow = useCallback( @@ -246,14 +267,15 @@ export function ComposerTrackRow({ [hovered, renderRow], ); - if (!onPress) { + if (!onPress && !opensPage) { return {renderRow(false)}; } return ( { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, - schedule: (task, delayMs) => { - const timeout = setTimeout(task, delayMs); - return () => clearTimeout(timeout); - }, }); viewedTimelineSyncRef.current = sync; setViewedTimelineSync(serverId, sync); diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index f0210f1402..46b7d81a2d 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -292,7 +292,10 @@ const CATALOG_DATA = [ version: "0.6.13", iconId: "minimax-code", installLink: "https://agent.minimax.io", - command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"], + // `-p ` names the package and `mcode` names its binary. Without the + // explicit binary, npx resolves the executable from the unscoped package + // name (`code`), which this package does not publish. + command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"], }, { id: "minion-code", diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 68b389fe15..87c62f81ca 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -8,7 +8,7 @@ import { } from "./provider-snapshot-cache"; import { queryClient as singletonQueryClient } from "./query-client"; import { replaceProviderSnapshotIcons } from "@/data/provider-icons"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { agentCommandsQueryRoot } from "@/agent-commands/cache"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 75ac313169..cc32f8f8e3 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -1,11 +1,12 @@ import type { OwnedSubscription } from "@getpaseo/client"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query"; import type { ListTerminalsResponse, MutableDaemonConfig, SessionOutboundMessage, } from "@getpaseo/protocol/messages"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { invalidateCommandsForProviderChange } from "@/agent-commands/cache"; import { shareCheckoutDiff } from "@/git/diff-sharing"; import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { daemonConfigQueryKey } from "@/data/daemon-config"; @@ -186,10 +187,10 @@ export async function applyProvidersSnapshotUpdate(input: { const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" }; const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd); const queryKey = providersSnapshotQueryKey(input.serverId, cwd); - const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey); + const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey); let announcement: typeof snapshot | undefined = snapshot; void input.queryClient.cancelQueries({ queryKey, exact: true }); - await input.queryClient.fetchQuery({ + const current = await input.queryClient.fetchQuery({ queryKey, staleTime: 0, structuralSharing: false, @@ -205,12 +206,13 @@ export async function applyProvidersSnapshotUpdate(input: { }); }, }); - if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) { - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); - } + void invalidateCommandsForProviderChange({ + queryClient: input.queryClient, + serverId: input.serverId, + cwd, + previous: previous?.entries, + current: current.entries, + }); } export function mountServerDataPushRouter(input: PushRouterInput): () => void { diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index 2a645f8666..e2b25d7b29 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -27,7 +27,7 @@ type ReplicaQueryInput type FetchQueryInput = Omit< UseQueryOptions, - "initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime" + "initialData" | "placeholderData" | "queryFn" | "staleTime" > & { dataShape: "list" | "value"; queryFn: QueryFnOption; @@ -113,7 +113,7 @@ export function fetchQueryOptions< dataShape, }, }, - refetchOnMount: immutableWhen ? true : "always", + refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"), staleTime: immutableWhen ? (query) => query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0 diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 8440221ec7..080da9c37a 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -80,7 +80,7 @@ export interface DesktopEditorOpenTargetInput { } export interface DesktopEditorBridge { - listTargets?: () => Promise; + listTargets?: (input?: { workspacePath?: string }) => Promise; openTarget?: (input: DesktopEditorOpenTargetInput) => Promise; } diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index 026ec9d43a..bbfeb6d758 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -16,7 +16,7 @@ import { resolveWorkingDiffComparison, selectWorkingDiffComparison, } from "@/git/working-diff-comparison"; -import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { draftAgentCommandsQueryKey } from "@/agent-commands/cache"; import { applyCheckoutStatusUpdateFromEvent, ensureCheckoutStatus, diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index ca5cbb3a7a..4aaa650c70 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -8,7 +8,7 @@ import { invalidatePrPaneTimelineForCheckout, } from "@/git/query-keys"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; -import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query"; +import { resetDraftAgentCommandsForCheckout } from "@/agent-commands/cache"; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; export type { CheckoutPrStatusPayload } from "@/git/pr-status"; diff --git a/packages/app/src/git/diff-document/benchmark-corpus.ts b/packages/app/src/git/diff-document/benchmark-corpus.ts new file mode 100644 index 0000000000..469d437e8d --- /dev/null +++ b/packages/app/src/git/diff-document/benchmark-corpus.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +/** + * The diff shapes the layout benchmarks measure, shared by the node bench + * (`model.bench.ts`) and the browser benchmark (`e2e/browser/diff-performance.spec.ts`) + * so both report against the same text. + */ +export interface BenchmarkCorpusFile { + path: string; + lines: string[]; +} + +function repeatToLength(unit: string, length: number): string { + return unit.repeat(Math.ceil(length / unit.length)).slice(0, length); +} + +/** A real source file: short lines, a few with ligature punctuation. */ +export function realCodeCorpusFile(): BenchmarkCorpusFile { + const source = readFileSync(path.resolve(__dirname, "../diff-pane.tsx"), "utf8"); + return { path: "packages/app/src/git/diff-pane.tsx", lines: source.split("\n") }; +} + +/** The CHANGELOG shape that once froze the renderer: 42 lines, longest ~600 characters. */ +export function changelogCorpusFile(): BenchmarkCorpusFile { + const sentence = + "- Fixed an issue where the diff viewer re-measured every line -> on toggle, which made wrapping painfully slow. "; + return { + path: "CHANGELOG.md", + lines: Array.from({ length: 42 }, (_, index) => + repeatToLength(sentence, 200 + ((index * 97) % 398)), + ), + }; +} + +/** Generated TypeScript: ligature punctuation on every line, every tenth line long. */ +export function generatedCodeCorpusFile(lineCount: number): BenchmarkCorpusFile { + return { + path: "src/generated.ts", + lines: Array.from({ length: lineCount }, (_, index) => { + const base = `const handler${index} = async (event) => items.filter((item) => item.id !== ${index} && item.kind === "selected");`; + return index % 10 === 9 ? repeatToLength(`${base} // `, 320) : base; + }), + }; +} + +/** Serialized data: no ligatures, every line wraps several times. */ +export function jsonCorpusFile(lineCount: number): BenchmarkCorpusFile { + return { + path: "fixtures/data.json", + lines: Array.from({ length: lineCount }, (_, index) => + repeatToLength(`{"id": ${index}, "label": "row ${index}", "tags": ["alpha", "beta"]}, `, 300), + ), + }; +} + +/** A pull-request-sized working diff: one of each shape above. */ +export function pullRequestCorpus(): BenchmarkCorpusFile[] { + return [ + realCodeCorpusFile(), + changelogCorpusFile(), + generatedCodeCorpusFile(1200), + jsonCorpusFile(300), + ]; +} diff --git a/packages/app/src/git/diff-document/model.bench.ts b/packages/app/src/git/diff-document/model.bench.ts new file mode 100644 index 0000000000..2fb25d502a --- /dev/null +++ b/packages/app/src/git/diff-document/model.bench.ts @@ -0,0 +1,317 @@ +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; +import { bench, describe } from "vitest"; +import { + changelogCorpusFile, + generatedCodeCorpusFile, + jsonCorpusFile, + pullRequestCorpus, + realCodeCorpusFile, + type BenchmarkCorpusFile, +} from "./benchmark-corpus"; +import { buildDiffDocumentModel } from "./model"; +import { + createAdditiveAdvances, + createChunkedAdvanceMeasurer, + createChunkedWidthMeasurer, + createMeasuredAdvances, + requiresShaping, +} from "./text-measurement"; +import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types"; + +// The pure layout work the diff surface does when a diff opens or line wrap is +// toggled, without a browser or Skia. Counts are the backend-independent cost +// owners (how many strings a shaper has to see), timings are the JS overhead +// around them: +// npx vitest bench --project unit src/git/diff-document/model.bench.ts + +const DESKTOP_VIEWPORT = { width: 1400, height: 900 }; +const PHONE_VIEWPORT = { width: 390, height: 800 }; +const CELL_WIDTH = 7.8; + +interface MeasurementStats { + /** Whole strings handed to the backend. */ + calls: number; + /** Characters inside those strings; canvas and Skia cost scales with this. */ + characters: number; +} + +interface Backend { + measurer: TextMeasurer; + stats: MeasurementStats; +} + +function monospaceWidth(text: string): number { + let width = 0; + for (const character of text) width += character.charCodeAt(0) > 0x2e7f ? 2 : 1; + return width * CELL_WIDTH; +} + +function countingMeasure(stats: MeasurementStats): (text: string) => number { + return (text) => { + stats.calls += 1; + stats.characters += text.length; + return monospaceWidth(text); + }; +} + +/** Previous web backend, retained to isolate the cost of width-only probes. */ +function webBackendWithoutWidthProbe(): Backend { + const stats: MeasurementStats = { calls: 0, characters: 0 }; + const measure = countingMeasure(stats); + return { stats, measurer: { measure, measureAdvances: createMeasuredAdvances(measure) } }; +} + +/** + * `createWebTextMeasurer` in surface.web.tsx, with canvas replaced by a counter. + * Width-only probes shape one string per chunk, like the native measurer; the + * widths are the same sums, in the same order, as the last cumulative advance. + */ +function webBackend(): Backend { + const stats: MeasurementStats = { calls: 0, characters: 0 }; + const measure = countingMeasure(stats); + const additive = createAdditiveAdvances(measure); + return { + stats, + measurer: { + measure, + measureAdvances: createMeasuredAdvances(measure), + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0); + }), + }, + }; +} + +/** `createNativeTextMeasurer` in text.native.ts, with Skia replaced by a counter. */ +function nativeBackend(): Backend { + const stats: MeasurementStats = { calls: 0, characters: 0 }; + const widths = new Map(); + const additive = (graphemes: readonly string[]) => { + let advance = 0; + return graphemes.map((grapheme) => { + let width = widths.get(grapheme); + if (width === undefined) { + stats.calls += 1; + stats.characters += grapheme.length; + width = monospaceWidth(grapheme); + widths.set(grapheme, width); + } + return (advance += width); + }); + }; + const shape = countingMeasure(stats); + return { + stats, + measurer: { + measure(text) { + return requiresShaping(text) ? shape(text) : (additive([...text]).at(-1) ?? 0); + }, + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + return requiresShaping(text) ? shape(text) : (additive(graphemes).at(-1) ?? 0); + }), + measureAdvances: createChunkedAdvanceMeasurer({ + requiresShaping, + measureAdditive: additive, + measureShaped(graphemes) { + shape(graphemes.join("")); + let advance = 0; + return graphemes.map((grapheme) => (advance += monospaceWidth(grapheme))); + }, + }), + }, + }; +} + +function addedFile(file: BenchmarkCorpusFile): ParsedDiffFile { + return { + path: file.path, + isNew: true, + isDeleted: false, + additions: file.lines.length, + deletions: 0, + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: file.lines.length, + lines: file.lines.map((content) => ({ + type: "add" as const, + content, + tokens: [{ text: content, style: null }], + })), + }, + ], + }; +} + +interface Scenario { + name: string; + files: ParsedDiffFile[]; +} + +const SCENARIOS: Scenario[] = [ + { name: "real code (diff-pane.tsx)", files: [addedFile(realCodeCorpusFile())] }, + { name: "changelog (42 long prose lines)", files: [addedFile(changelogCorpusFile())] }, + { name: "generated code (1200 lines)", files: [addedFile(generatedCodeCorpusFile(1200))] }, + { name: "json (300 lines x 300 chars)", files: [addedFile(jsonCorpusFile(300))] }, + { name: "pull request (all of the above)", files: pullRequestCorpus().map(addedFile) }, +]; + +function input( + scenario: Scenario, + options: { + wrapLines: boolean; + viewport: { width: number; height: number }; + measureText: TextMeasurer; + }, +): BuildDiffDocumentModelInput { + return { + files: scenario.files, + collapsedFilePaths: new Set(), + layout: "unified", + wrapLines: options.wrapLines, + viewportWidth: options.viewport.width, + typography: { family: "monospace", size: 13, lineHeight: 20 }, + measureText: options.measureText, + palette: { + surface: "#000", + headerSurface: "#111", + border: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + addition: "green", + deletion: "red", + additionBackground: "#010", + deletionBackground: "#100", + emptyBackground: "#111", + selection: "blue", + headerActiveSurface: "#222", + headerBorder: "#333", + statusSuccess: "green", + statusDanger: "red", + statusWarning: "orange", + syntax: {}, + }, + labels: { binary: "Binary", tooLarge: "Too large" }, + // What the surfaces pass at scrollTop 0: five viewports tall. + materializationWindow: { top: 0, height: options.viewport.height * 5 }, + }; +} + +interface Variant { + name: string; + wrapLines: boolean; + viewport: { width: number; height: number }; + backend: () => Backend; +} + +const VARIANTS: Variant[] = [ + { + name: "unwrapped desktop | web", + wrapLines: false, + viewport: DESKTOP_VIEWPORT, + backend: webBackend, + }, + { + name: "wrapped desktop | web", + wrapLines: true, + viewport: DESKTOP_VIEWPORT, + backend: webBackend, + }, + { + name: "wrapped desktop | web without width probe", + wrapLines: true, + viewport: DESKTOP_VIEWPORT, + backend: webBackendWithoutWidthProbe, + }, + { + name: "wrapped phone | web", + wrapLines: true, + viewport: PHONE_VIEWPORT, + backend: webBackend, + }, + { + name: "unwrapped desktop | native", + wrapLines: false, + viewport: DESKTOP_VIEWPORT, + backend: nativeBackend, + }, + { + name: "wrapped desktop | native", + wrapLines: true, + viewport: DESKTOP_VIEWPORT, + backend: nativeBackend, + }, + { + name: "wrapped phone | native", + wrapLines: true, + viewport: PHONE_VIEWPORT, + backend: nativeBackend, + }, +]; + +function countModel(scenario: Scenario, variant: Variant) { + const { measurer, stats } = variant.backend(); + const started = performance.now(); + const model = buildDiffDocumentModel( + input(scenario, { + wrapLines: variant.wrapLines, + viewport: variant.viewport, + measureText: measurer, + }), + ); + const elapsedMs = performance.now() - started; + let materializedCells = 0; + let fragments = 0; + let graphemes = 0; + for (const row of model.rows) { + if (row.kind !== "line") continue; + for (const cell of row.cells) { + if (!cell || cell.fragments.length === 0) continue; + materializedCells += 1; + fragments += cell.fragments.length; + for (const fragment of cell.fragments) graphemes += fragment.graphemes.length; + } + } + const sourceLines = scenario.files.reduce( + (count, file) => count + file.hunks.reduce((total, hunk) => total + hunk.lines.length, 0), + 0, + ); + return { + sourceLines, + materializedCells, + fragments, + graphemeObjects: graphemes, + measureCalls: stats.calls, + measuredCharacters: stats.characters, + coldMs: Number(elapsedMs.toFixed(1)), + }; +} + +const report: Record> = {}; +for (const scenario of SCENARIOS) { + for (const variant of VARIANTS) { + report[`${scenario.name} | ${variant.name}`] = countModel(scenario, variant); + } +} +console.table(report); + +for (const scenario of SCENARIOS) { + describe(scenario.name, () => { + for (const variant of VARIANTS) { + if (variant.backend === nativeBackend) continue; + bench(variant.name, () => { + buildDiffDocumentModel( + input(scenario, { + wrapLines: variant.wrapLines, + viewport: variant.viewport, + measureText: variant.backend().measurer, + }), + ); + }); + } + }); +} diff --git a/packages/app/src/git/diff-document/model.test.ts b/packages/app/src/git/diff-document/model.test.ts index ace965c1ce..9b55a242db 100644 --- a/packages/app/src/git/diff-document/model.test.ts +++ b/packages/app/src/git/diff-document/model.test.ts @@ -10,7 +10,7 @@ import { resolveRelayoutScrollTop, shouldApplyRelayoutScroll, } from "./model"; -import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types"; +import type { BuildDiffDocumentModelInput, DiffCell, TextMeasurer } from "./types"; const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 }; @@ -639,17 +639,119 @@ describe("diff document model", () => { expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts")); }); - it("keeps wrapped geometry fully measured", () => { + it("replaces estimated wrapped heights through reuse and preserves the scroll anchor", () => { + const changed = fileWithChangedLine("x".repeat(100)); + const files = [changed, file("later.ts")]; + // Deliberately inaccurate M width makes the estimate differ from exact layout. + const measureText = { + measure: (text: string) => (text === "M" ? 5 : Array.from(text).length * 10), + }; + const options = input({ files, measureText }); + const first = buildDiffDocumentModel({ + ...options, + materializationWindow: { top: 30, height: 1 }, + }); + const eager = buildDiffDocumentModel(options); + const exact = buildDiffDocumentModel({ ...options, reuseFrom: [first] }); + expect(first.height).not.toBe(eager.height); + expect(exact.rows).toEqual(eager.rows); + expect(exact.height).toBe(eager.height); + expect(firstLineCells(exact, changed.path)).toBe(firstLineCells(first, changed.path)); + const scrollTop = first.files[1]!.bodyTop + 3; + const nextScrollTop = resolveRelayoutScrollTop(first, exact, scrollTop); + expect(nextScrollTop).toBe(exact.files[1]!.bodyTop + 3); + expect(captureScrollAnchor(exact, nextScrollTop)).toEqual( + captureScrollAnchor(first, scrollTop), + ); + }); + + it("preserves the wrapped source offset when resize leaves the anchor outside the window", () => { + const source = file(); + source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({ + type: "add" as const, + content: "x".repeat(200), + })); + const options = input({ files: [source], viewportWidth: 500 }); + const before = buildDiffDocumentModel(options); + const scrollTop = before.rows[100]!.top + 36; + const anchor = captureScrollAnchor(before, scrollTop)!; + expect(anchor).toMatchObject({ kind: "text", cellOffset: 90 }); + const resizedOptions = { ...options, viewportWidth: 180 }; + const estimated = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { + top: Math.floor(scrollTop / 800) * 800 - 800, + height: 2000, + }, + }); + const estimatedRow = estimated.rows[100]!; + expect(estimatedRow.kind === "line" && estimatedRow.cells[0]?.fragments).toEqual([]); + const restored = resolveRelayoutScrollTop(before, estimated, scrollTop); + const recaptured = captureScrollAnchor(estimated, restored)!; + expect(recaptured).toMatchObject({ kind: "text", cellOffset: 78 }); + + const materialized = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { top: restored - 800, height: 2000 }, + reuseFrom: [estimated], + }); + const eager = buildDiffDocumentModel(resizedOptions); + const expected = resolveRelayoutScrollTop(before, eager, scrollTop); + expect(expected).toBe(28_938); + expect(Math.abs(restored - expected)).toBeLessThan(before.lineHeight); + expect(resolveRelayoutScrollTop(estimated, materialized, restored)).toBe(expected); + }); + + it("materializes the relayout anchor even when off-screen height estimates are inaccurate", () => { + const source = file(); + source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({ + type: "add" as const, + content: "x".repeat(200), + })); + const options = input({ files: [source], viewportWidth: 500 }); + const before = buildDiffDocumentModel(options); + const scrollTop = before.rows[100]!.top + 38; + const anchor = captureScrollAnchor(before, scrollTop)!; + const resizedOptions = { + ...options, + viewportWidth: 180, + measureText: { measure: (text: string) => (text === "M" ? 5 : text.length * 10) }, + }; + const resized = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { top: scrollTop - 800, height: 2000 }, + materializationAnchor: anchor, + }); + const row = resized.rows[100]!; + expect(row.kind === "line" && row.cells[0]!.fragments.length).toBeGreaterThan(0); + const restored = resolveRelayoutScrollTop(before, resized, scrollTop); + const materialized = buildDiffDocumentModel({ + ...resizedOptions, + materializationWindow: { top: restored - 800, height: 2000 }, + materializationAnchor: captureScrollAnchor(resized, restored), + reuseFrom: [resized], + }); + const nextScrollTop = resolveRelayoutScrollTop(resized, materialized, restored); + const nextAnchor = captureScrollAnchor(materialized, nextScrollTop); + // Offset 90 belongs to the fragment beginning at 78 after narrowing. + expect(nextAnchor).toMatchObject({ kind: "text", cellOffset: 78, viewportDelta: -2 }); + expect(nextScrollTop).toBe(resolveScrollAnchor(materialized, anchor)); + }); + + it("materializes wrapped text only inside the window", () => { const model = buildDiffDocumentModel( input({ files: [file("a.ts"), file("b.ts")], wrapLines: true, - materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT }, + materializationWindow: { top: FILE_HEADER_HEIGHT, height: 1 }, }), ); expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0); - expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0); + expect(measuredFragments(model, "b.ts")).toBe(0); + expect( + model.rows.filter((row) => row.kind === "line" && row.cells.some(hasFragments)), + ).toHaveLength(1); }); }); @@ -798,3 +900,7 @@ function reviewActionsWithEditor( onDeleteComment() {}, }; } + +function hasFragments(cell: DiffCell | null): boolean { + return cell !== null && cell.fragments.length > 0; +} diff --git a/packages/app/src/git/diff-document/model.ts b/packages/app/src/git/diff-document/model.ts index fb2b9a04f0..67efe06c2b 100644 --- a/packages/app/src/git/diff-document/model.ts +++ b/packages/app/src/git/diff-document/model.ts @@ -154,27 +154,38 @@ function appendReusableFileRows(candidate: { }): FileRowsResult { const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd); const retainsRowGeometry = - !candidate.input.wrapLines && candidate.model.reviewGeometryKey === candidate.reviewGeometryKey && candidate.file.top === candidate.fileTop && candidate.file.fileIndex === candidate.fileIndex && candidate.file.rowStart === candidate.rows.length; let top = candidate.bodyTop; for (const reusedRow of reusedRows) { - let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top); + let nextRow = + retainsRowGeometry && reusedRow.top === top + ? reusedRow + : repositionReusedRow(reusedRow, candidate, top); if ( nextRow.kind === "line" && - intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) && + (intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) || + containsMaterializationAnchor(candidate.input, candidate.file.path, nextRow.cells)) && nextRow.cells.some((cell) => cell && cell.fragments.length === 0) ) { + const cells = materializeCells( + nextRow.cells, + candidate.file.file, + candidate.file.gutterWidth, + candidate.input, + ); nextRow = { ...nextRow, - cells: materializeCells( - nextRow.cells, - candidate.file.file, - candidate.file.gutterWidth, - candidate.input, - ), + cells, + height: + Math.max( + candidate.input.typography.lineHeight, + ...cells.map( + (cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight, + ), + ) + nextRow.reviewHeight, }; } candidate.rows.push(nextRow); @@ -240,6 +251,7 @@ function appendNewFileRows(candidate: { const lines = geometryLines( lineSources(candidate.file, candidate.input.layout, false), candidate.input, + candidate.gutterWidth, ); const fileBottom = candidate.bodyTop + @@ -252,8 +264,8 @@ function appendNewFileRows(candidate: { let maximumHorizontalOverflow = 0; for (const line of lines) { const shouldMaterialize = - candidate.input.wrapLines || - intersectsMaterializationWindow(candidate.input, top, top + line.height); + intersectsMaterializationWindow(candidate.input, top, top + line.height) || + containsMaterializationAnchor(candidate.input, candidate.file.path, line.cells); const cells = shouldMaterialize ? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input) : line.cells; @@ -275,7 +287,7 @@ function appendNewFileRows(candidate: { candidate.input.typography.lineHeight, ...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight), ); - const height = textHeight + line.reviewHeight; + const height = shouldMaterialize ? textHeight + line.reviewHeight : line.height; candidate.rows.push({ kind: "line", index: candidate.rows.length, @@ -412,13 +424,14 @@ function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean): }; } -function geometryCell(source: CellSource): DiffCell { +function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCell { return { type: source.type, content: source.content, lineNumber: source.lineNumber, tokens: [], fragments: [], + estimatedWrapColumns, reviewTarget: source.reviewTarget, sourceIdentity: source.sourceIdentity, }; @@ -427,20 +440,87 @@ function geometryCell(source: CellSource): DiffCell { function geometryLines( sources: Array<[CellSource] | [CellSource | null, CellSource | null]>, input: BuildDiffDocumentModelInput, + gutterWidth: number, ): GeometryLine[] { + const characterWidth = input.wrapLines ? input.measureText.measure("M") : 0; return sources.map((sourceCells) => { + const columns = input.wrapLines + ? Math.max( + 1, + Math.floor( + (input.viewportWidth / sourceCells.length - gutterWidth - CODE_HORIZONTAL_PADDING) / + characterWidth, + ), + ) + : Infinity; const cells = sourceCells.map((source) => - source ? geometryCell(source) : null, + source ? geometryCell(source, columns) : null, ) as DiffLineRow["cells"]; const reviewHeight = reviewHeightForCells(cells, input); return { cells, reviewHeight, - height: input.typography.lineHeight + reviewHeight, + // Unseen wrapped rows use a monospace estimate; materialization replaces + // it with the exact fragment count without changing source identity. + height: + input.typography.lineHeight * + Math.max( + 1, + ...cells.map((cell) => { + return cell && input.wrapLines + ? Math.ceil(estimatedDisplayOffset(cell.content) / columns) + : 1; + }), + ) + + reviewHeight, }; }); } +// Use the same tab expansion and column count for estimated heights and anchors. +function estimatedDisplayOffset(text: string): number { + return text.length + (text.match(/\t/g)?.length ?? 0) * 3; +} + +function estimatedVisualLine(cell: DiffCell, sourceOffset: number): number { + return Math.floor( + estimatedDisplayOffset(cell.content.slice(0, sourceOffset)) / + (cell.estimatedWrapColumns ?? Infinity), + ); +} + +function estimatedFragment(cell: DiffCell, visualIndex: number, lineHeight: number) { + const columns = cell.estimatedWrapColumns ?? Infinity; + const lastIndex = Math.max(0, Math.ceil(estimatedDisplayOffset(cell.content) / columns) - 1); + const index = Math.min(visualIndex, lastIndex); + const displayOffset = index === 0 ? 0 : index * columns; + let start = Math.min(displayOffset, cell.content.length); + if (cell.content.includes("\t")) { + let offset = 0; + start = 0; + while (start < cell.content.length && offset < displayOffset) { + offset += cell.content[start] === "\t" ? 4 : 1; + start += 1; + } + } + return { start, top: index * lineHeight }; +} + +// A resize can move the anchor outside the old window. Measure that row in the +// same pass: estimated columns cannot identify exact shaped-text fragments. +function containsMaterializationAnchor( + input: BuildDiffDocumentModelInput, + path: string, + cells: DiffLineRow["cells"], +): boolean { + const anchor = input.materializationAnchor; + return ( + anchor?.kind === "text" && + anchor.path === path && + cells.some((cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity)) + ); +} + function intersectsMaterializationWindow( input: BuildDiffDocumentModelInput, fileTop: number, @@ -795,17 +875,27 @@ export function captureScrollAnchor( } const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight)); const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight)); - let cellIndex = row.cells.findIndex((cell) => Boolean(cell?.fragments[visualIndex])); + let cellIndex = row.cells.findIndex( + (cell) => + cell && + (cell.fragments.length > 0 + ? Boolean(cell.fragments[visualIndex]) + : estimatedFragment(cell, visualIndex, model.lineHeight).top === + visualIndex * model.lineHeight), + ); if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null); const cell = row.cells[cellIndex]; if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop }; - const fragment = cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]; - const anchorY = row.top + (fragment?.top ?? 0); + const fragment = + cell.fragments.length > 0 + ? cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]! + : estimatedFragment(cell, visualIndex, model.lineHeight); + const anchorY = row.top + fragment.top; return { kind: "text", path: file.path, sourceIdentity: cell.sourceIdentity, - cellOffset: fragment?.start ?? 0, + cellOffset: fragment.start, viewportDelta: anchorY - scrollTop, }; } @@ -831,8 +921,10 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll const fragment = cell.fragments.find( (candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end, - ) ?? cell.fragments.at(-1); - return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta); + ) ?? + cell.fragments.at(-1) ?? + estimatedFragment(cell, estimatedVisualLine(cell, anchor.cellOffset), model.lineHeight); + return Math.max(0, row.top + fragment.top - anchor.viewportDelta); } export function resolveRelayoutScrollTop( diff --git a/packages/app/src/git/diff-document/surface.native.tsx b/packages/app/src/git/diff-document/surface.native.tsx index 2063198712..00a47437cd 100644 --- a/packages/app/src/git/diff-document/surface.native.tsx +++ b/packages/app/src/git/diff-document/surface.native.tsx @@ -38,6 +38,7 @@ import { } from "./horizontal-offsets"; import { buildDiffDocumentModel, + captureScrollAnchor, resolveRelayoutScrollTop, retainReusableModels, shouldApplyRelayoutScroll, @@ -138,6 +139,9 @@ export function DiffSurface(props: DiffSurfaceProps) { tooLarge: t("workspace.git.diff.tooLarge"), }, materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height), + materializationAnchor: previousModelRef.current + ? captureScrollAnchor(previousModelRef.current, scrollTop.get()) + : null, reuseFrom, }); reusableModelRef.current = { @@ -148,6 +152,7 @@ export function DiffSurface(props: DiffSurfaceProps) { }, [ measurement, fileWindowTop, + scrollTop, props.collapsedFilePaths, props.displayPreferences.layout, props.displayPreferences.wrapLines, diff --git a/packages/app/src/git/diff-document/surface.web.tsx b/packages/app/src/git/diff-document/surface.web.tsx index 97801b708f..393f6dfcd7 100644 --- a/packages/app/src/git/diff-document/surface.web.tsx +++ b/packages/app/src/git/diff-document/surface.web.tsx @@ -28,10 +28,20 @@ import { } from "./hit-testing"; import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets"; import { HorizontalScroll } from "./horizontal-scroll.web"; -import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model"; +import { + buildDiffDocumentModel, + captureScrollAnchor, + FILE_HEADER_HEIGHT, + resolveRelayoutScrollTop, +} from "./model"; import { paintWebFileHeader, paintWebViewport } from "./paint.web"; import { hasPointerDragStarted } from "./pointer-gesture"; -import { createMeasuredAdvances } from "./text-measurement"; +import { + createMeasuredAdvances, + createAdditiveAdvances, + createChunkedWidthMeasurer, + requiresShaping, +} from "./text-measurement"; import { retainDiffViewport } from "./viewport"; import type { DiffHit, @@ -183,6 +193,9 @@ export function DiffSurface(props: DiffSurfaceProps) { tooLarge: t("workspace.git.diff.tooLarge"), }, materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height), + materializationAnchor: previousModelRef.current + ? captureScrollAnchor(previousModelRef.current, scrollTopRef.current) + : null, }); return next; }, [ @@ -1166,5 +1179,13 @@ function createWebTextMeasurer(typography: DiffTypography): TextMeasurer { }; // Canvas exposes no glyph coverage, so `requiresShaping` alone decides which // runs a shaper has to see -- there is no `glyphIds` to fall back on. - return { measure, measureAdvances: createMeasuredAdvances((text) => measure(text)) }; + const additive = createAdditiveAdvances(measure); + return { + measure, + measureAdvances: createMeasuredAdvances(measure), + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + return requiresShaping(text) ? measure(text) : (additive(graphemes).at(-1) ?? 0); + }), + }; } diff --git a/packages/app/src/git/diff-document/types.ts b/packages/app/src/git/diff-document/types.ts index 4ac8125445..d98880eb8c 100644 --- a/packages/app/src/git/diff-document/types.ts +++ b/packages/app/src/git/diff-document/types.ts @@ -112,6 +112,8 @@ export interface DiffCell { lineNumber: number | null; tokens: DiffTokenRun[]; fragments: DiffFragment[]; + /** Monospace columns per visual line until this cell is measured. */ + estimatedWrapColumns?: number; reviewTarget: ReviewableDiffTarget | null; sourceIdentity: DiffSourceIdentity; } @@ -185,6 +187,8 @@ export interface BuildDiffDocumentModelInput { reviewActions?: InlineReviewActions; labels: { binary: string; tooLarge: string }; materializationWindow?: { top: number; height: number }; + /** Also measure the anchored row so relayout can restore its exact fragment. */ + materializationAnchor?: DiffScrollAnchor | null; /** A geometry-compatible model whose unchanged file measurements may be reused. */ reuseFrom?: readonly DiffDocumentModel[]; } diff --git a/packages/app/src/git/diff-document/workspace-cache.test.ts b/packages/app/src/git/diff-document/workspace-cache.test.ts index ddf9972a46..a264d417ca 100644 --- a/packages/app/src/git/diff-document/workspace-cache.test.ts +++ b/packages/app/src/git/diff-document/workspace-cache.test.ts @@ -95,6 +95,37 @@ describe("diff document workspace cache", () => { expect(stats.calls).toBe(0); }); + it("materializes a new anchor when the viewport window is unchanged", () => { + const cache = createDiffDocumentWorkspaceCache(); + const source = diffFile(); + source.hunks[0]!.lines = Array.from({ length: 100 }, () => ({ + type: "add" as const, + content: "x".repeat(200), + })); + const { measureText } = countingMeasurer(); + const input = modelInput([source], measureText, { + wrapLines: true, + materializationWindow: { top: 30, height: 54 }, + }); + const first = cache.buildModel(input); + const anchored = cache.buildModel({ + ...input, + materializationAnchor: { + kind: "text", + path: source.path, + sourceIdentity: { hunkIndex: 0, lineIndex: 50, side: "new" }, + cellOffset: 90, + viewportDelta: -2, + }, + }); + const firstRow = first.rows[50]!; + const anchoredRow = anchored.rows[50]!; + expect(firstRow.kind === "line" && firstRow.cells[0]!.fragments.length).toBe(0); + expect(anchoredRow.kind === "line" && anchoredRow.cells[0]!.fragments.length).toBeGreaterThan( + 0, + ); + }); + it("retains text cells for unchanged files when another file changes", () => { const cache = createDiffDocumentWorkspaceCache(); const { measureText } = countingMeasurer(); diff --git a/packages/app/src/git/diff-document/workspace-cache.tsx b/packages/app/src/git/diff-document/workspace-cache.tsx index 5b9921e31d..dac4d96c2e 100644 --- a/packages/app/src/git/diff-document/workspace-cache.tsx +++ b/packages/app/src/git/diff-document/workspace-cache.tsx @@ -128,7 +128,9 @@ function exactModelKey(input: Omit): s const materializationWindow = input.materializationWindow ? [input.materializationWindow.top, input.materializationWindow.height] : null; - return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]); + const anchor = input.materializationAnchor; + const anchoredRow = anchor?.kind === "text" ? [anchor.path, anchor.sourceIdentity] : null; + return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow, anchoredRow]); } function typographyKey(typography: DiffTypography): string { diff --git a/packages/app/src/git/pull-request-panel/checks-ring.tsx b/packages/app/src/git/pull-request-panel/checks-ring.tsx index d5ae3e6226..75427adabc 100644 --- a/packages/app/src/git/pull-request-panel/checks-ring.tsx +++ b/packages/app/src/git/pull-request-panel/checks-ring.tsx @@ -1,3 +1,4 @@ +import { isNative } from "@/constants/platform"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; @@ -75,8 +76,9 @@ function ChecksRingSvg({ height={size} viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`} style={ringStyles.svg} - accessibilityElementsHidden - importantForAccessibility="no-hide-descendants" + {...(isNative + ? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" } + : { "aria-hidden": true })} > { expect(findProvider("minimax-code")).toMatchObject({ title: "MiniMax Code", version: "0.6.13", - command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"], + // The package publishes `mcode`, not `code`, so npx needs the package and + // the binary named separately. + command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"], }); expect(findProvider("minimax-code").iconSvg).toContain(" { - const module = await import("./use-agent-history"); - fetchAgentHistoryBatch = module.fetchAgentHistoryBatch; - fetchAgentHistoryPage = module.fetchAgentHistoryPage; - collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors; -}); +const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } = + await import("./use-agent-history"); type FetchAgentHistory = DaemonClient["fetchAgentHistory"]; type FetchAgentHistoryResult = Awaited>; diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index f2726fba02..e517021b6b 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -78,6 +78,7 @@ describe("ensureAgentIsInitialized", () => { cursor: { epoch: "epoch-1", seq: 42 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -105,6 +106,7 @@ describe("ensureAgentIsInitialized", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -148,6 +150,7 @@ describe("ensureAgentIsInitialized", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -192,6 +195,7 @@ describe("ensureAgentIsInitialized", () => { cursor: { epoch: "epoch-1", seq: 12 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); @@ -283,6 +287,7 @@ describe("refreshAgent", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); 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 19caef4f8b..68102ed131 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 @@ -422,6 +422,23 @@ describe("deriveAgentScreenViewState", () => { expect(result.memory.lastReadyAgent).toBeNull(); }); + it.each(["error", "retrying"] as const)("shows archived history sync %s", (status) => { + const result = deriveAgentScreenViewState({ + input: { + ...createBaseInput(), + agent: createAgent("agent-1"), + isArchived: true, + hasHydratedHistoryBefore: true, + visibilityCatchUpStatus: status, + }, + memory: createBaseMemory(), + }); + expect(expectReadyState(result.state).sync).toEqual({ + status: "sync_error", + isRetrying: status === "retrying", + }); + }); + it("renders an archived agent before provider history is initialized", () => { const result = deriveAgentScreenViewState({ input: { 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 591fd1abb1..e0cc75ee06 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -175,10 +175,14 @@ function resolveAgentScreenSync(args: { hadInitialSyncFailure: boolean; }): AgentScreenReadySyncState { const { input, hadInitialSyncFailure } = args; - if (input.isArchived) { + if ( + input.isArchived && + input.visibilityCatchUpStatus !== "error" && + input.visibilityCatchUpStatus !== "retrying" + ) { return { status: "idle" }; } - if (!input.isConnected) { + if (!input.isConnected && !input.isArchived) { return { status: "reconnecting" }; } if (input.missingAgentState.kind === "error") { diff --git a/packages/app/src/hooks/use-directory-demand.test.tsx b/packages/app/src/hooks/use-directory-demand.test.tsx new file mode 100644 index 0000000000..7a03d83471 --- /dev/null +++ b/packages/app/src/hooks/use-directory-demand.test.tsx @@ -0,0 +1,40 @@ +/** + * @vitest-environment jsdom + */ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useDirectoryDemand } from "./use-directory-demand"; + +function Probe({ serverId }: { serverId: string | null }) { + useDirectoryDemand(serverId); + return null; +} + +describe("useDirectoryDemand", () => { + let root: Root | null = null; + afterEach(() => { + act(() => root?.unmount()); + root = null; + vi.restoreAllMocks(); + }); + + it("demands the host directory while mounted and releases it on unmount", () => { + const release = vi.fn(); + const acquire = vi + .spyOn(getHostRuntimeStore(), "acquireDirectoryDemand") + .mockImplementation(() => release); + const container = document.createElement("div"); + root = createRoot(container); + + act(() => root?.render()); + expect(acquire).toHaveBeenCalledTimes(1); + expect(acquire).toHaveBeenCalledWith("srv_a"); + expect(release).not.toHaveBeenCalled(); + + act(() => root?.render()); + expect(release).toHaveBeenCalledTimes(1); + expect(acquire).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/hooks/use-directory-demand.ts b/packages/app/src/hooks/use-directory-demand.ts new file mode 100644 index 0000000000..f4d2953ab0 --- /dev/null +++ b/packages/app/src/hooks/use-directory-demand.ts @@ -0,0 +1,16 @@ +import { useEffect } from "react"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; + +/** + * Keeps a host's directory demanded while the caller is mounted. + * + * The sidebar only demands the directory while its panel is active, so a compact app that + * restores straight into a workspace or agent route has nothing else asking the host for + * workspaces and agents. Without demand the route waits on hydration that never starts. + */ +export function useDirectoryDemand(serverId: string | null | undefined): void { + useEffect(() => { + if (!serverId) return; + return getHostRuntimeStore().acquireDirectoryDemand(serverId); + }, [serverId]); +} diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 1da68b4edf..3259631992 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -152,6 +152,7 @@ describe("loadOlderAgentHistory", () => { cursor: { epoch: "epoch-1", seq: 10 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }, }, ]); diff --git a/packages/app/src/hooks/use-preferred-editor.test.ts b/packages/app/src/hooks/use-preferred-editor.test.ts index 49a3c4c18f..120be39fb1 100644 --- a/packages/app/src/hooks/use-preferred-editor.test.ts +++ b/packages/app/src/hooks/use-preferred-editor.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { resolvePreferredEditorId } from "./use-preferred-editor"; +import { QueryClient } from "@tanstack/react-query"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createPreferredEditorPreference, resolvePreferredEditorId } from "./use-preferred-editor"; describe("resolvePreferredEditorId", () => { it("keeps the stored editor when it is still available", () => { @@ -30,3 +32,78 @@ describe("resolvePreferredEditorId", () => { expect(resolvePreferredEditorId([], "cursor")).toBeNull(); }); }); + +describe("project editor preferences", () => { + it("changing one project leaves another project's cached and persisted choice unchanged", async () => { + await AsyncStorage.clear(); + await AsyncStorage.setItem("@paseo:preferred-editor", "vscode"); + const queryClient = new QueryClient(); + const projectA = createPreferredEditorPreference({ + scope: { serverId: "host", projectId: "project-a" }, + queryClient, + storage: AsyncStorage, + }); + const projectB = createPreferredEditorPreference({ + scope: { serverId: "host", projectId: "project-b" }, + queryClient, + storage: AsyncStorage, + }); + expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("vscode"); + expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode"); + await projectA.update("cursor"); + expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("cursor"); + expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode"); + queryClient.clear(); + expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("cursor"); + expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode"); + expect(await AsyncStorage.getItem("@paseo:preferred-editor")).toBe("vscode"); + queryClient.clear(); + }); + it("shares a choice across workspaces of the same project while isolating hosts", async () => { + await AsyncStorage.clear(); + const queryClient = new QueryClient(); + const root = createPreferredEditorPreference({ + scope: { serverId: "host-a", projectId: "project" }, + queryClient, + storage: AsyncStorage, + }); + const worktree = createPreferredEditorPreference({ + scope: { serverId: "host-a", projectId: "project" }, + queryClient, + storage: AsyncStorage, + }); + const otherHost = createPreferredEditorPreference({ + scope: { serverId: "host-b", projectId: "project" }, + queryClient, + storage: AsyncStorage, + }); + await root.update("zed"); + expect(await queryClient.fetchQuery(worktree.queryOptions)).toBe("zed"); + expect(await queryClient.fetchQuery(otherHost.queryOptions)).toBeNull(); + queryClient.clear(); + expect(await queryClient.fetchQuery(worktree.queryOptions)).toBe("zed"); + expect(await queryClient.fetchQuery(otherHost.queryOptions)).toBeNull(); + queryClient.clear(); + }); + + it("preserves the previous choice when saving fails", async () => { + await AsyncStorage.clear(); + await AsyncStorage.setItem("@paseo:preferred-editor", "vscode"); + const queryClient = new QueryClient(); + const preference = createPreferredEditorPreference({ + scope: { serverId: "host", projectId: "project" }, + queryClient, + storage: { + getItem: AsyncStorage.getItem, + removeItem: AsyncStorage.removeItem, + async setItem() { + throw new Error("Storage is full"); + }, + }, + }); + expect(await queryClient.fetchQuery(preference.queryOptions)).toBe("vscode"); + await expect(preference.update("cursor")).rejects.toThrow("Storage is full"); + expect(await queryClient.fetchQuery(preference.queryOptions)).toBe("vscode"); + queryClient.clear(); + }); +}); diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts index b216185182..234ff4a0f8 100644 --- a/packages/app/src/hooks/use-preferred-editor.ts +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -1,16 +1,57 @@ -import { useCallback } from "react"; +import { useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; -import { readValidatedString } from "@/storage/validated-storage"; +import { readValidatedString, type ValidatedStorage } from "@/storage/validated-storage"; type EditorTargetId = string; const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor"; -const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"]; -async function loadPreferredEditor(): Promise { - return readValidatedString(AsyncStorage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1)); +interface EditorPreferenceStorage extends ValidatedStorage { + setItem(key: string, value: string): Promise; +} + +interface ProjectEditorScope { + serverId: string; + projectId: string | null; +} + +export function createPreferredEditorPreference({ + scope, + queryClient, + storage, +}: { + scope: ProjectEditorScope; + queryClient: QueryClient; + storage: EditorPreferenceStorage; +}) { + const queryKey = ["preferred-editor", scope.serverId, scope.projectId]; + const storageKey = `${PREFERRED_EDITOR_STORAGE_KEY}:${JSON.stringify([scope.serverId, scope.projectId])}`; + async function load() { + if (scope.projectId === null) return null; + const projectEditor = await readValidatedString(storage, storageKey, z.string().trim().min(1)); + if (projectEditor !== null) return projectEditor; + return readValidatedString(storage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1)); + } + return { + queryOptions: { + queryKey, + queryFn: load, + enabled: scope.projectId !== null, + staleTime: Infinity, + gcTime: Infinity, + }, + async update(editorId: EditorTargetId | null) { + if (scope.projectId === null) return; + if (editorId) { + await storage.setItem(storageKey, editorId); + } else { + await storage.removeItem(storageKey); + } + queryClient.setQueryData(queryKey, await load()); + }, + }; } export function resolvePreferredEditorId( @@ -29,30 +70,24 @@ export function resolvePreferredEditorId( return availableEditorIds[0] ?? null; } -export function usePreferredEditor() { +export function usePreferredEditor(scope: ProjectEditorScope) { const queryClient = useQueryClient(); - const { data, isPending } = useQuery({ - queryKey: PREFERRED_EDITOR_QUERY_KEY, - queryFn: loadPreferredEditor, - staleTime: Infinity, - gcTime: Infinity, - }); - - const updatePreferredEditor = useCallback( - async (editorId: EditorTargetId | null) => { - queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId); - if (editorId) { - await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId); - return; - } - await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY); - }, - [queryClient], + const { serverId, projectId } = scope; + const preference = useMemo( + () => + createPreferredEditorPreference({ + scope: { serverId, projectId }, + queryClient, + storage: AsyncStorage, + }), + [queryClient, serverId, projectId], ); + const { data, isPending } = useQuery(preference.queryOptions); + const isLoading = projectId !== null && isPending; return { - preferredEditorId: isPending ? undefined : (data ?? null), - isLoading: isPending, - updatePreferredEditor, + preferredEditorId: isLoading ? undefined : (data ?? null), + isLoading, + updatePreferredEditor: preference.update, }; } diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 2f3c90b340..0c730cddbd 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -1,4 +1,4 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { beforeEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -8,7 +8,7 @@ import { expandProviderSnapshot, } from "@getpaseo/protocol/provider-snapshot-codec"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; -import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { draftAgentCommandsQueryKey, sessionAgentCommandsQueryKey } from "@/agent-commands/cache"; import { providerSnapshotIcons } from "@/data/provider-icons"; import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router"; import { @@ -363,6 +363,153 @@ describe("applyProvidersSnapshotUpdate", () => { }; } + it("refreshes active commands only when their provider changes after discovery", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + let requests = 0; + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { + requests++; + return [{ name: "review" }]; + }, + staleTime: Infinity, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(1); + const codex = codexEntry("ready", [readyCodexModel]); + const other = { provider: "claude", status: "loading", enabled: true } as ProviderSnapshotEntry; + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("loading"), other], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codex, other], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codex, { ...other, status: "ready" }], "/repo-a"), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [ + { ...codex, fetchedAt: "2026-01-01T00:00:02.000Z" }, + { ...other, status: "ready" }, + ], + "/repo-a", + ), + }); + expect(requests).toBe(1); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-b"), + providersSnapshot([codex]), + ); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [codexEntry("ready", [{ ...readyCodexModel, label: "Other checkout" }])], + "/repo-b", + ), + }); + expect(requests).toBe(1); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage( + [ + codexEntry("ready", [{ ...readyCodexModel, label: "Updated" }]), + { ...other, status: "ready" }, + ], + "/repo-a", + ), + }); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(2); + unsubscribe(); + queryClient.clear(); + }); + + it("retries a failed command discovery when its provider first becomes ready", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + let ready = false; + let requests = 0; + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { + requests++; + if (!ready) throw new Error("provider unavailable"); + return [{ name: "review" }]; + }, + retry: false, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + await observer.refetch({ cancelRefetch: false }); + expect(observer.getCurrentResult().isError).toBe(true); + ready = true; + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), + }); + await observer.refetch({ cancelRefetch: false }); + expect(requests).toBe(2); + expect(observer.getCurrentResult().data).toEqual([{ name: "review" }]); + unsubscribe(); + queryClient.clear(); + }); + + it("refreshes again when its provider changes during an active refresh", async () => { + const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" }); + queryClient.setQueryData(key, [{ name: "old" }]); + let requests = 0; + const waiting = new Promise<{ name: string }[]>(() => {}); + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => (++requests === 1 ? waiting : [{ name: "new" }]), + staleTime: Infinity, + meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" }, + }); + const unsubscribe = observer.subscribe(() => {}); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-a"), + providersSnapshot([codexEntry("ready", [readyCodexModel])]), + ); + const send = (label: string) => + applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [{ ...readyCodexModel, label }])], "/repo-a"), + }); + await send("First change"); + expect(requests).toBe(1); + await send("Second change"); + expect(requests).toBe(2); + await observer.refetch({ cancelRefetch: false }); + expect(observer.getCurrentResult().data).toEqual([{ name: "new" }]); + unsubscribe(); + queryClient.clear(); + }); + it("routes updates to the home query cache when the message carries no cwd", async () => { await applyProvidersSnapshotUpdate({ client: createClient(), @@ -455,6 +602,10 @@ describe("applyProvidersSnapshotUpdate", () => { draftConfig: { provider: "codex", cwd: "/repo-a" }, }); queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]); + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, "/repo-a"), + providersSnapshot([codexEntry("ready")]), + ); await applyProvidersSnapshotUpdate({ client: createClient(), diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 66a8e767b1..382ab350fc 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -42,12 +42,14 @@ import { saveAppSettings as saveAppSettingsPure, type AppSettings, type AppSettingsUpdate, + type ChatDisplay, type OpenInSidePanePreferences, type PullRequestOpenLocation, type DesktopSettingsBridge, type KeyValueStorage, type ReleaseChannel, type SendBehavior, + type SendShortcut, type ServiceUrlBehavior, type Settings, type SidebarWorkspaceTrailing, @@ -84,6 +86,7 @@ export { export type { AppSettings, AppSettingsUpdate, + ChatDisplay, AppLanguage, OpenInSidePanePreferences, PullRequestOpenLocation, @@ -91,6 +94,7 @@ export type { KeyValueStorage, ReleaseChannel, SendBehavior, + SendShortcut, ServiceUrlBehavior, Settings, SettingsDeps, diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index dadb912a24..0b169b5214 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -185,6 +185,7 @@ describe("loadAppSettingsFromStorage", () => { expect(result).toEqual(DEFAULT_CLIENT_SETTINGS); expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system"); + expect(DEFAULT_CLIENT_SETTINGS.chatDisplay).toBe("collapseTurns"); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual( DEFAULT_CLIENT_SETTINGS, ); @@ -226,6 +227,39 @@ describe("loadAppSettingsFromStorage", () => { expect(result.chatOutlineEnabled).toBe(false); }); + it("uses Enter to send by default", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("enter"); + expect(DEFAULT_CLIENT_SETTINGS.sendShortcut).toBe("enter"); + }); + + it("loads the modifier Enter send preference", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "mod-enter" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("mod-enter"); + }); + + it("falls back to Enter when the stored send shortcut is invalid", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "yes" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sendShortcut).toBe("enter"); + }); + it("defaults sidebar navigation items to an empty preference list", async () => { const deps = makeDeps(); @@ -454,6 +488,7 @@ describe("loadAppSettingsFromStorage", () => { ...DEFAULT_CLIENT_SETTINGS, theme: "dark", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", }); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({ manageBuiltInDaemon: false, @@ -592,6 +627,7 @@ describe("loadSettingsFromStorage", () => { ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", }); }); @@ -636,6 +672,7 @@ describe("loadSettingsFromStorage", () => { ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", manageBuiltInDaemon: false, releaseChannel: "beta", }); @@ -657,6 +694,7 @@ describe("loadSettingsFromStorage", () => { ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", }); }); }); @@ -732,7 +770,7 @@ describe("saveAppSettings", () => { ...DEFAULT_CLIENT_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, - toolCallDetailLevel: "overview", + chatDisplay: "collapseToolCalls", }); }); @@ -787,27 +825,45 @@ describe("appearance settings", () => { expect(result.contentFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE); expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE); expect(result.syntaxTheme).toBe("one"); - expect(result.toolCallDetailLevel).toBe("detailed"); + expect(result.chatDisplay).toBe("full"); }); - it("migrates the enabled compact tool call preference to overview", async () => { + it("migrates the enabled compact tool call preference to collapsed tool calls", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); + expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseToolCalls"); }); - it("clears settings with an unrecognized tool call detail level", async () => { + it.each([ + ["overview", "collapseToolCalls"], + ["concise", "collapseToolCalls"], + ["detailed", "full"], + ["unknown", "full"], + ])("reads the %s tool call detail level as the %s chat display", async (legacy, display) => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ - [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }), + [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: legacy }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed"); + expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe(display); + }); + + it("keeps a stored chat display over the tool call detail level it replaced", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + chatDisplay: "collapseTurns", + toolCallDetailLevel: "detailed", + }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseTurns"); }); it("migrates a switched-off checks row item to the hidden checks display", async () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 4fe08bb6d5..424536ac69 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -33,6 +33,8 @@ import { migrateAppSettings } from "./migrations"; export { APP_SETTINGS_KEY } from "./keys"; export const APP_SETTINGS_QUERY_KEY = ["app-settings"]; +export type SendShortcut = "enter" | "mod-enter"; + export type SendBehavior = ActiveTurnBehavior | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; @@ -40,7 +42,8 @@ export type WorkspaceTitleSource = "title" | "branch"; export type PullRequestOpenLocation = "main" | "side" | "explorer"; /** What a sidebar workspace row shows in the space to the right of its title. */ export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none"; -export type ToolCallDetailLevel = "overview" | "detailed"; +/** How much of an agent's work the chat shows between the user's message and the reply. */ +export type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns"; const ThemePreferenceSchema = z.enum([ ...THEME_OPTIONS.map((option) => option.name), @@ -100,9 +103,10 @@ export interface AppSettings { /** How usage reads and which windows the sidebar summary shows. */ usage: UsagePreferences; autoExpandReasoning: boolean; - toolCallDetailLevel: ToolCallDetailLevel; + chatDisplay: ChatDisplay; chatOutlineEnabled: boolean; vimKeybindings: boolean; + sendShortcut: SendShortcut; filePanelAssociations: Record; /** Desktop-only preferences for implicit opens into the ordinary side pane. */ openInSidePane: OpenInSidePanePreferences; @@ -156,9 +160,10 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { sidebarFooterItems: [], usage: DEFAULT_USAGE_PREFERENCES, autoExpandReasoning: false, - toolCallDetailLevel: "detailed", + chatDisplay: "collapseTurns", chatOutlineEnabled: true, vimKeybindings: false, + sendShortcut: "enter", filePanelAssociations: {}, openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES, pullRequestOpenLocation: "explorer", @@ -199,6 +204,7 @@ const SidebarRowItemsSchema = z type StoredAppSettingsFallback = AppSettings & { uiFontSize?: number; compactToolCalls?: boolean; + toolCallDetailLevel?: "overview" | "detailed"; manageBuiltInDaemon?: boolean; releaseChannel?: ReleaseChannel; needsWrite: boolean; @@ -251,15 +257,18 @@ const StoredAppSettingsSchema = z sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]), usage: UsagePreferencesSchema, autoExpandReasoning: z.boolean().catch(false), + chatDisplay: z.enum(["full", "collapseToolCalls", "collapseTurns"]).optional().catch("full"), + // COMPAT(toolCallDetailLevel): replaced by chatDisplay in v0.13, remove after 2027-04-24. toolCallDetailLevel: z .enum(["overview", "detailed"]) .or(z.literal("concise").transform(() => "overview" as const)) .optional() - .catch("detailed"), + .catch(undefined), // COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12. compactToolCalls: z.boolean().optional().catch(undefined), chatOutlineEnabled: z.boolean().catch(true), vimKeybindings: z.boolean().catch(false), + sendShortcut: z.enum(["enter", "mod-enter"]).catch("enter"), filePanelAssociations: z.record(z.string(), z.string()).catch({}), openInSidePane: z .object({ @@ -305,8 +314,10 @@ const StoredAppSettingsSchema = z (isChecksHiddenByLegacyRowItem(stored.sidebarRowItems) ? "none" : DEFAULT_SIDEBAR_CHECKS_DISPLAY); - const toolCallDetailLevel = - stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed"); + const groupsToolCalls = stored.toolCallDetailLevel + ? stored.toolCallDetailLevel === "overview" + : stored.compactToolCalls === true; + const chatDisplay = stored.chatDisplay ?? (groupsToolCalls ? "collapseToolCalls" : "full"); return { ...stored, openInSidePane, @@ -321,7 +332,7 @@ const StoredAppSettingsSchema = z stored.sidebarRowItems.services ?? (stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services), }, - toolCallDetailLevel, + chatDisplay, needsWrite, }; }) @@ -449,6 +460,7 @@ export function normalizeAppSettings(value: unknown): AppSettings { manageBuiltInDaemon: _manageBuiltInDaemon, releaseChannel: _releaseChannel, compactToolCalls: _compactToolCalls, + toolCallDetailLevel: _toolCallDetailLevel, uiFontSize: _uiFontSize, ...settings } = StoredAppSettingsSchema.parse(value); diff --git a/packages/app/src/hosts/host-chooser.tsx b/packages/app/src/hosts/host-chooser.tsx index 5e4d7f053c..3d807ba3a1 100644 --- a/packages/app/src/hosts/host-chooser.tsx +++ b/packages/app/src/hosts/host-chooser.tsx @@ -1,13 +1,7 @@ +import { Modal } from "@/components/ui/modal"; import { usePanelStore } from "@/stores/panel-store"; import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Modal, - Pressable, - ScrollView, - Text, - View, - type PressableStateCallbackType, -} from "react-native"; +import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { EditingTextInput as TextInput, type EditingTextInputHandle, diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 18ba3f041e..b73b2c4fec 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -832,7 +832,6 @@ describe("translation resources", () => { expect(en.common.placeholders.search).toBe("Search..."); expect(en.common.empty.noResults).toBe("No results found"); expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search."); - expect(en.toolCallDetails.subAgentActivity).toBe("Sub-agent activity"); expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview"); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c765f3b835..8d7c2a2950 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -67,6 +67,7 @@ export const ar: TranslationResources = { daemonClientUnavailable: "عميل Daemon غير متوفر", daemonClientDisconnected: "تم قطع اتصال عميل Daemon", noFileFound: "لم يتم العثور على ملف لـ{{token}}", + folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه", unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.", }, connectionStatus: { @@ -133,6 +134,8 @@ export const ar: TranslationResources = { sendAndInterrupt: "إرسال ومقاطعة", sendAndSteer: "إرسال وتوجيه", sendMessage: "أرسل رسالة", + longPressToQueue: "اضغط مطولًا لإضافتها إلى الطابور", + longPressToSendNow: "اضغط مطولًا للإرسال الآن", queue: "طابور", send: "يرسل", }, @@ -614,6 +617,7 @@ export const ar: TranslationResources = { terminal: { hostDisconnected: "Host غير متصل", updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", + restoreRequiresUpdate: "حدّث المضيف لعرض الطرفيات.", unableToSubscribe: "غير قادر على الاشتراك في المحطة", fileUploaded: "تم رفع الملف", pasteFileFailed: "تعذر لصق الملف: {{reason}}", @@ -1908,6 +1912,12 @@ export const ar: TranslationResources = { backdrop: "خلفية القائمة", }, subagents: { + parent: { + backToParent: "العودة إلى الوكيل الأصلي", + backTo: "العودة إلى {{label}}", + parentAgent: "الوكيل الأصلي", + openParent: "فتح الوكيل الأصلي", + }, title: "الوكلاء الفرعيون", pillLabelOne: "وكيل فرعي واحد", pillLabelMany: "{{count}} وكلاء فرعيين", @@ -2004,11 +2014,17 @@ export const ar: TranslationResources = { toolCallDetails: { error: "خطأ", empty: "لا توجد تفاصيل إضافية متاحة", - subAgentActivity: "نشاط الوكيل الفرعي", input: "مدخل", output: "الإخراج", }, + turnSummary: { + details: "عرض تفاصيل الجولة", + sentBy: "أرسلها {{name}}", + showMore: "عرض المزيد", + showLess: "عرض أقل", + }, toolCallGroup: { + thought: "فكّر", editedFiles: { one: "حرّر {{count}} ملفًا", other: "حرّر {{count}} ملفات", @@ -2076,6 +2092,11 @@ export const ar: TranslationResources = { }, }, providerUsage: { + checkUsage: "التحقق من الاستخدام", + checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة", + off: "التحقق من الاستخدام متوقف على {{host}}.", + turnOn: "تشغيل", + options: "الإعدادات", refreshFailed: "تعذّر تحديث الاستخدام", noHosts: "لا توجد مضيفات متصلة", @@ -2141,6 +2162,7 @@ export const ar: TranslationResources = { minutes_few: "{{value}} دقائق", minutes_many: "{{value}} دقيقة", minutes_other: "{{value}} دقيقة", + pair: "{{first}} و{{second}}", }, timing: { lastUpdated: "آخر تحديث {{relativeTime}}", @@ -2148,6 +2170,8 @@ export const ar: TranslationResources = { resetTimePassed: "مضى وقت إعادة التعيين", resettingNow: "تتم إعادة التعيين الآن", resetsIn: "تتم إعادة التعيين خلال {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}، {{time}}", runsOutNow: "ينفد الآن", runsOutIn: "ينفد خلال {{duration}}", justNow: "الآن", @@ -2245,6 +2269,7 @@ export const ar: TranslationResources = { connections: "اتصالات", agents: "Agents", metadata: "البيانات الوصفية", + voice: "الصوت", workspaces: "Workspaces", providers: "مقدمي الخدمات", usage: "الاستخدام", @@ -2267,6 +2292,69 @@ export const ar: TranslationResources = { docs: "الوثائق", saveError: "تعذر تحديث إنشاء البيانات الوصفية", }, + voice: { + offline: "اتصل بهذا المضيف لضبط الصوت.", + updateHost: "حدّث المضيف لضبط الصوت.", + saveError: "تعذّر تحديث إعدادات الصوت", + provider: "المزوّد", + providers: { + local: "محلي", + openai: "OpenAI", + }, + voice: "الصوت", + save: "حفظ", + retry: "إعادة المحاولة", + cancel: "إلغاء", + model: "النموذج", + language: "اللغة", + languagePlaceholder: "اكتشاف تلقائي", + setBy: "محدد بواسطة {{name}}", + unknownProvider: "حدّث التطبيق لإعداد {{provider}}", + sites: { + dictationStt: "تحويل الكلام إلى نص", + voiceModeStt: "الاستماع", + voiceModeTts: "التحدث", + }, + dictation: { + title: "الإملاء", + enabled: "الإملاء", + enabledHint: "تحدّث في مربع الرسالة بدلًا من الكتابة", + }, + voiceMode: { + title: "وضع الصوت", + info: "يحوّل وضع الصوت كلامك إلى نص ويقرأ ردود الوكيل بصوت عالٍ.", + enabled: "وضع الصوت", + enabledHint: "تحدّث مع وكلائك دون استخدام يديك", + }, + openai: { + apiKey: "مفتاح API", + apiKeyHint: "يُحفظ على المضيف ولا يُعرض مرة أخرى.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "محفوظ", + missingKey: "مفتاح API مفقود", + baseUrl: "عنوان URL الأساسي", + baseUrlHint: "للخوادم المتوافقة مع OpenAI. اتركه فارغًا لاستخدام OpenAI.", + }, + models: { + loading: "جارٍ تحميل النماذج...", + loadError: "تعذّر تحميل النماذج المحلية", + none: "لا توجد نماذج متاحة", + downloaded: "منزّل", + inUse: "قيد الاستخدام", + recommended: "موصى به", + download: "تنزيل", + downloadAction: "تنزيل {{label}}", + downloading: "جارٍ التنزيل...", + downloadingPercent: "جارٍ التنزيل {{percent}}%", + extracting: "جارٍ الاستخراج...", + delete: "حذف", + deleteAction: "حذف {{label}}", + deleteTitle: "حذف {{label}}؟", + deleteMessage: "سيؤدي هذا إلى تحرير {{size}}. يمكنك تنزيله مرة أخرى لاحقًا.", + languages: "لغات {{label}}", + moreLanguages: "{{languages}} و{{count}} أخرى", + }, + }, general: { title: "عام", sending: "الإرسال", @@ -2290,12 +2378,25 @@ export const ar: TranslationResources = { "عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.", queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.", }, + commandEnterDescriptions: { + interrupt: "عند تشغيل الوكيل، يوقف {{modifier}}+Enter التشغيل. يُدرج Enter سطرًا جديدًا.", + steer: "عند تشغيل الوكيل، يوجّه {{modifier}}+Enter الجولة النشطة. يُدرج Enter سطرًا جديدًا.", + queue: + "عند تشغيل الوكيل، يضيف {{modifier}}+Enter إلى قائمة الانتظار. يُدرج Enter سطرًا جديدًا.", + }, options: { interrupt: "مقاطعة", steer: "توجيه", queue: "طابور", }, }, + sendShortcut: { + label: "اختصار الإرسال", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "بسأل", @@ -2312,12 +2413,13 @@ export const ar: TranslationResources = { label: "عرض التفكير دائماً", description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي", }, - toolCallDetail: { - label: "عرض استدعاءات الأدوات", - description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني", + chatDisplay: { + label: "عرض المحادثة", + description: "مقدار عمل الوكيل الذي تعرضه المحادثة", options: { - overview: "ملخص", - detailed: "التفاصيل الكاملة", + full: "كامل", + collapseToolCalls: "طيّ استدعاءات الأدوات", + collapseTurns: "طيّ الأدوار", }, }, language: { @@ -2684,6 +2786,19 @@ export const ar: TranslationResources = { }, agents: { unavailable: "Connect to this host to manage agents", + resumeOnStartup: { + title: "استئناف الوكلاء بعد إعادة التشغيل", + info: "إعادة فتح الوكلاء الذين كانوا يعملون عند توقف المضيف وإرسال طلب الاستئناف. ينطبق ذلك بعد إعادة التشغيل العادية والتعطّل.", + hint: "متابعة العمل المنقطع تلقائيًا.", + prompt: "طلب الاستئناف", + edit: "تحرير", + editAccessibilityLabel: "تعديل الطلب", + }, + idleRuntimeCleanup: { + title: "تحرير الوكلاء الخاملين", + hint: "تحرير الذاكرة بعد ساعة من الخمول.", + info: "يوقف عملية الوكيل مع الاحتفاظ بمحادثته. أرسل رسالة لاستئنافها.\n\nيمنع العمل النشط والإشعارات المعلّقة وفحوصات أمان المزوّد عملية التنظيف.", + }, }, workspaces: { unavailable: "Connect to this host to manage workspaces", @@ -2774,6 +2889,10 @@ export const ar: TranslationResources = { requestFailedTitle: "خطأ", requestFailedMessage: "فشل في إرسال طلب إعادة التشغيل. يقوم Paseo بإعادة الاتصال تلقائيًا - حاول مرة أخرى بمجرد ظهور المضيف على أنه متصل بالإنترنت.", + closeTerminals: "إعادة التشغيل وإغلاق الطرفيات", + closeTerminalsHint: "يعيد التشغيل ويغلق أيضًا كل الطرفيات المفتوحة على هذا المضيف.", + closeTerminalsConfirmMessage: + "سيُعاد تشغيل الخدمة وتُغلق كل طرفياتها، مما ينهي البرامج التي تعمل فيها. سيعيد التطبيق الاتصال تلقائيًا.", dialogFailedMessage: "غير قادر على فتح مربع حوار تأكيد إعادة التشغيل.", }, update: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 351fb33ac3..1f61a83f12 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -63,6 +63,7 @@ export const en = { daemonClientUnavailable: "Daemon client unavailable", daemonClientDisconnected: "Daemon client is disconnected", noFileFound: "No file found for {{token}}", + folderOutsideWorkspace: "{{path}} is outside this workspace", unexpectedDictationError: "An unexpected error occurred while handling dictation.", }, connectionStatus: { @@ -129,6 +130,8 @@ export const en = { sendAndInterrupt: "Send and interrupt", sendAndSteer: "Send and steer", sendMessage: "Send message", + longPressToQueue: "Long press to queue", + longPressToSendNow: "Long press to send now", queue: "Queue", send: "Send", }, @@ -611,6 +614,7 @@ export const en = { terminal: { hostDisconnected: "Host is not connected", updateHost: "Update the host to use the native terminal renderer.", + restoreRequiresUpdate: "Update the host to view terminals.", unableToSubscribe: "Unable to subscribe to terminal", fileUploaded: "File uploaded", pasteFileFailed: "Failed to paste file: {{reason}}", @@ -1933,6 +1937,12 @@ export const en = { backdrop: "Menu backdrop", }, subagents: { + parent: { + backToParent: "Back to parent", + backTo: "Back to {{label}}", + parentAgent: "Parent agent", + openParent: "Open parent", + }, title: "Subagents", pillLabelOne: "1 subagent", pillLabelMany: "{{count}} subagents", @@ -2029,11 +2039,17 @@ export const en = { toolCallDetails: { error: "Error", empty: "No additional details available", - subAgentActivity: "Sub-agent activity", input: "Input", output: "Output", }, + turnSummary: { + details: "View turn details", + sentBy: "Sent by {{name}}", + showMore: "Show more", + showLess: "Show less", + }, toolCallGroup: { + thought: "Thought", editedFiles: { one: "edited {{count}} file", other: "edited {{count}} files", @@ -2101,6 +2117,11 @@ export const en = { }, }, providerUsage: { + checkUsage: "Check usage", + checkUsageHint: "Applies to {{host}} on every device", + off: "Usage is off on {{host}}.", + turnOn: "Turn on", + options: "Settings", refreshFailed: "Unable to refresh usage", noHosts: "No connected hosts", @@ -2148,15 +2169,18 @@ export const en = { days: "{{value}}d", hours: "{{value}}h", minutes: "{{value}}m", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "Last updated {{relativeTime}}", noSuccessfulUpdate: "No successful update", resetTimePassed: "Reset time passed", resettingNow: "resetting now", - resetsIn: "resets {{duration}}", + resetsIn: "resets in {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}, {{time}}", runsOutNow: "runs out now", - runsOutIn: "runs out {{duration}}", + runsOutIn: "runs out in {{duration}}", justNow: "just now", daysAgo: "{{value}}d ago", hoursAgo: "{{value}}h ago", @@ -2272,6 +2296,7 @@ export const en = { connections: "Connections", agents: "Agents", metadata: "Metadata", + voice: "Voice", workspaces: "Workspaces", providers: "Providers", usage: "Usage", @@ -2356,6 +2381,69 @@ export const en = { docs: "Docs", saveError: "Unable to update metadata generation", }, + voice: { + offline: "Connect to this host to configure voice.", + updateHost: "Update the host to configure voice.", + saveError: "Unable to update voice settings", + provider: "Provider", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voice", + save: "Save", + retry: "Retry", + cancel: "Cancel", + model: "Model", + language: "Language", + languagePlaceholder: "Auto-detect", + setBy: "Set by {{name}}", + unknownProvider: "Update the app to configure {{provider}}", + sites: { + dictationStt: "Speech-to-text", + voiceModeStt: "Listening", + voiceModeTts: "Speaking", + }, + dictation: { + title: "Dictation", + enabled: "Dictation", + enabledHint: "Speak into the message box instead of typing", + }, + voiceMode: { + title: "Voice mode", + info: "Voice mode transcribes what you say and reads agent replies aloud.", + enabled: "Voice mode", + enabledHint: "Talk with your agents hands-free", + }, + openai: { + apiKey: "API key", + apiKeyHint: "Saved on the host and never shown again.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Saved", + missingKey: "API key missing", + baseUrl: "Base URL", + baseUrlHint: "For OpenAI-compatible servers. Leave empty to use OpenAI.", + }, + models: { + loading: "Loading models...", + loadError: "Couldn't load local models", + none: "No models available", + downloaded: "Downloaded", + inUse: "In use", + recommended: "Recommended", + download: "Download", + downloadAction: "Download {{label}}", + downloading: "Downloading...", + downloadingPercent: "Downloading {{percent}}%", + extracting: "Extracting...", + delete: "Delete", + deleteAction: "Delete {{label}}", + deleteTitle: "Delete {{label}}?", + deleteMessage: "This frees {{size}}. You can download it again later.", + languages: "{{label}} languages", + moreLanguages: "{{languages}} and {{count}} more", + }, + }, general: { title: "General", sending: "Sending", @@ -2378,12 +2466,26 @@ export const en = { "When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.", queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.", }, + commandEnterDescriptions: { + interrupt: + "When the agent is running, {{modifier}}+Enter interrupts. Enter inserts a new line.", + steer: + "When the agent is running, {{modifier}}+Enter steers the active turn. Enter inserts a new line.", + queue: "When the agent is running, {{modifier}}+Enter queues. Enter inserts a new line.", + }, options: { interrupt: "Interrupt", steer: "Steer", queue: "Queue", }, }, + sendShortcut: { + label: "Send shortcut", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Ask", @@ -2400,12 +2502,13 @@ export const en = { label: "Always expand reasoning", description: "Show agent thinking and chain-of-thought blocks fully expanded by default", }, - toolCallDetail: { - label: "Tool call display", - description: "How tool calls appear in the timeline", + chatDisplay: { + label: "Chat display", + description: "How much of an agent's work the chat shows", options: { - overview: "Summary", - detailed: "Full detail", + full: "Full", + collapseToolCalls: "Collapse tool calls", + collapseTurns: "Collapse turns", }, }, language: { @@ -2775,6 +2878,19 @@ export const en = { }, agents: { unavailable: "Connect to this host to manage agents", + resumeOnStartup: { + title: "Resume agents after restart", + info: "Reopen agents that were working when the host stopped and send the resume prompt. Applies after graceful restarts and crashes.", + hint: "Continue interrupted work automatically.", + prompt: "Resume prompt", + edit: "Edit", + editAccessibilityLabel: "Edit prompt", + }, + idleRuntimeCleanup: { + title: "Release idle agents", + hint: "Free up memory after an hour of inactivity.", + info: "Stops the agent's process while keeping its conversation. Send a message to resume.\n\nActive work, pending notifications, and provider safety checks prevent cleanup.", + }, }, workspaces: { unavailable: "Connect to this host to manage workspaces", @@ -2866,6 +2982,10 @@ export const en = { requestFailedTitle: "Error", requestFailedMessage: "Failed to send the restart request. Paseo reconnects automatically-try again once the host shows as online.", + closeTerminals: "Restart and close terminals", + closeTerminalsHint: "Restart and also close every terminal running on this host.", + closeTerminalsConfirmMessage: + "This will restart the daemon and close every terminal on it, ending the programs running in them. The app will reconnect automatically.", dialogFailedMessage: "Unable to open the restart confirmation dialog.", }, update: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index fd195ce599..7e5a1d41e6 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -68,6 +68,7 @@ export const es: TranslationResources = { daemonClientUnavailable: "ClienteDaemonno disponible", daemonClientDisconnected: "El clienteDaemonestá desconectado", noFileFound: "No se encontró ningún archivo para{{token}}", + folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo", unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.", }, connectionStatus: { @@ -134,6 +135,8 @@ export const es: TranslationResources = { sendAndInterrupt: "Enviar e interrumpir", sendAndSteer: "Enviar y orientar", sendMessage: "enviar mensaje", + longPressToQueue: "Mantén pulsado para poner en cola", + longPressToSendNow: "Mantén pulsado para enviar ahora", queue: "Cola", send: "Enviar", }, @@ -620,6 +623,7 @@ export const es: TranslationResources = { terminal: { hostDisconnected: "Hostno está conectado", updateHost: "Actualiza el host para usar el renderizador de terminal nativo.", + restoreRequiresUpdate: "Actualiza el host para ver las terminales.", unableToSubscribe: "No se puede suscribir al terminal", fileUploaded: "Archivo subido", pasteFileFailed: "No se pudo pegar el archivo: {{reason}}", @@ -1956,6 +1960,12 @@ export const es: TranslationResources = { backdrop: "Fondo del menú", }, subagents: { + parent: { + backToParent: "Volver al padre", + backTo: "Volver a {{label}}", + parentAgent: "Agente padre", + openParent: "Abrir padre", + }, title: "Subagentes", pillLabelOne: "1 subagente", pillLabelMany: "{{count}} subagentes", @@ -2052,11 +2062,17 @@ export const es: TranslationResources = { toolCallDetails: { error: "Error", empty: "No hay detalles adicionales disponibles", - subAgentActivity: "Actividad de subagente", input: "Aporte", output: "Producción", }, + turnSummary: { + details: "Ver detalles del turno", + sentBy: "Enviado por {{name}}", + showMore: "Mostrar más", + showLess: "Mostrar menos", + }, toolCallGroup: { + thought: "Razonó", editedFiles: { one: "editó {{count}} archivo", other: "editó {{count}} archivos", @@ -2124,6 +2140,11 @@ export const es: TranslationResources = { }, }, providerUsage: { + checkUsage: "Consultar uso", + checkUsageHint: "Se aplica a {{host}} en todos los dispositivos", + off: "El uso está desactivado en {{host}}.", + turnOn: "Activar", + options: "Configuración", refreshFailed: "No se pudo actualizar el uso", noHosts: "No hay hosts conectados", @@ -2171,6 +2192,7 @@ export const es: TranslationResources = { days: "{{value}} d", hours: "{{value}} h", minutes: "{{value}} min", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "Última actualización {{relativeTime}}", @@ -2178,6 +2200,8 @@ export const es: TranslationResources = { resetTimePassed: "Pasó la hora de restablecimiento", resettingNow: "se restablece ahora", resetsIn: "se restablece en {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}, {{time}}", runsOutNow: "se agota ahora", runsOutIn: "se agota en {{duration}}", justNow: "ahora mismo", @@ -2257,6 +2281,7 @@ export const es: TranslationResources = { connections: "Conexiones", agents: "Agents", metadata: "Metadatos", + voice: "Voz", workspaces: "Workspaces", providers: "Proveedores", usage: "Uso", @@ -2279,6 +2304,69 @@ export const es: TranslationResources = { docs: "Documentación", saveError: "No se pudo actualizar la generación de metadatos", }, + voice: { + offline: "Conéctate a este host para configurar la voz.", + updateHost: "Actualiza el host para configurar la voz.", + saveError: "No se pudo actualizar la configuración de voz", + provider: "Proveedor", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voz", + save: "Guardar", + retry: "Reintentar", + cancel: "Cancelar", + model: "Modelo", + language: "Idioma", + languagePlaceholder: "Detección automática", + setBy: "Definido por {{name}}", + unknownProvider: "Actualiza la app para configurar {{provider}}", + sites: { + dictationStt: "Voz a texto", + voiceModeStt: "Escucha", + voiceModeTts: "Habla", + }, + dictation: { + title: "Dictado", + enabled: "Dictado", + enabledHint: "Habla en el cuadro de mensaje en lugar de escribir", + }, + voiceMode: { + title: "Modo de voz", + info: "El modo de voz transcribe lo que dices y lee en voz alta las respuestas del agente.", + enabled: "Modo de voz", + enabledHint: "Habla con tus agentes sin usar las manos", + }, + openai: { + apiKey: "Clave de API", + apiKeyHint: "Se guarda en el host y no se vuelve a mostrar.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Guardada", + missingKey: "Falta la clave de API", + baseUrl: "URL base", + baseUrlHint: "Para servidores compatibles con OpenAI. Déjala vacía para usar OpenAI.", + }, + models: { + loading: "Cargando modelos...", + loadError: "No se pudieron cargar los modelos locales", + none: "No hay modelos disponibles", + downloaded: "Descargado", + inUse: "En uso", + recommended: "Recomendado", + download: "Descargar", + downloadAction: "Descargar {{label}}", + downloading: "Descargando...", + downloadingPercent: "Descargando {{percent}} %", + extracting: "Extrayendo...", + delete: "Eliminar", + deleteAction: "Eliminar {{label}}", + deleteTitle: "¿Eliminar {{label}}?", + deleteMessage: "Esto libera {{size}}. Puedes volver a descargarlo más tarde.", + languages: "Idiomas de {{label}}", + moreLanguages: "{{languages}} y {{count}} más", + }, + }, general: { title: "General", sending: "Envío", @@ -2305,12 +2393,27 @@ export const es: TranslationResources = { queue: "Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.", }, + commandEnterDescriptions: { + interrupt: + "Cuando el agente se está ejecutando, {{modifier}}+Enter interrumpe. Enter inserta una línea nueva.", + steer: + "Cuando el agente se está ejecutando, {{modifier}}+Enter dirige el turno activo. Enter inserta una línea nueva.", + queue: + "Cuando el agente se está ejecutando, {{modifier}}+Enter pone en cola. Enter inserta una línea nueva.", + }, options: { interrupt: "Interrumpir", steer: "Dirigir", queue: "Cola", }, }, + sendShortcut: { + label: "Atajo de envío", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Preguntar", @@ -2328,12 +2431,13 @@ export const es: TranslationResources = { description: "Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada", }, - toolCallDetail: { - label: "Visualización de llamadas a herramientas", - description: "Cómo aparecen las llamadas a herramientas en la cronología", + chatDisplay: { + label: "Visualización del chat", + description: "Cuánto del trabajo del agente muestra el chat", options: { - overview: "Resumen", - detailed: "Detalle completo", + full: "Completo", + collapseToolCalls: "Contraer llamadas a herramientas", + collapseTurns: "Contraer turnos", }, }, language: { @@ -2704,6 +2808,19 @@ export const es: TranslationResources = { }, agents: { unavailable: "Connect to this host to manage agents", + resumeOnStartup: { + title: "Reanudar agentes tras reiniciar", + info: "Vuelve a abrir los agentes que estaban trabajando cuando se detuvo el host y envía el mensaje de reanudación. Se aplica tras reinicios normales y fallos.", + hint: "Continuar automáticamente el trabajo interrumpido.", + prompt: "Mensaje de reanudación", + edit: "Editar", + editAccessibilityLabel: "Editar mensaje", + }, + idleRuntimeCleanup: { + title: "Liberar agentes inactivos", + hint: "Libera memoria tras una hora de inactividad.", + info: "Detiene el proceso del agente y conserva la conversación. Envía un mensaje para reanudarla.\n\nEl trabajo activo, las notificaciones pendientes y las comprobaciones de seguridad del proveedor impiden la limpieza.", + }, }, workspaces: { unavailable: "Connect to this host to manage workspaces", @@ -2796,6 +2913,11 @@ export const es: TranslationResources = { requestFailedTitle: "Error", requestFailedMessage: "No se pudo enviar la solicitud de reinicio.Paseose vuelve a conectar automáticamente; inténtelo nuevamente una vez que el host se muestre en línea.", + closeTerminals: "Reiniciar y cerrar terminales", + closeTerminalsHint: + "Reinicia y además cierra todas las terminales abiertas en este host.", + closeTerminalsConfirmMessage: + "Esto reiniciará el daemon y cerrará todas sus terminales, terminando los programas que se ejecutan en ellas. La app se reconectará automáticamente.", dialogFailedMessage: "No se puede abrir el cuadro de diálogo de confirmación de reinicio.", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 975d6a3736..551f4a4158 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -68,6 +68,7 @@ export const fr: TranslationResources = { daemonClientUnavailable: "Client du daemon indisponible", daemonClientDisconnected: "Le client du daemon est déconnecté", noFileFound: "Aucun fichier trouvé pour {{token}}", + folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail", unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.", }, connectionStatus: { @@ -135,6 +136,8 @@ export const fr: TranslationResources = { sendAndInterrupt: "Envoyer et interrompre", sendAndSteer: "Envoyer et réorienter", sendMessage: "Envoyer le message", + longPressToQueue: "Appui long pour mettre en file d’attente", + longPressToSendNow: "Appui long pour envoyer maintenant", queue: "Mettre en file d’attente", send: "Envoyer", }, @@ -620,6 +623,7 @@ export const fr: TranslationResources = { terminal: { hostDisconnected: "L’hôte n’est pas connecté", updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.", + restoreRequiresUpdate: "Mettez à jour l’hôte pour afficher les terminaux.", unableToSubscribe: "Impossible de s’abonner au terminal", fileUploaded: "Fichier envoyé", pasteFileFailed: "Impossible de coller le fichier : {{reason}}", @@ -1954,6 +1958,12 @@ export const fr: TranslationResources = { backdrop: "Arrière-plan du menu", }, subagents: { + parent: { + backToParent: "Retour au parent", + backTo: "Retour à {{label}}", + parentAgent: "Agent parent", + openParent: "Ouvrir le parent", + }, title: "Sous-agents", pillLabelOne: "1 sous-agent", pillLabelMany: "{{count}} sous-agents", @@ -2051,11 +2061,17 @@ export const fr: TranslationResources = { toolCallDetails: { error: "Erreur", empty: "Aucun détail supplémentaire disponible", - subAgentActivity: "Activité du sous-agent", input: "Entrée", output: "Sortie", }, + turnSummary: { + details: "Voir les détails du tour", + sentBy: "Envoyé par {{name}}", + showMore: "Afficher plus", + showLess: "Afficher moins", + }, toolCallGroup: { + thought: "Réfléchi", editedFiles: { one: "{{count}} fichier modifié", other: "{{count}} fichiers modifiés", @@ -2124,6 +2140,11 @@ export const fr: TranslationResources = { }, }, providerUsage: { + checkUsage: "Vérifier l’utilisation", + checkUsageHint: "S’applique à {{host}} sur tous les appareils", + off: "L’utilisation est désactivée sur {{host}}.", + turnOn: "Activer", + options: "Paramètres", refreshFailed: "Impossible d’actualiser l’utilisation", noHosts: "Aucun hôte connecté", @@ -2171,6 +2192,7 @@ export const fr: TranslationResources = { days: "{{value}} j", hours: "{{value}} h", minutes: "{{value}} min", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "Dernière mise à jour {{relativeTime}}", @@ -2178,6 +2200,8 @@ export const fr: TranslationResources = { resetTimePassed: "Heure de réinitialisation dépassée", resettingNow: "réinitialisation en cours", resetsIn: "se réinitialise dans {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}, {{time}}", runsOutNow: "s’épuise maintenant", runsOutIn: "s’épuise dans {{duration}}", justNow: "à l’instant", @@ -2257,6 +2281,7 @@ export const fr: TranslationResources = { connections: "Connexions", agents: "Agents", metadata: "Métadonnées", + voice: "Voix", workspaces: "Espaces de travail", providers: "Fournisseurs", usage: "Utilisation", @@ -2279,6 +2304,69 @@ export const fr: TranslationResources = { docs: "Documentation", saveError: "Impossible de mettre à jour la génération de métadonnées", }, + voice: { + offline: "Connectez-vous à cet hôte pour configurer la voix.", + updateHost: "Mettez à jour l'hôte pour configurer la voix.", + saveError: "Impossible de mettre à jour les réglages vocaux", + provider: "Fournisseur", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voix", + save: "Enregistrer", + retry: "Réessayer", + cancel: "Annuler", + model: "Modèle", + language: "Langue", + languagePlaceholder: "Détection automatique", + setBy: "Défini par {{name}}", + unknownProvider: "Mettez à jour l'app pour configurer {{provider}}", + sites: { + dictationStt: "Transcription", + voiceModeStt: "Écoute", + voiceModeTts: "Parole", + }, + dictation: { + title: "Dictée", + enabled: "Dictée", + enabledHint: "Parlez dans la zone de message au lieu de taper", + }, + voiceMode: { + title: "Mode vocal", + info: "Le mode vocal transcrit ce que vous dites et lit à voix haute les réponses de l'agent.", + enabled: "Mode vocal", + enabledHint: "Parlez à vos agents sans les mains", + }, + openai: { + apiKey: "Clé d'API", + apiKeyHint: "Enregistrée sur l'hôte, jamais réaffichée.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Enregistrée", + missingKey: "Clé d'API manquante", + baseUrl: "URL de base", + baseUrlHint: "Pour les serveurs compatibles OpenAI. Laissez vide pour utiliser OpenAI.", + }, + models: { + loading: "Chargement des modèles...", + loadError: "Impossible de charger les modèles locaux", + none: "Aucun modèle disponible", + downloaded: "Téléchargé", + inUse: "Utilisé", + recommended: "Recommandé", + download: "Télécharger", + downloadAction: "Télécharger {{label}}", + downloading: "Téléchargement...", + downloadingPercent: "Téléchargement {{percent}} %", + extracting: "Extraction...", + delete: "Supprimer", + deleteAction: "Supprimer {{label}}", + deleteTitle: "Supprimer {{label}} ?", + deleteMessage: "Cela libère {{size}}. Vous pourrez le retélécharger plus tard.", + languages: "Langues de {{label}}", + moreLanguages: "{{languages}} et {{count}} autres", + }, + }, general: { title: "Général", sending: "Envoi", @@ -2304,12 +2392,27 @@ export const fr: TranslationResources = { queue: "Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.", }, + commandEnterDescriptions: { + interrupt: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée interrompt. Entrée insère une nouvelle ligne.", + steer: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée oriente le tour actif. Entrée insère une nouvelle ligne.", + queue: + "Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée met en file d'attente. Entrée insère une nouvelle ligne.", + }, options: { interrupt: "Interrompre", steer: "Réorienter", queue: "File d’attente", }, }, + sendShortcut: { + label: "Raccourci d’envoi", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Demander", @@ -2326,12 +2429,13 @@ export const fr: TranslationResources = { label: "Toujours afficher le raisonnement", description: "Afficher par défaut le raisonnement de l’agent entièrement déplié", }, - toolCallDetail: { - label: "Affichage des appels d’outils", - description: "Comment les appels d’outils apparaissent dans la chronologie", + chatDisplay: { + label: "Affichage du chat", + description: "La part du travail de l’agent affichée dans le chat", options: { - overview: "Résumé", - detailed: "Détails complets", + full: "Complet", + collapseToolCalls: "Réduire les appels d’outils", + collapseTurns: "Réduire les tours", }, }, language: { @@ -2707,6 +2811,19 @@ export const fr: TranslationResources = { }, agents: { unavailable: "Connectez-vous à cet hôte pour gérer les agents", + resumeOnStartup: { + title: "Reprendre les agents après un redémarrage", + info: "Rouvre les agents qui travaillaient lorsque l’hôte s’est arrêté et envoie le message de reprise. S’applique après un redémarrage normal ou un plantage.", + hint: "Continuer automatiquement le travail interrompu.", + prompt: "Message de reprise", + edit: "Modifier", + editAccessibilityLabel: "Modifier le message", + }, + idleRuntimeCleanup: { + title: "Libérer les agents inactifs", + hint: "Libère de la mémoire après une heure d’inactivité.", + info: "Arrête le processus de l’agent tout en conservant sa conversation. Envoyez un message pour la reprendre.\n\nLe travail en cours, les notifications en attente et les vérifications de sécurité du fournisseur empêchent le nettoyage.", + }, }, workspaces: { unavailable: "Connectez-vous à cet hôte pour gérer les espaces de travail", @@ -2801,6 +2918,10 @@ export const fr: TranslationResources = { requestFailedTitle: "Erreur", requestFailedMessage: "Impossible d’envoyer la demande de redémarrage. Paseo se reconnecte automatiquement : réessayez quand l’hôte apparaît en ligne.", + closeTerminals: "Redémarrer et fermer les terminaux", + closeTerminalsHint: "Redémarre et ferme aussi tous les terminaux ouverts sur cet hôte.", + closeTerminalsConfirmMessage: + "Le daemon va redémarrer et fermer tous ses terminaux, ce qui arrêtera les programmes qui y tournent. L'app se reconnectera automatiquement.", dialogFailedMessage: "Impossible d’ouvrir la boîte de dialogue de confirmation du redémarrage.", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 60a207e1e1..4b1b8b6f64 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -68,6 +68,7 @@ export const ja: TranslationResources = { daemonClientUnavailable: "デーモンクライアントが利用できません", daemonClientDisconnected: "デーモンクライアントが切断されています", noFileFound: "{{token}}のファイルが見つかりません", + folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります", unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。", }, connectionStatus: { @@ -134,6 +135,8 @@ export const ja: TranslationResources = { sendAndInterrupt: "送信して中断", sendAndSteer: "送信して指示を追加", sendMessage: "メッセージを送信", + longPressToQueue: "長押しでキューに追加", + longPressToSendNow: "長押しで今すぐ送信", queue: "キュー", send: "送信", }, @@ -620,6 +623,7 @@ export const ja: TranslationResources = { terminal: { hostDisconnected: "ホストが接続されていません", updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", + restoreRequiresUpdate: "ターミナルを表示するにはホストを更新してください。", unableToSubscribe: "ターミナルに接続できません", fileUploaded: "ファイルをアップロードしました", pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}", @@ -1927,6 +1931,12 @@ export const ja: TranslationResources = { backdrop: "メニューの背景", }, subagents: { + parent: { + backToParent: "親に戻る", + backTo: "{{label}} に戻る", + parentAgent: "親エージェント", + openParent: "親を開く", + }, title: "サブエージェント", pillLabelOne: "サブエージェント 1 件", pillLabelMany: "サブエージェント {{count}} 件", @@ -2024,11 +2034,17 @@ export const ja: TranslationResources = { toolCallDetails: { error: "エラー", empty: "追加の詳細はありません", - subAgentActivity: "サブエージェントアクティビティ", input: "入力", output: "出力", }, + turnSummary: { + details: "ターンの詳細を表示", + sentBy: "{{name}} から送信", + showMore: "もっと見る", + showLess: "折りたたむ", + }, toolCallGroup: { + thought: "思考", editedFiles: { one: "{{count}}個のファイルを編集", other: "{{count}}個のファイルを編集", @@ -2097,6 +2113,11 @@ export const ja: TranslationResources = { }, }, providerUsage: { + checkUsage: "使用状況を確認", + checkUsageHint: "すべてのデバイスで {{host}} に適用", + off: "{{host}} の使用状況確認はオフです。", + turnOn: "オンにする", + options: "設定", refreshFailed: "使用状況を更新できません", noHosts: "接続中のホストがありません", @@ -2144,6 +2165,7 @@ export const ja: TranslationResources = { days: "{{value}}日", hours: "{{value}}時間", minutes: "{{value}}分", + pair: "{{first}}{{second}}", }, timing: { lastUpdated: "最終更新: {{relativeTime}}", @@ -2151,6 +2173,8 @@ export const ja: TranslationResources = { resetTimePassed: "リセット時刻を過ぎています", resettingNow: "まもなくリセット", resetsIn: "{{duration}}後にリセット", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}} {{time}}", runsOutNow: "まもなく上限に到達", runsOutIn: "{{duration}}後に上限に到達", justNow: "たった今", @@ -2230,6 +2254,7 @@ export const ja: TranslationResources = { connections: "接続", agents: "エージェント", metadata: "メタデータ", + voice: "音声", workspaces: "ワークスペース", providers: "プロバイダー", usage: "使用状況", @@ -2252,6 +2277,69 @@ export const ja: TranslationResources = { docs: "ドキュメント", saveError: "メタデータ生成を更新できません", }, + voice: { + offline: "音声を設定するには、このホストに接続してください。", + updateHost: "音声を設定するには、ホストを更新してください。", + saveError: "音声設定を更新できませんでした", + provider: "プロバイダー", + providers: { + local: "ローカル", + openai: "OpenAI", + }, + voice: "ボイス", + save: "保存", + retry: "再試行", + cancel: "キャンセル", + model: "モデル", + language: "言語", + languagePlaceholder: "自動検出", + setBy: "{{name}} で設定", + unknownProvider: "{{provider}} を設定するにはアプリを更新してください", + sites: { + dictationStt: "音声入力", + voiceModeStt: "聞き取り", + voiceModeTts: "読み上げ", + }, + dictation: { + title: "音声入力", + enabled: "音声入力", + enabledHint: "入力する代わりにメッセージ欄に話しかけます", + }, + voiceMode: { + title: "ボイスモード", + info: "ボイスモードは話した内容を文字起こしし、エージェントの返答を読み上げます。", + enabled: "ボイスモード", + enabledHint: "ハンズフリーでエージェントと会話します", + }, + openai: { + apiKey: "API キー", + apiKeyHint: "ホストに保存され、再表示されません。", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "保存済み", + missingKey: "API キーがありません", + baseUrl: "ベース URL", + baseUrlHint: "OpenAI 互換サーバー用です。OpenAI を使う場合は空欄のままにします。", + }, + models: { + loading: "モデルを読み込み中...", + loadError: "ローカルモデルを読み込めませんでした", + none: "利用できるモデルがありません", + downloaded: "ダウンロード済み", + inUse: "使用中", + recommended: "おすすめ", + download: "ダウンロード", + downloadAction: "{{label}} をダウンロード", + downloading: "ダウンロード中...", + downloadingPercent: "ダウンロード中 {{percent}}%", + extracting: "展開中...", + delete: "削除", + deleteAction: "{{label}} を削除", + deleteTitle: "{{label}} を削除しますか?", + deleteMessage: "{{size}} が解放されます。後で再ダウンロードできます。", + languages: "{{label}} の対応言語", + moreLanguages: "{{languages}} ほか {{count}} 言語", + }, + }, general: { title: "一般", sending: "送信", @@ -2274,12 +2362,25 @@ export const ja: TranslationResources = { "エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。", queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。", }, + commandEnterDescriptions: { + interrupt: "エージェント実行中、{{modifier}}+Enterで中断します。Enterで改行します。", + steer: + "エージェント実行中、{{modifier}}+Enterで現在のターンに指示を送ります。Enterで改行します。", + queue: "エージェント実行中、{{modifier}}+Enterでキューに追加します。Enterで改行します。", + }, options: { interrupt: "中断", steer: "指示", queue: "キュー", }, }, + sendShortcut: { + label: "送信ショートカット", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "確認する", @@ -2296,12 +2397,13 @@ export const ja: TranslationResources = { label: "常に思考プロセスを展開", description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します", }, - toolCallDetail: { - label: "ツール呼び出しの表示", - description: "タイムラインでのツール呼び出しの表示方法", + chatDisplay: { + label: "チャットの表示", + description: "チャットに表示するエージェントの作業量", options: { - overview: "要約", - detailed: "すべての詳細", + full: "すべて", + collapseToolCalls: "ツール呼び出しを折りたたむ", + collapseTurns: "ターンを折りたたむ", }, }, language: { @@ -2673,6 +2775,19 @@ export const ja: TranslationResources = { }, agents: { unavailable: "エージェントを管理するにはこのホストに接続してください", + resumeOnStartup: { + title: "再起動後にエージェントを再開", + info: "ホストの停止時に作業中だったエージェントを開き直し、再開メッセージを送信します。通常の再起動とクラッシュの両方に適用されます。", + hint: "中断された作業を自動的に続行します。", + prompt: "再開メッセージ", + edit: "編集", + editAccessibilityLabel: "メッセージを編集", + }, + idleRuntimeCleanup: { + title: "アイドル状態のエージェントを解放", + hint: "1 時間のアイドル状態の後にメモリを解放します。", + info: "会話を保持したままエージェントのプロセスを停止します。メッセージを送信すると再開します。\n\n実行中の作業、保留中の通知、プロバイダーの安全性チェックがある場合は解放しません。", + }, }, workspaces: { unavailable: "ワークスペースを管理するにはこのホストに接続してください", @@ -2766,6 +2881,10 @@ export const ja: TranslationResources = { requestFailedTitle: "エラー", requestFailedMessage: "再起動リクエストの送信に失敗しました。Paseoは自動的に再接続します。ホストがオンラインになったら再試行してください。", + closeTerminals: "再起動してターミナルを閉じる", + closeTerminalsHint: "再起動し、このホストのすべてのターミナルも閉じます。", + closeTerminalsConfirmMessage: + "デーモンを再起動し、すべてのターミナルを閉じます。ターミナルで実行中のプログラムは終了します。アプリは自動的に再接続します。", dialogFailedMessage: "再起動確認ダイアログを開けませんでした。", }, update: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 4773625327..b9e33b4eba 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -67,6 +67,7 @@ export const ko: TranslationResources = { daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다", daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다", noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다", + folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다", unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.", }, connectionStatus: { @@ -133,6 +134,8 @@ export const ko: TranslationResources = { sendAndInterrupt: "보내고 중단", sendAndSteer: "보내고 지시 추가", sendMessage: "메시지 보내기", + longPressToQueue: "길게 눌러 대기열에 추가", + longPressToSendNow: "길게 눌러 지금 보내기", queue: "대기열", send: "보내기", }, @@ -615,6 +618,7 @@ export const ko: TranslationResources = { terminal: { hostDisconnected: "호스트가 연결되어 있지 않습니다", updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.", + restoreRequiresUpdate: "터미널을 보려면 호스트를 업데이트하세요.", unableToSubscribe: "터미널을 구독할 수 없습니다", fileUploaded: "파일을 업로드했습니다", pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}", @@ -1918,6 +1922,12 @@ export const ko: TranslationResources = { backdrop: "메뉴 배경", }, subagents: { + parent: { + backToParent: "상위 에이전트로 돌아가기", + backTo: "{{label}}(으)로 돌아가기", + parentAgent: "상위 에이전트", + openParent: "상위 에이전트 열기", + }, title: "하위 에이전트", pillLabelOne: "하위 에이전트 1개", pillLabelMany: "하위 에이전트 {{count}}개", @@ -2014,11 +2024,17 @@ export const ko: TranslationResources = { toolCallDetails: { error: "오류", empty: "추가 세부 정보가 없습니다", - subAgentActivity: "서브에이전트 활동", input: "입력", output: "출력", }, + turnSummary: { + details: "턴 세부 정보 보기", + sentBy: "{{name}}이(가) 보냄", + showMore: "더 보기", + showLess: "접기", + }, toolCallGroup: { + thought: "생각함", editedFiles: { one: "{{count}} 파일을 편집했습니다.", other: "{{count}} 파일을 편집했습니다.", @@ -2086,6 +2102,11 @@ export const ko: TranslationResources = { }, }, providerUsage: { + checkUsage: "사용량 확인", + checkUsageHint: "모든 기기에서 {{host}}에 적용", + off: "{{host}}의 사용량 확인이 꺼져 있습니다.", + turnOn: "켜기", + options: "설정", refreshFailed: "사용량을 새로 고칠 수 없습니다", noHosts: "연결된 호스트가 없습니다", @@ -2133,6 +2154,7 @@ export const ko: TranslationResources = { days: "{{value}}일", hours: "{{value}}시간", minutes: "{{value}}분", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "마지막 업데이트: {{relativeTime}}", @@ -2140,6 +2162,8 @@ export const ko: TranslationResources = { resetTimePassed: "초기화 시간이 지났습니다", resettingNow: "지금 재설정 중", resetsIn: "{{duration}} 후 재설정", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}} {{time}}", runsOutNow: "지금 한도에 도달", runsOutIn: "{{duration}} 후 한도에 도달", justNow: "방금", @@ -2219,6 +2243,7 @@ export const ko: TranslationResources = { connections: "연결", agents: "에이전트", metadata: "메타데이터", + voice: "음성", workspaces: "워크스페이스", providers: "프로바이더", usage: "사용량", @@ -2241,6 +2266,69 @@ export const ko: TranslationResources = { docs: "문서", saveError: "메타데이터 생성을 업데이트할 수 없습니다", }, + voice: { + offline: "음성을 설정하려면 이 호스트에 연결하세요.", + updateHost: "음성을 설정하려면 호스트를 업데이트하세요.", + saveError: "음성 설정을 업데이트할 수 없습니다", + provider: "제공자", + providers: { + local: "로컬", + openai: "OpenAI", + }, + voice: "목소리", + save: "저장", + retry: "다시 시도", + cancel: "취소", + model: "모델", + language: "언어", + languagePlaceholder: "자동 감지", + setBy: "{{name}}에서 설정됨", + unknownProvider: "{{provider}}을(를) 설정하려면 앱을 업데이트하세요", + sites: { + dictationStt: "음성 텍스트 변환", + voiceModeStt: "듣기", + voiceModeTts: "말하기", + }, + dictation: { + title: "받아쓰기", + enabled: "받아쓰기", + enabledHint: "입력하는 대신 메시지 상자에 말하세요", + }, + voiceMode: { + title: "음성 모드", + info: "음성 모드는 말한 내용을 텍스트로 옮기고 에이전트의 답변을 소리 내어 읽어 줍니다.", + enabled: "음성 모드", + enabledHint: "손을 쓰지 않고 에이전트와 대화하세요", + }, + openai: { + apiKey: "API 키", + apiKeyHint: "호스트에 저장되며 다시 표시되지 않습니다.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "저장됨", + missingKey: "API 키 없음", + baseUrl: "기본 URL", + baseUrlHint: "OpenAI 호환 서버용입니다. OpenAI를 사용하려면 비워 두세요.", + }, + models: { + loading: "모델을 불러오는 중...", + loadError: "로컬 모델을 불러올 수 없습니다", + none: "사용 가능한 모델 없음", + downloaded: "다운로드됨", + inUse: "사용 중", + recommended: "추천", + download: "다운로드", + downloadAction: "{{label}} 다운로드", + downloading: "다운로드 중...", + downloadingPercent: "다운로드 중 {{percent}}%", + extracting: "압축 해제 중...", + delete: "삭제", + deleteAction: "{{label}} 삭제", + deleteTitle: "{{label}}을(를) 삭제할까요?", + deleteMessage: "{{size}}가 확보됩니다. 나중에 다시 다운로드할 수 있습니다.", + languages: "{{label}} 지원 언어", + moreLanguages: "{{languages}} 외 {{count}}개", + }, + }, general: { title: "일반", sending: "전송", @@ -2265,12 +2353,27 @@ export const ko: TranslationResources = { queue: "에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.", }, + commandEnterDescriptions: { + interrupt: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 중단합니다. Enter는 줄바꿈합니다.", + steer: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 현재 턴에 지시합니다. Enter는 줄바꿈합니다.", + queue: + "에이전트가 실행 중일 때 {{modifier}}+Enter는 대기열에 추가합니다. Enter는 줄바꿈합니다.", + }, options: { interrupt: "중단", steer: "지시", queue: "대기열", }, }, + sendShortcut: { + label: "전송 단축키", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "물어보기", @@ -2287,12 +2390,13 @@ export const ko: TranslationResources = { label: "추론 항상 펼치기", description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.", }, - toolCallDetail: { - label: "도구 호출 표시", - description: "타임라인에 도구 호출이 표시되는 방식", + chatDisplay: { + label: "채팅 표시", + description: "채팅에 표시할 에이전트 작업의 양", options: { - overview: "요약", - detailed: "전체 세부정보", + full: "전체", + collapseToolCalls: "도구 호출 접기", + collapseTurns: "턴 접기", }, }, language: { @@ -2659,6 +2763,19 @@ export const ko: TranslationResources = { }, agents: { unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요", + resumeOnStartup: { + title: "재시작 후 에이전트 재개", + info: "호스트가 중지될 때 작업 중이던 에이전트를 다시 열고 재개 메시지를 보냅니다. 정상 재시작과 비정상 종료 후 모두 적용됩니다.", + hint: "중단된 작업을 자동으로 계속합니다.", + prompt: "재개 메시지", + edit: "편집", + editAccessibilityLabel: "메시지 편집", + }, + idleRuntimeCleanup: { + title: "유휴 에이전트 해제", + hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.", + info: "대화를 유지하면서 에이전트 프로세스를 중지합니다. 메시지를 보내면 다시 이어집니다.\n\n진행 중인 작업, 대기 중인 알림, 프로바이더 안전 검사가 있으면 정리하지 않습니다.", + }, }, workspaces: { unavailable: "워크스페이스를 관리하려면 이 호스트에 연결하세요", @@ -2751,6 +2868,10 @@ export const ko: TranslationResources = { requestFailedTitle: "오류", requestFailedMessage: "재시작 요청을 보내지 못했습니다. Paseo가 자동으로 다시 연결하므로 호스트가 온라인으로 표시되면 다시 시도하세요.", + closeTerminals: "재시작하고 터미널 닫기", + closeTerminalsHint: "재시작하면서 이 호스트의 모든 터미널도 닫습니다.", + closeTerminalsConfirmMessage: + "데몬을 재시작하고 모든 터미널을 닫습니다. 터미널에서 실행 중인 프로그램은 종료됩니다. 앱은 자동으로 다시 연결됩니다.", dialogFailedMessage: "재시작 확인 대화 상자를 열 수 없습니다.", }, update: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 766c6d98ed..2fa6c30c8e 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -68,6 +68,7 @@ export const ptBR: TranslationResources = { daemonClientUnavailable: "Cliente do daemon indisponível", daemonClientDisconnected: "O cliente do daemon está desconectado", noFileFound: "Nenhum arquivo encontrado para {{token}}", + folderOutsideWorkspace: "{{path}} está fora deste workspace", unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.", }, connectionStatus: { @@ -134,6 +135,8 @@ export const ptBR: TranslationResources = { sendAndInterrupt: "Enviar e interromper", sendAndSteer: "Enviar e orientar", sendMessage: "Enviar mensagem", + longPressToQueue: "Mantenha pressionado para enfileirar", + longPressToSendNow: "Mantenha pressionado para enviar agora", queue: "Fila", send: "Enviar", }, @@ -619,6 +622,7 @@ export const ptBR: TranslationResources = { terminal: { hostDisconnected: "Host não está conectado", updateHost: "Atualize o host para usar o renderizador de terminal nativo.", + restoreRequiresUpdate: "Atualize o host para visualizar os terminais.", unableToSubscribe: "Não foi possível assinar o terminal", fileUploaded: "Arquivo enviado", pasteFileFailed: "Falha ao colar o arquivo: {{reason}}", @@ -1941,6 +1945,12 @@ export const ptBR: TranslationResources = { backdrop: "Fundo do menu", }, subagents: { + parent: { + backToParent: "Voltar ao pai", + backTo: "Voltar para {{label}}", + parentAgent: "Agente pai", + openParent: "Abrir pai", + }, title: "Subagentes", pillLabelOne: "1 subagente", pillLabelMany: "{{count}} subagentes", @@ -2037,11 +2047,17 @@ export const ptBR: TranslationResources = { toolCallDetails: { error: "Erro", empty: "Nenhum detalhe adicional disponível", - subAgentActivity: "Atividade do subagente", input: "Entrada", output: "Saída", }, + turnSummary: { + details: "Ver detalhes do turno", + sentBy: "Enviado por {{name}}", + showMore: "Mostrar mais", + showLess: "Mostrar menos", + }, toolCallGroup: { + thought: "Pensou", editedFiles: { one: "editou {{count}} arquivo", other: "editou {{count}} arquivos", @@ -2109,6 +2125,11 @@ export const ptBR: TranslationResources = { }, }, providerUsage: { + checkUsage: "Consultar uso", + checkUsageHint: "Aplica-se a {{host}} em todos os dispositivos", + off: "O uso está desativado em {{host}}.", + turnOn: "Ativar", + options: "Configurações", refreshFailed: "Não foi possível atualizar o uso", noHosts: "Nenhum host conectado", @@ -2156,6 +2177,7 @@ export const ptBR: TranslationResources = { days: "{{value}} d", hours: "{{value}} h", minutes: "{{value}} min", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "Última atualização {{relativeTime}}", @@ -2163,6 +2185,8 @@ export const ptBR: TranslationResources = { resetTimePassed: "O horário de redefinição passou", resettingNow: "redefinindo agora", resetsIn: "redefine em {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}, {{time}}", runsOutNow: "esgota agora", runsOutIn: "esgota em {{duration}}", justNow: "agora mesmo", @@ -2242,6 +2266,7 @@ export const ptBR: TranslationResources = { connections: "Conexões", agents: "Agentes", metadata: "Metadados", + voice: "Voz", workspaces: "Workspaces", providers: "Provedores", usage: "Uso", @@ -2264,6 +2289,69 @@ export const ptBR: TranslationResources = { docs: "Documentação", saveError: "Não foi possível atualizar a geração de metadados", }, + voice: { + offline: "Conecte-se a este host para configurar a voz.", + updateHost: "Atualize o host para configurar a voz.", + saveError: "Não foi possível atualizar as configurações de voz", + provider: "Provedor", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voz", + save: "Salvar", + retry: "Tentar novamente", + cancel: "Cancelar", + model: "Modelo", + language: "Idioma", + languagePlaceholder: "Detecção automática", + setBy: "Definido por {{name}}", + unknownProvider: "Atualize o app para configurar {{provider}}", + sites: { + dictationStt: "Fala para texto", + voiceModeStt: "Escuta", + voiceModeTts: "Fala", + }, + dictation: { + title: "Ditado", + enabled: "Ditado", + enabledHint: "Fale na caixa de mensagem em vez de digitar", + }, + voiceMode: { + title: "Modo de voz", + info: "O modo de voz transcreve o que você diz e lê em voz alta as respostas do agente.", + enabled: "Modo de voz", + enabledHint: "Converse com seus agentes sem usar as mãos", + }, + openai: { + apiKey: "Chave de API", + apiKeyHint: "Salva no host e nunca mostrada novamente.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Salva", + missingKey: "Chave de API ausente", + baseUrl: "URL base", + baseUrlHint: "Para servidores compatíveis com a OpenAI. Deixe vazio para usar a OpenAI.", + }, + models: { + loading: "Carregando modelos...", + loadError: "Não foi possível carregar os modelos locais", + none: "Nenhum modelo disponível", + downloaded: "Baixado", + inUse: "Em uso", + recommended: "Recomendado", + download: "Baixar", + downloadAction: "Baixar {{label}}", + downloading: "Baixando...", + downloadingPercent: "Baixando {{percent}}%", + extracting: "Extraindo...", + delete: "Excluir", + deleteAction: "Excluir {{label}}", + deleteTitle: "Excluir {{label}}?", + deleteMessage: "Isso libera {{size}}. Você pode baixá-lo de novo depois.", + languages: "Idiomas do {{label}}", + moreLanguages: "{{languages}} e mais {{count}}", + }, + }, general: { title: "Geral", sending: "Envio", @@ -2288,12 +2376,27 @@ export const ptBR: TranslationResources = { "Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.", queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.", }, + commandEnterDescriptions: { + interrupt: + "Quando o agente está em execução, {{modifier}}+Enter interrompe. Enter insere uma nova linha.", + steer: + "Quando o agente está em execução, {{modifier}}+Enter orienta o turno ativo. Enter insere uma nova linha.", + queue: + "Quando o agente está em execução, {{modifier}}+Enter enfileira. Enter insere uma nova linha.", + }, options: { interrupt: "Interromper", steer: "Orientar", queue: "Fila", }, }, + sendShortcut: { + label: "Atalho de envio", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Perguntar", @@ -2311,12 +2414,13 @@ export const ptBR: TranslationResources = { description: "Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão", }, - toolCallDetail: { - label: "Exibição de chamadas de ferramentas", - description: "Como as chamadas de ferramentas aparecem na linha do tempo", + chatDisplay: { + label: "Exibição do chat", + description: "Quanto do trabalho do agente o chat mostra", options: { - overview: "Resumo", - detailed: "Detalhes completos", + full: "Completo", + collapseToolCalls: "Recolher chamadas de ferramentas", + collapseTurns: "Recolher turnos", }, }, language: { @@ -2686,6 +2790,19 @@ export const ptBR: TranslationResources = { }, agents: { unavailable: "Conecte-se a este host para gerenciar agentes", + resumeOnStartup: { + title: "Retomar agentes após reiniciar", + info: "Reabre os agentes que estavam trabalhando quando o host parou e envia a mensagem de retomada. Aplica-se após reinicializações normais e falhas.", + hint: "Continuar automaticamente o trabalho interrompido.", + prompt: "Mensagem de retomada", + edit: "Editar", + editAccessibilityLabel: "Editar mensagem", + }, + idleRuntimeCleanup: { + title: "Liberar agentes ociosos", + hint: "Libera memória após uma hora de inatividade.", + info: "Encerra o processo do agente e mantém a conversa. Envie uma mensagem para retomar.\n\nTrabalho ativo, notificações pendentes e verificações de segurança do provedor impedem a limpeza.", + }, }, workspaces: { unavailable: "Conecte-se a este host para gerenciar workspaces", @@ -2779,6 +2896,10 @@ export const ptBR: TranslationResources = { requestFailedTitle: "Erro", requestFailedMessage: "Falha ao enviar a solicitação de reinício. O Paseo reconecta automaticamente. Tente novamente quando o host aparecer como online.", + closeTerminals: "Reiniciar e fechar terminais", + closeTerminalsHint: "Reinicia e também fecha todos os terminais abertos neste host.", + closeTerminalsConfirmMessage: + "Isso reiniciará o daemon e fechará todos os terminais dele, encerrando os programas que rodam neles. O app vai se reconectar automaticamente.", dialogFailedMessage: "Não foi possível abrir o diálogo de confirmação de reinício.", }, update: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 23f3d810ee..9a6eb5b0e2 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -68,6 +68,7 @@ export const ru: TranslationResources = { daemonClientUnavailable: "Daemon клиента недоступен", daemonClientDisconnected: "Daemon клиента отключен", noFileFound: "Файл для {{token}} не найден", + folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства", unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.", }, connectionStatus: { @@ -134,6 +135,8 @@ export const ru: TranslationResources = { sendAndInterrupt: "Отправить и прервать", sendAndSteer: "Отправить и направить", sendMessage: "Отправить сообщение", + longPressToQueue: "Удерживайте, чтобы поставить в очередь", + longPressToSendNow: "Удерживайте, чтобы отправить сейчас", queue: "Очередь", send: "Отправить", }, @@ -621,6 +624,7 @@ export const ru: TranslationResources = { terminal: { hostDisconnected: "Хост не подключён", updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.", + restoreRequiresUpdate: "Обновите хост, чтобы просматривать терминалы.", unableToSubscribe: "Не удалось подключиться к терминалу", fileUploaded: "Файл загружен", pasteFileFailed: "Не удалось вставить файл: {{reason}}", @@ -1940,6 +1944,12 @@ export const ru: TranslationResources = { backdrop: "Фон меню", }, subagents: { + parent: { + backToParent: "Назад к родителю", + backTo: "Назад к {{label}}", + parentAgent: "Родительский агент", + openParent: "Открыть родителя", + }, title: "Субагенты", pillLabelOne: "1 субагент", pillLabelMany: "Субагентов: {{count}}", @@ -2037,11 +2047,17 @@ export const ru: TranslationResources = { toolCallDetails: { error: "Ошибка", empty: "Дополнительные сведения отсутствуют", - subAgentActivity: "Активность субагента", input: "Входные данные", output: "Выходные данные", }, + turnSummary: { + details: "Показать подробности хода", + sentBy: "Отправлено агентом {{name}}", + showMore: "Показать больше", + showLess: "Свернуть", + }, toolCallGroup: { + thought: "Обдумал", editedFiles: { one: "изменён {{count}} файл", other: "изменены файлы ({{count}})", @@ -2109,6 +2125,11 @@ export const ru: TranslationResources = { }, }, providerUsage: { + checkUsage: "Проверять использование", + checkUsageHint: "Применяется к {{host}} на всех устройствах", + off: "Проверка использования отключена на {{host}}.", + turnOn: "Включить", + options: "Настройки", refreshFailed: "Не удалось обновить использование", noHosts: "Нет подключённых хостов", @@ -2156,6 +2177,7 @@ export const ru: TranslationResources = { days: "{{value}} дн.", hours: "{{value}} ч", minutes: "{{value}} мин", + pair: "{{first}} {{second}}", }, timing: { lastUpdated: "Последнее обновление {{relativeTime}}", @@ -2163,6 +2185,8 @@ export const ru: TranslationResources = { resetTimePassed: "Время сброса прошло", resettingNow: "сброс сейчас", resetsIn: "сброс через {{duration}}", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}}, {{time}}", runsOutNow: "закончится сейчас", runsOutIn: "закончится через {{duration}}", justNow: "только что", @@ -2242,6 +2266,7 @@ export const ru: TranslationResources = { connections: "Подключения", agents: "Агенты", metadata: "Метаданные", + voice: "Голос", workspaces: "Рабочие пространства", providers: "Провайдеры", usage: "Использование", @@ -2265,6 +2290,70 @@ export const ru: TranslationResources = { docs: "Документация", saveError: "Не удалось обновить настройки генерации метаданных", }, + voice: { + offline: "Подключитесь к этому хосту, чтобы настроить голос.", + updateHost: "Обновите хост, чтобы настроить голос.", + saveError: "Не удалось обновить голосовые настройки", + provider: "Провайдер", + providers: { + local: "Локально", + openai: "OpenAI", + }, + voice: "Голос", + save: "Сохранить", + retry: "Повторить", + cancel: "Отмена", + model: "Модель", + language: "Язык", + languagePlaceholder: "Автоопределение", + setBy: "Задано через {{name}}", + unknownProvider: "Обновите приложение, чтобы настроить {{provider}}", + sites: { + dictationStt: "Распознавание речи", + voiceModeStt: "Прослушивание", + voiceModeTts: "Озвучивание", + }, + dictation: { + title: "Диктовка", + enabled: "Диктовка", + enabledHint: "Говорите в поле сообщения вместо набора текста", + }, + voiceMode: { + title: "Голосовой режим", + info: "Голосовой режим расшифровывает вашу речь и зачитывает ответы агента вслух.", + enabled: "Голосовой режим", + enabledHint: "Общайтесь с агентами без рук", + }, + openai: { + apiKey: "API-ключ", + apiKeyHint: "Хранится на хосте и больше не показывается.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Сохранён", + missingKey: "Нет API-ключа", + baseUrl: "Базовый URL", + baseUrlHint: + "Для серверов, совместимых с OpenAI. Оставьте пустым, чтобы использовать OpenAI.", + }, + models: { + loading: "Загрузка моделей...", + loadError: "Не удалось загрузить локальные модели", + none: "Нет доступных моделей", + downloaded: "Скачана", + inUse: "Используется", + recommended: "Рекомендуется", + download: "Скачать", + downloadAction: "Скачать {{label}}", + downloading: "Скачивание...", + downloadingPercent: "Скачивание {{percent}}%", + extracting: "Распаковка...", + delete: "Удалить", + deleteAction: "Удалить {{label}}", + deleteTitle: "Удалить {{label}}?", + deleteMessage: "Освободится {{size}}. Позже её можно скачать снова.", + languages: "Языки {{label}}", + moreLanguages: "{{languages}} и ещё {{count}}", + }, + }, general: { title: "Основные", sending: "Отправка", @@ -2290,12 +2379,27 @@ export const ru: TranslationResources = { queue: "Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.", }, + commandEnterDescriptions: { + interrupt: + "Когда агент работает, {{modifier}}+Enter прерывает его работу, а Enter вставляет новую строку.", + steer: + "Когда агент работает, {{modifier}}+Enter дополняет текущий ответ новым указанием, а Enter вставляет новую строку.", + queue: + "Когда агент работает, {{modifier}}+Enter ставит сообщение в очередь, а Enter вставляет новую строку.", + }, options: { interrupt: "Прерывать", steer: "Направить", queue: "Поставить в очередь", }, }, + sendShortcut: { + label: "Сочетание клавиш для отправки", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "Спрашивать", @@ -2313,12 +2417,13 @@ export const ru: TranslationResources = { description: "По умолчанию полностью разворачивать блоки размышлений и хода рассуждений агента", }, - toolCallDetail: { - label: "Отображение вызовов инструментов", - description: "Как вызовы инструментов отображаются на временной шкале", + chatDisplay: { + label: "Отображение чата", + description: "Какую часть работы агента показывает чат", options: { - overview: "Сводка", - detailed: "Все подробности", + full: "Полностью", + collapseToolCalls: "Сворачивать вызовы инструментов", + collapseTurns: "Сворачивать ходы", }, }, language: { @@ -2695,6 +2800,19 @@ export const ru: TranslationResources = { }, agents: { unavailable: "Подключитесь к этому хосту, чтобы управлять агентами", + resumeOnStartup: { + title: "Возобновлять агентов после перезапуска", + info: "Открывает агентов, которые работали при остановке хоста, и отправляет сообщение для продолжения. Применяется после обычного перезапуска и сбоя.", + hint: "Автоматически продолжать прерванную работу.", + prompt: "Сообщение для продолжения", + edit: "Редактировать", + editAccessibilityLabel: "Изменить сообщение", + }, + idleRuntimeCleanup: { + title: "Освобождать неактивных агентов", + hint: "Освобождает память после часа бездействия.", + info: "Останавливает процесс агента, сохраняя разговор. Отправьте сообщение, чтобы продолжить.\n\nАктивная работа, ожидающие уведомления и проверки безопасности провайдера предотвращают очистку.", + }, }, workspaces: { unavailable: "Подключитесь к этому хосту, чтобы управлять рабочими пространствами", @@ -2788,6 +2906,10 @@ export const ru: TranslationResources = { requestFailedTitle: "Ошибка", requestFailedMessage: "Не удалось отправить запрос на перезапуск. Paseo переподключается автоматически. Повторите попытку, когда хост появится в сети.", + closeTerminals: "Перезапустить и закрыть терминалы", + closeTerminalsHint: "Перезапускает демон и закрывает все терминалы на этом хосте.", + closeTerminalsConfirmMessage: + "Демон будет перезапущен, а все его терминалы закрыты вместе с запущенными в них программами. Приложение переподключится автоматически.", dialogFailedMessage: "Не удалось открыть диалог подтверждения перезапуска.", }, update: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 43d8ceeef2..553d8212b4 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -67,6 +67,7 @@ export const zhCN: TranslationResources = { daemonClientUnavailable: "Daemon client 不可用", daemonClientDisconnected: "Daemon client 已断开连接", noFileFound: "未找到 {{token}} 对应的文件", + folderOutsideWorkspace: "{{path}} 不在此工作区内", unexpectedDictationError: "处理听写时发生意外错误。", }, connectionStatus: { @@ -133,6 +134,8 @@ export const zhCN: TranslationResources = { sendAndInterrupt: "发送并中断", sendAndSteer: "发送并引导", sendMessage: "发送消息", + longPressToQueue: "长按加入队列", + longPressToSendNow: "长按立即发送", queue: "排队", send: "发送", }, @@ -614,6 +617,7 @@ export const zhCN: TranslationResources = { terminal: { hostDisconnected: "Host 未连接", updateHost: "请更新主机以使用原生终端渲染器。", + restoreRequiresUpdate: "更新主机以查看终端。", unableToSubscribe: "无法订阅 Terminal", fileUploaded: "文件已上传", pasteFileFailed: "无法粘贴文件:{{reason}}", @@ -1886,6 +1890,12 @@ export const zhCN: TranslationResources = { backdrop: "菜单背景", }, subagents: { + parent: { + backToParent: "返回父代理", + backTo: "返回 {{label}}", + parentAgent: "父代理", + openParent: "打开父代理", + }, title: "Subagent", pillLabelOne: "1 个 subagent", pillLabelMany: "{{count}} 个 subagent", @@ -1982,11 +1992,17 @@ export const zhCN: TranslationResources = { toolCallDetails: { error: "错误", empty: "没有可用的更多详情", - subAgentActivity: "Sub-agent 活动", input: "输入", output: "输出", }, + turnSummary: { + details: "查看轮次详情", + sentBy: "由 {{name}} 发送", + showMore: "展开", + showLess: "收起", + }, toolCallGroup: { + thought: "已思考", editedFiles: { one: "编辑了 {{count}} 个文件", other: "编辑了 {{count}} 个文件", @@ -2054,6 +2070,11 @@ export const zhCN: TranslationResources = { }, }, providerUsage: { + checkUsage: "检查用量", + checkUsageHint: "在所有设备上应用于 {{host}}", + off: "{{host}} 的用量检查已关闭。", + turnOn: "开启", + options: "设置", refreshFailed: "无法刷新用量", noHosts: "没有已连接的主机", @@ -2101,6 +2122,7 @@ export const zhCN: TranslationResources = { days: "{{value}}天", hours: "{{value}}小时", minutes: "{{value}}分钟", + pair: "{{first}}{{second}}", }, timing: { lastUpdated: "上次更新于 {{relativeTime}}", @@ -2108,6 +2130,8 @@ export const zhCN: TranslationResources = { resetTimePassed: "重置时间已过", resettingNow: "正在重置", resetsIn: "将在 {{duration}}后重置", + resetsAtWeekday: "{{weekday}} {{time}}", + resetsAtDate: "{{date}} {{time}}", runsOutNow: "即将用尽", runsOutIn: "将在 {{duration}}后用尽", justNow: "刚刚", @@ -2187,6 +2211,7 @@ export const zhCN: TranslationResources = { connections: "连接", agents: "Agents", metadata: "元数据", + voice: "语音", workspaces: "Workspaces", providers: "Providers", usage: "使用情况", @@ -2208,6 +2233,69 @@ export const zhCN: TranslationResources = { docs: "文档", saveError: "无法更新元数据生成设置", }, + voice: { + offline: "连接到此主机以配置语音。", + updateHost: "请更新主机以配置语音。", + saveError: "无法更新语音设置", + provider: "提供方", + providers: { + local: "本地", + openai: "OpenAI", + }, + voice: "音色", + save: "保存", + retry: "重试", + cancel: "取消", + model: "模型", + language: "语言", + languagePlaceholder: "自动检测", + setBy: "由 {{name}} 设置", + unknownProvider: "更新应用以配置 {{provider}}", + sites: { + dictationStt: "语音转文字", + voiceModeStt: "聆听", + voiceModeTts: "朗读", + }, + dictation: { + title: "听写", + enabled: "听写", + enabledHint: "对着消息框说话,无需打字", + }, + voiceMode: { + title: "语音模式", + info: "语音模式会转写你说的话,并朗读智能体的回复。", + enabled: "语音模式", + enabledHint: "免手动与智能体对话", + }, + openai: { + apiKey: "API 密钥", + apiKeyHint: "保存在主机上,不会再次显示。", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "已保存", + missingKey: "缺少 API 密钥", + baseUrl: "基础 URL", + baseUrlHint: "用于兼容 OpenAI 的服务器。留空则使用 OpenAI。", + }, + models: { + loading: "正在加载模型...", + loadError: "无法加载本地模型", + none: "没有可用的模型", + downloaded: "已下载", + inUse: "使用中", + recommended: "推荐", + download: "下载", + downloadAction: "下载 {{label}}", + downloading: "正在下载...", + downloadingPercent: "正在下载 {{percent}}%", + extracting: "正在解压...", + delete: "删除", + deleteAction: "删除 {{label}}", + deleteTitle: "删除 {{label}}?", + deleteMessage: "这将释放 {{size}}。之后可以重新下载。", + languages: "{{label}} 支持的语言", + moreLanguages: "{{languages}} 等另外 {{count}} 种", + }, + }, general: { title: "通用", sending: "发送", @@ -2229,12 +2317,24 @@ export const zhCN: TranslationResources = { steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。", queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。", }, + commandEnterDescriptions: { + interrupt: "Agent 运行时,{{modifier}}+Enter 会中断。Enter 会插入换行。", + steer: "Agent 运行时,{{modifier}}+Enter 会引导当前回合。Enter 会插入换行。", + queue: "Agent 运行时,{{modifier}}+Enter 会排队。Enter 会插入换行。", + }, options: { interrupt: "中断", steer: "引导", queue: "排队", }, }, + sendShortcut: { + label: "发送快捷键", + options: { + enter: "Enter", + "mod-enter": "{{modifier}}+Enter", + }, + }, serviceUrls: { options: { ask: "询问", @@ -2251,12 +2351,13 @@ export const zhCN: TranslationResources = { label: "始终展开推理过程", description: "默认情况下完全展开 AI 的思考和推理过程", }, - toolCallDetail: { - label: "工具调用显示", - description: "工具调用在时间线中的显示方式", + chatDisplay: { + label: "聊天显示", + description: "聊天中显示多少智能体的工作", options: { - overview: "摘要", - detailed: "完整详情", + full: "完整", + collapseToolCalls: "折叠工具调用", + collapseTurns: "折叠轮次", }, }, language: { @@ -2620,6 +2721,19 @@ export const zhCN: TranslationResources = { }, agents: { unavailable: "连接到这个 Host 以管理 Agent", + resumeOnStartup: { + title: "重启后恢复智能体", + info: "重新打开主机停止时正在工作的智能体,并发送恢复提示。适用于正常重启和崩溃后的恢复。", + hint: "自动继续被中断的工作。", + prompt: "恢复提示", + edit: "编辑", + editAccessibilityLabel: "编辑提示", + }, + idleRuntimeCleanup: { + title: "释放空闲 Agent", + hint: "空闲一小时后释放内存。", + info: "停止 Agent 进程,但保留对话。发送消息即可继续。\n\n进行中的工作、待处理的通知和 Provider 安全检查会阻止清理。", + }, }, workspaces: { unavailable: "连接到这个 Host 以管理 Workspace", @@ -2706,6 +2820,10 @@ export const zhCN: TranslationResources = { offlineMessage: "这个 Host 已离线。Paseo 会自动重连,请等它恢复在线后再重启。", requestFailedTitle: "错误", requestFailedMessage: "发送重启请求失败。Paseo 会自动重连,请在 Host 显示在线后重试。", + closeTerminals: "重启并关闭终端", + closeTerminalsHint: "重启的同时关闭此主机上的所有终端。", + closeTerminalsConfirmMessage: + "这将重启守护进程并关闭其所有终端,终端中运行的程序会被结束。应用会自动重新连接。", dialogFailedMessage: "无法打开重启确认对话框。", }, update: { diff --git a/packages/app/src/navigation/settings-presentation/index.ts b/packages/app/src/navigation/settings-presentation/index.ts new file mode 100644 index 0000000000..cdf86b0d19 --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/index.ts @@ -0,0 +1,57 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; +import { ScrollView, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; +import { usePathname, useRootNavigationState } from "expo-router"; +import { useStore } from "zustand"; +import { + useIsLastWorkspaceSelectionHydrated, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { createSettingsPresentation } from "./model"; + +const selection = createSettingsPresentation(); + +// Mount once above the sibling Settings routes so replacements and Back share +// one visit, and leaving Settings gives the next workspace entry a fresh default. +export function useSettingsRouteEntry(): void { + const pathname = usePathname(); + const navigationState = useRootNavigationState(); + const routeKey = navigationState?.routes[navigationState.index]?.key; + const workspace = useLastWorkspaceSelection(); + const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated(); + const workspaceServerId = workspace?.serverId ?? null; + useLayoutEffect(() => { + if (isWorkspaceHydrated && routeKey) { + selection.enterRoute({ key: routeKey, pathname }, workspaceServerId); + } + }, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]); +} + +export function useSettingsHostSelection(): string | null { + return useStore(selection.store, (state) => state.selectedServerId); +} + +export const selectSettingsHost = selection.selectHost; + +// Sibling routes remount the wide sidebar. Only the current mount owns its +// offset: RN Web can deliver its final scroll callback after unmount. +export function useSettingsSidebarScroll(enabled: boolean) { + const ref = useRef(null); + const owner = useRef | null>(null); + const restore = useCallback(() => { + if (owner.current) ref.current?.scrollTo({ y: owner.current.offset, animated: false }); + }, []); + useLayoutEffect(() => { + if (!enabled) return; + const sidebar = selection.attachSidebar(); + owner.current = sidebar; + restore(); + return () => { + sidebar.detach(); + owner.current = null; + }; + }, [enabled, restore]); + const onScroll = useCallback((event: NativeSyntheticEvent) => { + owner.current?.remember(event.nativeEvent.contentOffset.y); + }, []); + return { ref, onScroll, onContentSizeChange: restore, scrollEventThrottle: 16 }; +} diff --git a/packages/app/src/navigation/settings-presentation/model.test.ts b/packages/app/src/navigation/settings-presentation/model.test.ts new file mode 100644 index 0000000000..a8970b17b4 --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/model.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { createSettingsPresentation } from "./model"; + +describe("Settings host selection", () => { + it("keeps picker choices across sections, replacement and Back", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("workspace-host"); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/chosen-host/connections", + pathname: "/settings/hosts/chosen-host/connections", + }, + "workspace-host", + ); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + settings.enterRoute( + { key: "/settings/appearance", pathname: "/settings/appearance" }, + "workspace-host", + ); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + }); + + it("applies explicit host links once, including project and plugin screens", () => { + const settings = createSettingsPresentation(); + settings.enterRoute( + { + key: "/settings/hosts/linked-host/projects/project", + pathname: "/settings/hosts/linked-host/projects/project", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("linked-host"); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/linked-host/projects/project", + pathname: "/settings/hosts/linked-host/projects/project", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/plugin-host/plugins/plugin/screen", + pathname: "/settings/hosts/plugin-host/plugins/plugin/screen", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("plugin-host"); + }); + + it("seeds each new visit from the workspace, including a retained settings route", () => { + const settings = createSettingsPresentation(); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "first-workspace-host", + ); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/h/second-workspace-host/workspace/workspace", + pathname: "/h/second-workspace-host/workspace/workspace", + }, + "second-workspace-host", + ); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "second-workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("second-workspace-host"); + }); + + it("does not replay a retained host route after a newer picker choice", () => { + const settings = createSettingsPresentation(); + const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" }; + settings.enterRoute(oldRoute, "workspace-host"); + settings.selectHost("second"); + settings.enterRoute( + { key: "new-host-screen", pathname: "/settings/hosts/second/host" }, + "workspace-host", + ); + settings.enterRoute(oldRoute, "workspace-host"); + settings.enterRoute({ key: "app-screen", pathname: "/settings/appearance" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("second"); + settings.enterRoute({ ...oldRoute, key: "explicit-new-entry" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("first"); + }); + + it("does not reseed a visit when remembered workspace state changes", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, null); + settings.selectHost("chosen-host"); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + }); +}); + +describe("Settings sidebar presentation", () => { + it("restores across replacements and rejects callbacks from older sidebars", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + const oldSidebar = settings.attachSidebar(); + oldSidebar.remember(246); + settings.enterRoute({ key: "host", pathname: "/settings/hosts/host/providers" }, "host"); + const currentSidebar = settings.attachSidebar(); + expect(currentSidebar.offset).toBe(246); + oldSidebar.remember(0); + oldSidebar.detach(); + expect(currentSidebar.offset).toBe(246); + currentSidebar.remember(190); + currentSidebar.detach(); + currentSidebar.remember(0); + expect(settings.attachSidebar().offset).toBe(190); + }); + + it("resets on leaving Settings and rejects callbacks from a previous visit", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + const previousVisit = settings.attachSidebar(); + previousVisit.remember(246); + settings.enterRoute({ key: "workspace", pathname: "/h/host/workspace/workspace" }, "host"); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + previousVisit.remember(246); + expect(settings.attachSidebar().offset).toBe(0); + }); +}); diff --git a/packages/app/src/navigation/settings-presentation/model.ts b/packages/app/src/navigation/settings-presentation/model.ts new file mode 100644 index 0000000000..2a88e9d82b --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/model.ts @@ -0,0 +1,66 @@ +import { createStore } from "zustand/vanilla"; + +interface SettingsRouteEntry { + key: string; + pathname: string; +} + +interface SettingsSelectionState { + selectedServerId: string | null; +} + +export function createSettingsPresentation() { + const store = createStore(() => ({ selectedServerId: null })); + const enteredRoutes = new Map(); + let inSettings = false; + let sidebarOffset = 0; + let sidebarOwner: object | null = null; + + function attachSidebar() { + const owner = {}; + sidebarOwner = owner; + return { + get offset() { + return sidebarOffset; + }, + remember(offset: number) { + if (sidebarOwner === owner) sidebarOffset = offset; + }, + detach() { + if (sidebarOwner === owner) sidebarOwner = null; + }, + }; + } + + function enterRoute( + { key, pathname }: SettingsRouteEntry, + workspaceServerId: string | null, + ): void { + const isSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + if (!isSettings) { + sidebarOffset = 0; + sidebarOwner = null; + inSettings = false; + enteredRoutes.clear(); + return; + } + // A retained route returning through Back must not replay its old host intent. + if (enteredRoutes.get(key) === pathname) return; + const isNewVisit = !inSettings; + inSettings = true; + enteredRoutes.set(key, pathname); + + const hostMatch = /^\/settings\/hosts\/([^/]+)(?:\/|$)/.exec(pathname); + if (hostMatch) { + store.setState({ selectedServerId: decodeURIComponent(hostMatch[1]) }); + } else if (isNewVisit) { + store.setState({ selectedServerId: workspaceServerId }); + } + } + + function selectHost(serverId: string): void { + store.setState({ selectedServerId: serverId }); + } + + return { store, enterRoute, selectHost, attachSidebar }; +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index af14b94229..147ab7b260 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -20,6 +20,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { shallow, useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { ComposerDock } from "@/composer/dock"; @@ -27,6 +28,7 @@ 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 { AgentCommandsProvider } from "@/agent-commands"; import { useComposerEditingSession, useComposerFullscreen, @@ -43,7 +45,7 @@ import { useAgentDescriptor } from "@/agent-presentation"; import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; -import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft"; @@ -80,6 +82,7 @@ import { } from "@/screens/agent/agent-ready-screen-bottom-anchor"; import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks"; +import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { @@ -1109,7 +1112,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ }); const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId); const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent; + const parentAgentId = useParentAgentId(serverId, agentId); + // With a composer the parent is a pill above it; without one, the box takes the composer's place. + const parentPillAgentId = showsComposer ? parentAgentId : null; const hasVisibleAgentTracks = hasAgentTracks({ + parentAgentId: parentPillAgentId, subagentRows, tasks, archiveFinishedStatus: archiveFinishedSubagents.status, @@ -1161,6 +1168,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ composerState, ], ); + const parentCallout = parentAgentId ? ( + + ) : null; const composerSection = showsComposer ? ( - ) : null; + ) : ( + parentCallout + ); const streamContent = ( {showsComposer ? null : } @@ -1203,6 +1215,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ serverId={serverId} workspaceId={workspaceId} agentId={agentId} + parentAgentId={parentPillAgentId} subagentRows={subagentRows} tasks={tasks} archiveFinishedStatus={archiveFinishedSubagents.status} @@ -1243,7 +1256,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ); - return ( + const content = ( ); + return wrapAgentCommandProvider({ + enabled: showsComposer && hasActiveComposer, + serverId, + agentId, + editingSession: composerEditingSession, + children: content, + }); }); +function wrapAgentCommandProvider({ + enabled, + serverId, + agentId, + editingSession, + children, +}: { + enabled: boolean; + serverId: string; + agentId: string; + editingSession: ComposerEditingSession; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + function ChatSurface({ children, disabled, @@ -1354,9 +1399,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ const bottomOverlayControlClearance = hasVisibleComposerTracks ? resolveComposerTrackControlClearance(isCompactFormFactor) : 0; - const streamItemsRaw = useSessionStore((state) => - agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, - ); + const streamItemsRaw = useAgentStreamTail(serverId, agentId); const pendingMessageSubmissions = useSessionStore( useShallow((state) => agentId @@ -1502,7 +1545,7 @@ function ActiveAgentComposer({ }) { const isCompactFormFactor = useIsCompactFormFactor(); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( - COMPACT_FORM_FACTOR_WIDTH, + COMPACT_CONTENT_WIDTH, { initialIsBelow: isCompactFormFactor }, ); const paneContext = usePaneContext(); diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 3fc42030a7..6984633bcc 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -13,9 +13,10 @@ import { type ArchiveFinishedStatus, useArchiveSubagent, useDetachSubagent, - useSubagentOpensInSheet, + useSubagentSheetTab, type SubagentRow, } from "@/subagents"; +import { ParentAgentPill } from "@/subagents/parent"; import { SubagentsTrack } from "@/subagents/track"; import type { TodoEntry } from "@/types/stream"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -34,6 +35,7 @@ export const AgentTracks = memo(function AgentTracks({ serverId, workspaceId, agentId, + parentAgentId, subagentRows, tasks, archiveFinishedStatus, @@ -43,6 +45,7 @@ export const AgentTracks = memo(function AgentTracks({ serverId: string; workspaceId: string; agentId: string; + parentAgentId: string | null; subagentRows: SubagentRow[]; tasks: TodoEntry[] | undefined; archiveFinishedStatus: ArchiveFinishedStatus; @@ -88,12 +91,16 @@ export const AgentTracks = memo(function AgentTracks({ [openSubagentTarget, serverId, workspaceId], ); const handleOpenProviderSubagent = useCallback( - (parentAgentId: string, subagentId: string) => { - openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId }); + (ownerAgentId: string, subagentId: string) => { + openSubagentTarget({ + kind: "provider_subagent", + parentAgentId: ownerAgentId, + subagentId, + }); }, [openSubagentTarget], ); - const opensInSheet = useSubagentOpensInSheet(workspaceKey); + const sheetTabFor = useSubagentSheetTab(workspaceKey, tabId); const handleOpenChanges = useCallback(() => { if (!workspaceKey) { return; @@ -108,6 +115,7 @@ export const AgentTracks = memo(function AgentTracks({ if ( !hasWorkspaceDiffStat && !hasAgentTracks({ + parentAgentId, subagentRows, tasks, archiveFinishedStatus, @@ -119,13 +127,14 @@ export const AgentTracks = memo(function AgentTracks({ return ( + {parentAgentId ? : null} 0 || Boolean(tasks?.length) || archiveFinishedStatus.kind !== "idle" || diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index ecd4a082bf..99a38c2f8b 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -16,8 +16,10 @@ import { usePaneContext } from "@/panels/pane-context"; import { PaneSubtitle } from "@/panels/pane-subtitle"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; +import type { SheetTab } from "@/stores/workspace-layout-store"; +import { ParentAgentCallout } from "@/subagents/parent"; import { type SubagentRow, useSubagentsForParent } from "@/subagents/select"; -import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet"; +import { useSubagentSheetTab } from "@/subagents/sheet-tab"; import { SubagentsTrack } from "@/subagents/track"; import { providerSubagentKey, @@ -50,12 +52,12 @@ function ProviderSubagentChildTrack({ serverId, rows, onOpenProviderSubagent, - opensInSheet, + sheetTabFor, }: { serverId: string; rows: ReturnType; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; - opensInSheet: (row: SubagentRow) => boolean; + sheetTabFor: (row: SubagentRow) => SheetTab | null; }) { if (rows.length === 0) return null; return ( @@ -65,7 +67,7 @@ function ProviderSubagentChildTrack({ rows={rows} onOpenSubagent={NOOP_SUBAGENT} onOpenProviderSubagent={onOpenProviderSubagent} - opensInSheet={opensInSheet} + sheetTabFor={sheetTabFor} onArchiveSubagent={NOOP_SUBAGENT} /> @@ -115,7 +117,7 @@ function useProviderSubagentDescriptor( function ProviderSubagentPanel() { const { t } = useTranslation(); - const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext(); + const { serverId, workspaceId, tabId, target, openFileInWorkspace, openTab } = usePaneContext(); invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target"); const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId); const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`; @@ -143,8 +145,9 @@ function ProviderSubagentPanel() { providerParentSubagentId: target.subagentId, }); const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact); - const opensInSheet = useSubagentOpensInSheet( + const sheetTabFor = useSubagentSheetTab( buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), + tabId, ); const openProviderChild = useCallback( (parentAgentId: string, subagentId: string) => { @@ -253,33 +256,37 @@ function ProviderSubagentPanel() { return ( {subtitle ? : null} - - + + + + + ); } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0 }, + stream: { flex: 1, minHeight: 0 }, unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, })); diff --git a/packages/app/src/panels/retained-chat-content.browser.test.tsx b/packages/app/src/panels/retained-chat-content.browser.test.tsx index 07b1ceca75..3ffeb1bfef 100644 --- a/packages/app/src/panels/retained-chat-content.browser.test.tsx +++ b/packages/app/src/panels/retained-chat-content.browser.test.tsx @@ -1,11 +1,15 @@ import React, { act, + Profiler, useCallback, useEffect, useLayoutEffect, useState, useSyncExternalStore, } from "react"; +import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail"; +import { useSessionStore } from "@/stores/session-store"; +import type { StreamItem } from "@/types/stream"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel"; @@ -30,11 +34,24 @@ import { import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal"; import { render as renderEnvironment } from "@testing-library/react"; -import { useWorkspaceSheetEnvironment } from "@/screens/workspace/workspace-sheet-environment"; +import { + SheetPaneHostProvider, + useSheetPaneBridge, + type SheetPaneHost, +} from "@/screens/workspace/sheet-pane"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useAssistantFileLinkActions, type InlinePathTarget } from "@/assistant-file-links"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const SHEET_PANE_HOST: SheetPaneHost = { + workspaceKey: "host:workspace", + serverId: "host", + workspaceId: "workspace", + isWorkspaceFocused: true, + buildContent: () => { + throw new Error("The environment probe builds no pane content"); + }, +}; let root: Root; let container: HTMLDivElement; beforeEach(() => { @@ -151,14 +168,21 @@ describe("workspace sheet environment", () => { }, [name]); return null; } - function Capture() { - bridge = useWorkspaceSheetEnvironment(); + function CaptureBridge() { + bridge = useSheetPaneBridge(); return ( ); } + function Capture() { + return ( + + + + ); + } function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) { const onOpenFile = useCallback( (target: InlinePathTarget) => { @@ -220,3 +244,69 @@ describe("workspace sheet environment", () => { source.unmount(); }); }); + +for (const [runtime, Chat] of [ + ["native", NativeChat], + ["web", WebChat], +] as const) { + it(`${runtime} ignores hidden stream chunks and reads the latest transcript on reveal`, async () => { + const serverId = `hidden-stream-${runtime}`; + const agentId = "background-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null); + const profile = { + commits: 0, + onRender() { + profile.commits++; + }, + }; + const publish = (text: string) => { + const item: StreamItem = { + id: "message", + kind: "assistant_message", + text, + timestamp: new Date(), + }; + store.setAgentStreamTail(serverId, new Map([[agentId, [item]]])); + }; + function Content() { + const items = useAgentStreamTail(serverId, agentId); + const [draft] = useState("unsent draft"); + return ( + + {draft}:{items?.[0]?.kind === "assistant_message" ? items[0].text : ""} + + ); + } + const show = (active: boolean) => + act(() => + root.render( + + + + + + + , + ), + ); + try { + publish("first chunk"); + show(true); + expect(container.textContent).toBe("unsent draft:first chunk"); + show(false); + const hiddenCommits = profile.commits; + for (let index = 0; index < 10; index++) { + await act(async () => publish(`chunk ${index}`)); + } + expect(profile.commits).toBe(hiddenCommits); + show(true); + expect(container.textContent).toBe("unsent draft:chunk 9"); + await act(async () => publish("live after reveal")); + expect(container.textContent).toBe("unsent draft:live after reveal"); + } finally { + act(() => root.render(null)); + store.clearSession(serverId); + } + }); +} diff --git a/packages/app/src/plugins/catalog-sync-model.test.ts b/packages/app/src/plugins/catalog-sync-model.test.ts new file mode 100644 index 0000000000..98a8a3cf37 --- /dev/null +++ b/packages/app/src/plugins/catalog-sync-model.test.ts @@ -0,0 +1,424 @@ +import appPackage from "../../package.json"; +import { afterEach, expect, it } from "vitest"; +import { QueryObserver } from "@tanstack/react-query"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { settingsRpc } from "@getpaseo/plugin"; +import { syncPluginCatalog } from "./catalog-sync-model"; +import { createPluginHosts } from "./hosts"; +import { PluginRegistry } from "./registry"; +import { pluginSettingsKey } from "./settings/query"; +import type { InstalledPlugin } from "./types"; + +const audio = { play: async () => 0 }; +const rpc = settingsRpc("preferences"); +type Catalog = Awaited>; +type Observation = ReturnType; +type Observer = Parameters[0]; + +class SettingsStore { + private revision = 1; + private values = { label: "original" }; + reads = 0; + + read() { + this.reads++; + return { status: "ready", revision: String(this.revision), values: this.values } as const; + } + + write(input: unknown) { + const write = rpc.write.input.parse(input); + if (write.revision !== String(this.revision)) { + return { status: "conflict", error: "Settings changed" } as const; + } + const values = write.values; + if (typeof values !== "object" || values === null || !("label" in values)) { + throw new Error("Expected settings values"); + } + if (typeof values.label !== "string") throw new Error("Expected label"); + this.values = { label: values.label }; + this.revision++; + return { status: "saved", revision: String(this.revision), values: this.values } as const; + } +} + +/** In-memory daemon port; it delivers owned snapshots and stops updates on release. */ +class CatalogClient extends DaemonClient { + catalog = catalog("one"); + fetches = 0; + releases = 0; + events: Parameters[0] = []; + private readonly observers = new Set(); + + constructor(readonly settings = new SettingsStore()) { + super({ url: "ws://unused.test", clientId: "catalog-sync-test" }); + } + + override async getPluginCatalog(): Promise { + this.fetches++; + return this.catalog; + } + + override observeEvents(events: Parameters[0]): Observation { + this.events = events; + const snapshot = { requestId: "events", subscriptionId: "catalog" }; + const subscribers = new Set(); + return { + subscriptionId: snapshot.subscriptionId, + ready: Promise.resolve(snapshot), + subscribe: (observer) => { + subscribers.add(observer); + this.observers.add(observer); + observer.snapshot(snapshot); + return () => { + subscribers.delete(observer); + this.observers.delete(observer); + }; + }, + release: async () => { + this.releases++; + for (const observer of subscribers) this.observers.delete(observer); + subscribers.clear(); + }, + }; + } + + override async invokePluginRpc(_pluginId: string, method: string, input: unknown) { + if (method === rpc.read.name) return this.settings.read(); + if (method === rpc.write.name) return this.settings.write(input); + throw new Error(`Unexpected RPC: ${method}`); + } + + update(message: SessionOutboundMessage) { + for (const observer of this.observers) observer.update(message); + } + + snapshot() { + for (const observer of this.observers) { + observer.snapshot({ requestId: "reconnect", subscriptionId: "catalog" }); + } + } +} + +function catalog(marker: string): Catalog { + return [ + { + id: "reports", + requirements: { paseo: `>=${appPackage.version}` }, + clientBundle: `(function() { return { default: function(plugin) { + plugin.addTimelineTransformer({ + id: "report", query: { itemType: "tool_call" }, + transform() { return ${JSON.stringify(marker)} ? { items: [] } : undefined; } + }); + return function() { plugin.openSettings("cleanup"); }; + } }; })`, + }, + ]; +} + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0).toReversed()) cleanup(); +}); + +function harness() { + let evaluations = 0; + let disposals = 0; + const registry = new PluginRegistry({ + localeSource: { getLocale: () => "en", subscribe: () => () => {} }, + version: appPackage.version, + createRuntime(installation) { + evaluations++; + return { + hosts: createPluginHosts( + { + getHosts: () => [], + getSnapshot: () => null, + subscribeAll: () => () => {}, + subscribeHostList: () => () => {}, + }, + installation.lifetime.signal, + ), + paseo: installation.paseo, + i18n: installation.i18n, + rpc: async () => { + throw new Error("Unexpected runtime RPC"); + }, + openSettings: () => { + disposals++; + }, + openScreen() {}, + openSurface() {}, + async playAudio() {}, + openPanel() {}, + addComposerPill: () => ({ update() {}, remove() {} }), + addHeaderButton: () => ({ update() {}, remove() {} }), + }; + }, + }); + const client = new CatalogClient(); + cleanups.push(() => { + registry.removeHost("host"); + registry.removeHost("other"); + }); + function sync(options: Partial[0]> = {}) { + const stop = syncPluginCatalog({ + serverId: "host", + client, + audio, + connected: true, + supported: true, + registry, + ...options, + }); + cleanups.push(stop); + return stop; + } + function installed(serverId = "host") { + const plugin = registry.getSnapshot().find((entry) => entry.serverId === serverId); + if (!plugin) throw new Error(`Missing installation: ${serverId}`); + return plugin; + } + return { + registry, + client, + sync, + installed, + evaluations: () => evaluations, + disposals: () => disposals, + }; +} + +async function connected() { + const h = harness(); + const stop = h.sync(); + await expect.poll(() => h.registry.getSnapshot().length).toBe(1); + return { ...h, stop }; +} + +function settingsQuery(plugin: InstalledPlugin) { + return { + queryKey: pluginSettingsKey("preferences"), + queryFn: async () => rpc.read.output.parse(await plugin.invoke(rpc.read.name, {})), + // The replica policy used by useSettings deliberately disables automatic refetches. + gcTime: Infinity, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + } as const; +} + +it("retains installations and timeline transforms while disconnected", async () => { + const h = await connected(); + const plugin = h.installed(); + const snapshot = h.registry.getSnapshot(); + const transformers = plugin.timelineTransformers; + h.stop(); + h.sync({ connected: false }); + h.client.update({ + type: "status", + payload: { status: "plugin_catalog_changed", pluginId: "reports" }, + }); + expect(h.registry.getSnapshot()).toBe(snapshot); + expect(h.installed().timelineTransformers).toBe(transformers); + expect(plugin.lifetime.signal.aborted).toBe(false); + expect(h.disposals()).toBe(0); + expect(h.client.fetches).toBe(1); + expect(h.client.events).toEqual([ + "status.plugin_catalog_changed", + "status.plugin_settings_changed", + ]); +}); + +it.each(["mounted", "unmounted"])( + "refreshes %s settings and revisions after another client saves offline", + async (surface) => { + const h = await connected(); + const plugin = h.installed(); + const snapshot = h.registry.getSnapshot(); + const options = settingsQuery(plugin); + const original = await plugin.queryClient.fetchQuery(options); + const observer = new QueryObserver(plugin.queryClient, options); + const unsubscribe = observer.subscribe(() => {}); + cleanups.push(unsubscribe); + if (surface === "unmounted") unsubscribe(); + plugin.queryClient.setQueryData(["attachment-search"], "retained"); + const otherClient = new CatalogClient(); + h.registry.installCatalog("other", otherClient.catalog, { client: otherClient, audio }); + const otherPlugin = h.installed("other"); + await otherPlugin.queryClient.fetchQuery(settingsQuery(otherPlugin)); + // Another host publishes new presentation snapshots without replacing the owning lifetime. + const beforeReconnect = h.registry.getSnapshot(); + const retainedInstallation = h.installed(); + + h.stop(); + h.sync({ connected: false }); + const clientB = new CatalogClient(h.client.settings); + expect( + await clientB.invokePluginRpc("reports", rpc.write.name, { + revision: original.revision, + values: { label: "client B" }, + }), + ).toEqual({ status: "saved", revision: "2", values: { label: "client B" } }); + expect(plugin.queryClient.getQueryData(options.queryKey)).toEqual(original); + h.sync(); + + await expect + .poll(() => plugin.queryClient.getQueryData(options.queryKey)) + .toEqual({ + status: "ready", + revision: "2", + values: { label: "client B" }, + }); + const refreshed = await plugin.queryClient.fetchQuery(options); + expect( + await plugin.invoke(rpc.write.name, { + revision: refreshed.revision, + values: { label: "client A" }, + }), + ).toEqual({ status: "saved", revision: "3", values: { label: "client A" } }); + expect(h.registry.getSnapshot()).toBe(beforeReconnect); + expect(h.installed()).toBe(retainedInstallation); + expect(h.installed().lifetime).toBe(plugin.lifetime); + expect(h.installed().queryClient).toBe(plugin.queryClient); + expect(h.installed().timelineTransformers).toBe(snapshot[0].timelineTransformers); + expect(plugin.queryClient.getQueryData(["attachment-search"])).toBe("retained"); + expect(otherClient.settings.reads).toBe(1); + expect(otherPlugin.queryClient.getQueryState(options.queryKey)?.isInvalidated).toBe(false); + expect(otherPlugin.queryClient.getQueryData(options.queryKey)).toEqual(original); + expect(h.evaluations()).toBe(2); + expect(h.disposals()).toBe(0); + }, +); + +it("replaces changed bundles on reconnect and disposes the old installation", async () => { + const h = await connected(); + const plugin = h.installed(); + plugin.queryClient.setQueryData(["attachment-search"], "old"); + h.stop(); + h.client.catalog = catalog("two"); + h.sync(); + await expect.poll(() => h.installed() === plugin).toBe(false); + expect(plugin.lifetime.signal.aborted).toBe(true); + expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]); + expect(h.installed().timelineTransformers.map((entry) => entry.id)).toEqual(["report"]); + expect(h.evaluations()).toBe(2); + expect(h.disposals()).toBe(1); +}); + +it("replaces an explicitly reloaded bundle even when its source is unchanged", async () => { + const h = await connected(); + const plugin = h.installed(); + h.client.update({ + type: "status", + payload: { status: "plugin_catalog_changed", pluginId: "reports" }, + }); + await expect.poll(() => h.installed() === plugin).toBe(false); + expect(h.disposals()).toBe(1); + expect(h.evaluations()).toBe(2); +}); + +it.each(["unsupported", "no audio"])("disposes plugins when %s", async (reason) => { + const h = await connected(); + const plugin = h.installed(); + h.stop(); + h.sync({ supported: reason !== "unsupported", audio: reason === "no audio" ? null : audio }); + expect(h.registry.getSnapshot()).toEqual([]); + expect(plugin.lifetime.signal.aborted).toBe(true); + expect(h.disposals()).toBe(1); +}); + +it("refreshes settings on a replayed subscription snapshot without reinstalling", async () => { + const h = await connected(); + const plugin = h.installed(); + const options = settingsQuery(plugin); + await plugin.queryClient.fetchQuery(options); + h.client.settings.write({ revision: "1", values: { label: "replayed" } }); + h.client.snapshot(); + await expect + .poll(() => plugin.queryClient.getQueryData(options.queryKey)) + .toEqual({ + status: "ready", + revision: "2", + values: { label: "replayed" }, + }); + expect(h.installed()).toBe(plugin); + expect(h.evaluations()).toBe(1); +}); + +it("invalidates only the named settings on its host for a live notification", async () => { + const h = await connected(); + const plugin = h.installed(); + const options = settingsQuery(plugin); + await plugin.queryClient.fetchQuery(options); + const observer = new QueryObserver(plugin.queryClient, options); + cleanups.push(observer.subscribe(() => {})); + plugin.queryClient.setQueryData(pluginSettingsKey("other-settings"), "unchanged"); + const other = new CatalogClient(); + h.registry.installCatalog("other", other.catalog, { client: other, audio }); + await h.installed("other").queryClient.fetchQuery(settingsQuery(h.installed("other"))); + + const beforeNotification = h.registry.getSnapshot(); + const retainedInstallation = h.installed(); + h.client.settings.write({ revision: "1", values: { label: "live" } }); + h.client.update({ + type: "status", + payload: { + status: "plugin_settings_changed", + pluginId: "reports", + settingsId: "preferences", + }, + }); + + await expect + .poll(() => plugin.queryClient.getQueryData(options.queryKey)) + .toEqual({ + status: "ready", + revision: "2", + values: { label: "live" }, + }); + expect(plugin.queryClient.getQueryState(pluginSettingsKey("other-settings"))?.isInvalidated).toBe( + false, + ); + expect(other.settings.reads).toBe(1); + expect(h.registry.getSnapshot()).toBe(beforeNotification); + expect(h.installed()).toBe(retainedInstallation); + expect(h.installed().lifetime).toBe(plugin.lifetime); + expect(h.installed().queryClient).toBe(plugin.queryClient); + expect(h.evaluations()).toBe(2); + expect(h.client.fetches).toBe(1); +}); + +it("ignores an in-flight catalog snapshot after disconnect", async () => { + const h = await connected(); + const plugin = h.installed(); + await plugin.queryClient.fetchQuery(settingsQuery(plugin)); + const snapshot = h.registry.getSnapshot(); + h.stop(); + let resolveCatalog: (value: Catalog) => void = () => {}; + const pending = new Promise((resolve) => { + resolveCatalog = resolve; + }); + let fetching: () => void = () => {}; + const started = new Promise((resolve) => { + fetching = resolve; + }); + class PendingClient extends CatalogClient { + override getPluginCatalog() { + fetching(); + return pending; + } + } + const stop = h.sync({ client: new PendingClient(h.client.settings) }); + await started; + stop(); + resolveCatalog(catalog("late")); + await pending; + // Drain the catalog continuation through a subsequently queued task. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(h.registry.getSnapshot()).toBe(snapshot); + expect(h.client.settings.reads).toBe(1); + expect(h.evaluations()).toBe(1); +}); diff --git a/packages/app/src/plugins/catalog-sync-model.ts b/packages/app/src/plugins/catalog-sync-model.ts new file mode 100644 index 0000000000..6571294a68 --- /dev/null +++ b/packages/app/src/plugins/catalog-sync-model.ts @@ -0,0 +1,93 @@ +import type { AudioEngine } from "@/audio"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PluginRegistry } from "./registry"; +import { pluginSettingsKey, pluginSettingsPrefix } from "./settings/query"; + +interface CatalogSyncInput { + serverId: string; + client: DaemonClient; + audio: Pick | null; + connected: boolean; + supported: boolean; + registry: Pick; +} + +export function syncPluginCatalog({ + serverId, + client, + audio, + connected, + supported, + registry, +}: CatalogSyncInput): () => void { + if (!supported || !audio) { + registry.removeHost(serverId); + return () => {}; + } + // A dropped socket reconnects on the same client, so installations keep rendering offline. + if (!connected) return () => {}; + const installationOptions = { client, audio }; + let cancelled = false; + let refreshQueue = Promise.resolve(); + function refresh(replacePluginId?: string) { + refreshQueue = refreshQueue.then(() => + client + .getPluginCatalog() + .then((catalog) => { + if (!cancelled) { + registry.installCatalog(serverId, catalog, { ...installationOptions, replacePluginId }); + } + return undefined; + }) + .catch((error) => { + if (!cancelled) { + console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error); + } + return undefined; + }), + ); + return refreshQueue; + } + const observation = client.observeEvents([ + "status.plugin_catalog_changed", + "status.plugin_settings_changed", + ]); + observation.subscribe({ + snapshot: () => { + void refresh().then(() => { + if (cancelled) return undefined; + // Retained replicas miss offline writes and do not refetch when a screen mounts. + for (const plugin of registry.getSnapshot()) { + if (plugin.serverId !== serverId) continue; + void plugin.queryClient.invalidateQueries({ + queryKey: pluginSettingsPrefix, + refetchType: "all", + }); + } + return undefined; + }); + }, + 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 = registry + .getSnapshot() + .find((item) => item.serverId === serverId && item.id === pluginId); + void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) }); + } + } + if (message.payload.status === "plugin_catalog_changed") { + const pluginId = message.payload.pluginId; + if (typeof pluginId === "string") void refresh(pluginId); + } + }, + }); + return () => { + cancelled = true; + void observation + .release() + .catch((error) => console.warn("[Plugins] Failed to release catalog", error)); + }; +} diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index 0eedfde3ab..6dc11b72b8 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -1,10 +1,10 @@ import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; -import { pluginSettingsKey } from "./settings/use-settings"; import { useEffect } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostFeature } from "@/runtime/host-features"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { pluginRegistry } from "./registry"; +import { syncPluginCatalog } from "./catalog-sync-model"; export function PluginCatalogSync({ serverId, @@ -17,72 +17,18 @@ export function PluginCatalogSync({ const connected = useHostRuntimeIsConnected(serverId); const supported = useHostFeature(serverId, "plugins"); - useEffect(() => { - let cancelled = false; - let refreshQueue = Promise.resolve(); - if (!supported || !audio) { - pluginRegistry.removeHost(serverId); - return; - } - if (!connected) { - pluginRegistry.removeHost(serverId); - return; - } - const refresh = (replacePluginId?: string) => { - refreshQueue = refreshQueue.then(() => - client - .getPluginCatalog() - .then((catalog) => { - if (!cancelled) { - pluginRegistry.installCatalog(serverId, catalog, { - replacePluginId, - client, - audio, - }); - } - return undefined; - }) - .catch((error) => { - if (!cancelled) { - console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error); - } - return undefined; - }), - ); - return refreshQueue; - }; - const observation = client.observeEvents([ - "status.plugin_catalog_changed", - "status.plugin_settings_changed", - ]); - observation.subscribe({ - snapshot: () => { - void refresh(); - }, - update: (message) => { - if (message.type !== "status") return; - if (message.payload.status === "plugin_settings_changed") { - const { pluginId, settingsId } = message.payload; - if (typeof settingsId === "string") { - const plugin = pluginRegistry - .getSnapshot() - .find((item) => item.serverId === serverId && item.id === pluginId); - void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) }); - } - } - if (message.payload.status === "plugin_catalog_changed") { - const pluginId = message.payload.pluginId; - if (typeof pluginId === "string") void refresh(pluginId); - } - }, - }); - return () => { - cancelled = true; - void observation - .release() - .catch((error) => console.warn("[Plugins] Failed to release catalog", error)); - }; - }, [audio, client, connected, serverId, supported]); + useEffect( + () => + syncPluginCatalog({ + serverId, + client, + audio, + connected, + supported, + registry: pluginRegistry, + }), + [audio, client, connected, serverId, supported], + ); useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]); return null; diff --git a/packages/app/src/plugins/client-bundles/index.ts b/packages/app/src/plugins/client-bundles/index.ts new file mode 100644 index 0000000000..06580dd222 --- /dev/null +++ b/packages/app/src/plugins/client-bundles/index.ts @@ -0,0 +1,32 @@ +import * as FileSystem from "expo-file-system/legacy"; +import { CryptoDigestAlgorithm, digestStringAsync, randomUUID } from "expo-crypto"; +import { createPluginClientBundleStore } from "./store"; + +export function createHostPluginClientBundleStore(serverId: string) { + const directory = `${FileSystem.cacheDirectory}plugin-client-bundles/${encodeURIComponent(serverId)}/`; + const ensureDirectory = async () => { + if (!FileSystem.cacheDirectory) throw new Error("Plugin bundle cache directory unavailable"); + await FileSystem.makeDirectoryAsync(directory, { intermediates: true }); + }; + return createPluginClientBundleStore( + { + async list() { + await ensureDirectory(); + return FileSystem.readDirectoryAsync(directory); + }, + read: (name) => FileSystem.readAsStringAsync(`${directory}${name}`), + async writeAtomic(name, contents) { + await ensureDirectory(); + const temporary = `${directory}${randomUUID()}.tmp`; + try { + await FileSystem.writeAsStringAsync(temporary, contents); + await FileSystem.moveAsync({ from: temporary, to: `${directory}${name}` }); + } finally { + await FileSystem.deleteAsync(temporary, { idempotent: true }); + } + }, + remove: (name) => FileSystem.deleteAsync(`${directory}${name}`, { idempotent: true }), + }, + (contents) => digestStringAsync(CryptoDigestAlgorithm.SHA256, contents), + ); +} diff --git a/packages/app/src/plugins/client-bundles/index.web.ts b/packages/app/src/plugins/client-bundles/index.web.ts new file mode 100644 index 0000000000..6e67778089 --- /dev/null +++ b/packages/app/src/plugins/client-bundles/index.web.ts @@ -0,0 +1,62 @@ +import { createPluginClientBundleStore } from "./store"; + +const DATABASE_NAME = "paseo-plugin-client-bundles"; +const STORE_NAME = "bundles"; +let opening: Promise | undefined; + +function openDatabase(): Promise { + opening ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, 1); + request.addEventListener("upgradeneeded", () => request.result.createObjectStore(STORE_NAME)); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => reject(request.error)); + }).catch((error) => { + opening = undefined; + throw error; + }); + return opening; +} + +export function createHostPluginClientBundleStore(serverId: string) { + const prefix = `${encodeURIComponent(serverId)}/`; + const range = () => IDBKeyRange.bound(prefix, `${prefix}\uffff`); + async function run( + mode: IDBTransactionMode, + operation: (store: IDBObjectStore) => IDBRequest, + ): Promise { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, mode); + const request = operation(transaction.objectStore(STORE_NAME)); + transaction.addEventListener("complete", () => resolve(request.result)); + transaction.addEventListener("abort", () => reject(transaction.error)); + transaction.addEventListener("error", () => reject(transaction.error)); + }); + } + return createPluginClientBundleStore( + { + async list() { + return (await run("readonly", (store) => store.getAllKeys(range()))) + .filter((key): key is string => typeof key === "string") + .map((key) => key.slice(prefix.length)); + }, + async read(name) { + const result: unknown = await run("readonly", (store) => store.get(`${prefix}${name}`)); + if (typeof result !== "string") throw new Error("Missing plugin client bundle"); + return result; + }, + async writeAtomic(name, contents) { + await run("readwrite", (store) => store.put(contents, `${prefix}${name}`)); + }, + async remove(name) { + await run("readwrite", (store) => store.delete(`${prefix}${name}`)); + }, + }, + async (contents) => { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); + }, + ); +} diff --git a/packages/app/src/plugins/client-bundles/storage.browser.test.ts b/packages/app/src/plugins/client-bundles/storage.browser.test.ts new file mode 100644 index 0000000000..9acd7e0c52 --- /dev/null +++ b/packages/app/src/plugins/client-bundles/storage.browser.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "vitest"; +import { createHostPluginClientBundleStore } from "./index.web"; + +async function sha256(contents: string) { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +test("browser bundles survive a fresh store and remain isolated by host", async () => { + const first = createHostPluginClientBundleStore("bundle-test-host-a"); + const second = createHostPluginClientBundleStore("bundle-test-host-b"); + const clientBundle = "bundle".repeat(250_000); + const entry = { + id: "same-plugin", + clientBundle, + clientBundleHash: await sha256(clientBundle), + }; + const otherCode = "other host code"; + const other = { + id: entry.id, + clientBundle: otherCode, + clientBundleHash: await sha256(otherCode), + }; + try { + await first.replace([entry]); + await second.replace([other]); + expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([entry]); + expect(await createHostPluginClientBundleStore("bundle-test-host-b").load()).toEqual([other]); + await first.replace([]); + expect(await createHostPluginClientBundleStore("bundle-test-host-a").load()).toEqual([]); + expect(await second.load()).toEqual([other]); + } finally { + await first.replace([]); + await second.replace([]); + } +}); diff --git a/packages/app/src/plugins/client-bundles/store.test.ts b/packages/app/src/plugins/client-bundles/store.test.ts new file mode 100644 index 0000000000..c3332c6b0e --- /dev/null +++ b/packages/app/src/plugins/client-bundles/store.test.ts @@ -0,0 +1,179 @@ +import { createHash } from "node:crypto"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { expect, test } from "vitest"; +import { createPluginClientBundleStore, type PluginBundleFiles } from "./store"; + +const sha256 = async (contents: string) => createHash("sha256").update(contents).digest("hex"); + +class BundleFiles implements PluginBundleFiles { + readonly contents = new Map(); + readonly writes: string[] = []; + async list() { + return [...this.contents.keys()]; + } + async read(name: string) { + const contents = this.contents.get(name); + if (contents === undefined) throw new Error("File disappeared"); + return contents; + } + async writeAtomic(name: string, contents: string) { + this.writes.push(name); + this.contents.set(name, contents); + } + async remove(name: string) { + this.contents.delete(name); + } +} + +async function bundle(id: string, clientBundle: string) { + return { id, clientBundle, clientBundleHash: await sha256(clientBundle) }; +} + +test("a cold store loads verified bundles and prunes versions missing from the latest catalog", async () => { + const files = new BundleFiles(); + const old = await bundle("changed", "old"); + const kept = await bundle("kept", "Unicode: 折叠屏 🐚"); + await createPluginClientBundleStore(files, sha256).replace([old, kept]); + const cold = createPluginClientBundleStore(files, sha256); + expect(await cold.load()).toEqual([old, kept]); + const changed = await bundle("changed", "new"); + await cold.replace([kept, changed]); + expect(files.contents.size).toBe(2); + expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([kept, changed]); + expect(files.writes).toHaveLength(3); + await cold.replace([]); + expect(files.contents.size).toBe(0); +}); + +test("same-length corruption, truncated files and unreadable files are not advertised", async () => { + const files = new BundleFiles(); + const entries = await Promise.all([ + bundle("same-length", "good"), + bundle("truncated", "long"), + bundle("missing", "lost"), + ]); + await createPluginClientBundleStore(files, sha256).replace(entries); + const names = [...files.contents.keys()]; + files.contents.set(names[0]!, "evil"); + files.contents.set(names[1]!, "lo"); + // The directory entry still exists while reading the file fails. + const unreadable: PluginBundleFiles = { + list: () => files.list(), + read: (name) => + name === names[2] ? Promise.reject(new Error("Unreadable")) : files.read(name), + writeAtomic: (name, contents) => files.writeAtomic(name, contents), + remove: (name) => files.remove(name), + }; + const cold = createPluginClientBundleStore(unreadable, sha256); + expect(await cold.load()).toEqual([]); + await cold.replace(entries); + expect(await createPluginClientBundleStore(files, sha256).load()).toEqual(entries); +}); + +test("an interrupted atomic write leaves the prior catalog available for a cold reader", async () => { + const files = new BundleFiles(); + const previous = await bundle("plugin", "previous"); + await createPluginClientBundleStore(files, sha256).replace([previous]); + const failing: PluginBundleFiles = { + list: () => files.list(), + read: (name) => files.read(name), + remove: (name) => files.remove(name), + writeAtomic: async () => { + throw new Error("Disk full"); + }, + }; + await expect( + createPluginClientBundleStore(failing, sha256).replace([await bundle("plugin", "next")]), + ).rejects.toThrow("Disk full"); + expect(await createPluginClientBundleStore(files, sha256).load()).toEqual([previous]); +}); + +// Exercise the app store through a fresh platform-agnostic client, using its transport port. +test.each(["valid", "corrupt", "missing"])( + "cold catalog uses a %s persisted bundle correctly", + async (state) => { + const files = new BundleFiles(); + const entry = await bundle("plugin", "good code"); + await createPluginClientBundleStore(files, sha256).replace([entry]); + if (state === "corrupt") files.contents.set([...files.contents.keys()][0]!, "evil code"); + if (state === "missing") files.contents.clear(); + let open = () => {}; + let receive: (data: unknown, binary: boolean) => void = () => {}; + const requests: Record[] = []; + const client = new DaemonClient({ + url: "ws://test", + clientId: "plugin-cache-test", + reconnect: { enabled: false }, + createPluginClientBundleStore: () => createPluginClientBundleStore(files, sha256), + transportFactory: () => ({ + send(data) { + if (typeof data !== "string") return; + const frame = JSON.parse(data); + if (frame.type !== "session") return; + const request = frame.message; + if (request.type !== "plugin.catalog.get.request") return; + requests.push(request); + const { clientBundle: _code, ...reference } = entry; + receive( + JSON.stringify({ + type: "session", + message: { + type: "plugin.catalog.get.response", + payload: { + requestId: request.requestId, + plugins: [request.cachedClientBundles ? reference : entry], + }, + }, + }), + false, + ); + }, + close() {}, + onOpen(handler) { + open = handler; + return () => {}; + }, + onMessage(handler) { + receive = handler; + return () => {}; + }, + onClose() { + return () => {}; + }, + onError() { + return () => {}; + }, + }), + }); + try { + const connecting = client.connect(); + open(); + receive( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "cache-host", + hostname: null, + version: null, + features: { plugins: true }, + }, + }, + }), + false, + ); + await connecting; + expect(await client.getPluginCatalog()).toEqual([entry]); + if (state === "valid") + expect(requests[0]?.cachedClientBundles).toEqual([ + { id: entry.id, clientBundleHash: entry.clientBundleHash }, + ]); + else expect(requests[0]).not.toHaveProperty("cachedClientBundles"); + await expect.poll(() => createPluginClientBundleStore(files, sha256).load()).toEqual([entry]); + } finally { + await client.close(); + } + }, +); diff --git a/packages/app/src/plugins/client-bundles/store.ts b/packages/app/src/plugins/client-bundles/store.ts new file mode 100644 index 0000000000..4a120c2c2a --- /dev/null +++ b/packages/app/src/plugins/client-bundles/store.ts @@ -0,0 +1,56 @@ +import type { + PluginClientBundle, + PluginClientBundleStore, +} from "@getpaseo/client/internal/daemon-client"; + +/** Each adapter is scoped to one host. Writes publish a complete file/record atomically. */ +export interface PluginBundleFiles { + list(): Promise; + read(name: string): Promise; + writeAtomic(name: string, contents: string): Promise; + remove(name: string): Promise; +} + +export function createPluginClientBundleStore( + files: PluginBundleFiles, + sha256: (contents: string) => Promise, +): PluginClientBundleStore { + const verified = new Set(); + const filename = (bundle: PluginClientBundle) => + `${encodeURIComponent(bundle.id)}.${bundle.clientBundleHash}.js`; + return { + async load() { + const bundles: PluginClientBundle[] = []; + for (const name of await files.list()) { + try { + const match = /^(.*)\.([a-f0-9]{64})\.js$/.exec(name); + if (!match) continue; + const id = decodeURIComponent(match[1]!); + const clientBundleHash = match[2]!; + const clientBundle = await files.read(name); + if ((await sha256(clientBundle)) !== clientBundleHash) continue; + verified.add(name); + bundles.push({ id, clientBundleHash, clientBundle }); + } catch { + // Missing, unreadable or corrupt files are cache misses, never executable code. + } + } + return bundles; + }, + async replace(bundles) { + const retained = new Set(); + for (const bundle of bundles) { + const name = filename(bundle); + retained.add(name); + if (verified.has(name)) continue; + await files.writeAtomic(name, bundle.clientBundle); + verified.add(name); + } + for (const name of await files.list()) { + if (retained.has(name)) continue; + await files.remove(name); + verified.delete(name); + } + }, + }; +} diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index 18330dc949..d2d02cb568 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -219,7 +219,10 @@ test("locale snapshots preserve installation state and observations, then releas expect(after.paseo).toBe(before.paseo); expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component); expect(h.open).toEqual(new Set(["notes"])); + const translatedSnapshot = h.plugins.getSnapshot(); h.plugins.installCatalog("host", [translated], { client: h.client, audio }); + expect(h.plugins.getSnapshot()).toBe(translatedSnapshot); + expect(h.plugins.getSnapshot()[0].locale).toBe("zh-CN"); expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n); h.plugins.removeHost("host"); expect(listeners.size).toBe(0); diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index 9e8d246a95..4d3877161f 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -27,12 +27,12 @@ const pluginRegistry = { installCatalog( serverId: string, catalog: Parameters[1], - options: { replacePluginId?: string } = {}, + options: { replacePluginId?: string; client?: DaemonClient } = {}, ) { return registry.installCatalog( serverId, catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })), - { ...options, client: daemonClient, audio }, + { client: daemonClient, ...options, audio }, ); }, }; @@ -118,6 +118,37 @@ describe("PluginRegistry", () => { expect(second?.queryClient.getQueryData(["counter"])).toBe(3); }); + it("keeps the snapshot when the same catalog is installed again", () => { + const clientBundle = timelineBundle("one"); + pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]); + const snapshot = pluginRegistry.getSnapshot(); + const transformers = snapshot[0]?.timelineTransformers; + const listener = vi.fn(); + const unsubscribe = pluginRegistry.subscribe(listener); + + pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]); + unsubscribe(); + + expect(pluginRegistry.getSnapshot()).toBe(snapshot); + expect(pluginRegistry.getSnapshot()[0]?.timelineTransformers).toBe(transformers); + expect(listener).not.toHaveBeenCalled(); + }); + + it("reinstalls an unchanged bundle for a new host client", () => { + const clientBundle = bundle("one"); + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]); + const first = pluginRegistry.getSnapshot()[0]; + + pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], { + client: {} as DaemonClient, + }); + + const second = pluginRegistry.getSnapshot()[0]; + expect(second).not.toBe(first); + expect(first?.lifetime.signal.aborted).toBe(true); + expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1); + }); + it("replaces the query cache when the plugin bundle changes", () => { pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]); const first = pluginRegistry.getSnapshot()[0]; diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index adbdabc93c..cfe4f7212a 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -19,6 +19,8 @@ export class PluginRegistry { private snapshot: InstalledPlugin[] = []; private readonly disposed = new WeakSet(); private readonly evaluationErrors = new Map(); + /** The client each host's installations were bound to; their Paseo API and RPCs call it. */ + private readonly clientByHost = new Map(); constructor( private readonly dependencies: { @@ -49,10 +51,15 @@ export class PluginRegistry { }, ): boolean { const previous = this.byHost.get(serverId) ?? []; + const previousErrors = this.hostEvaluationErrors(serverId); + // Installations call the client they were created with, so a new client replaces them all. + const sameClient = this.clientByHost.get(serverId) === options.client; + this.clientByHost.set(serverId, options.client); const previousTimelineBundles = previous .filter((plugin) => plugin.timelineTransformers.length > 0) .map((plugin) => `${plugin.id}\0${plugin.clientBundle}`); const preserved = catalog.flatMap((entry) => { + if (!sameClient) return []; const existing = previous.find( (plugin) => plugin.id !== options.replacePluginId && @@ -146,13 +153,13 @@ export class PluginRegistry { } } this.byHost.set(serverId, installed); - if ( - installed.length !== catalog.length || - installed.length !== previous.length || - installed.some((plugin, index) => plugin !== previous[index]) - ) { - this.publish(); - } + // An unchanged catalog keeps the snapshot, so timeline projections cached against the + // installed transformers stay valid across a reconnect. + const unchanged = + installed.length === previous.length && + installed.every((plugin, index) => plugin === previous[index]) && + this.hostEvaluationErrors(serverId) === previousErrors; + if (!unchanged) this.publish(); const installedTimelineBundles = installed .filter((plugin) => plugin.timelineTransformers.length > 0) .map((plugin) => `${plugin.id}\0${plugin.clientBundle}`); @@ -170,9 +177,17 @@ export class PluginRegistry { if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key); } this.byHost.delete(serverId); + this.clientByHost.delete(serverId); this.publish(); } + private hostEvaluationErrors(serverId: string): string { + const prefix = `${serverId}/`; + return JSON.stringify( + [...this.evaluationErrors].filter(([key]) => key.startsWith(prefix)).sort(), + ); + } + private dispose(plugin: InstalledPlugin): void { if (this.disposed.has(plugin)) return; this.disposed.add(plugin); diff --git a/packages/app/src/plugins/settings/query.ts b/packages/app/src/plugins/settings/query.ts new file mode 100644 index 0000000000..db06d58320 --- /dev/null +++ b/packages/app/src/plugins/settings/query.ts @@ -0,0 +1,2 @@ +export const pluginSettingsPrefix = ["plugin-settings"] as const; +export const pluginSettingsKey = (id: string) => [...pluginSettingsPrefix, id] as const; diff --git a/packages/app/src/plugins/settings/use-settings.ts b/packages/app/src/plugins/settings/use-settings.ts index 0cefc1878f..decf4bfe05 100644 --- a/packages/app/src/plugins/settings/use-settings.ts +++ b/packages/app/src/plugins/settings/use-settings.ts @@ -6,8 +6,8 @@ import { type SettingsDefinition } from "@getpaseo/plugin"; import { settingsRpc } from "@getpaseo/plugin"; import { useReplicaQuery } from "@/data/query"; import { z, type ZodType } from "zod"; +import { pluginSettingsKey } from "./query"; -export const pluginSettingsKey = (id: string) => ["plugin-settings", id] as const; function message(error: unknown): string { if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n"); return error instanceof Error ? error.message : String(error); diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index e0313c6623..54d75f1da2 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -5,7 +5,7 @@ import type { ProviderSnapshotEntry, } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; -import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/agent-commands"; import { i18n } from "@/i18n/i18next"; import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { filterSelectableModels } from "./model-catalog"; diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 6fae0a7e92..e4db5dfb71 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -16,7 +16,8 @@ import { normalizeWorkspaceDescriptor, useSessionStore, } from "@/stores/session-store"; -import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; +import { deriveProjectPlacementFromCwd } from "@/utils/project-placement"; import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; import { useWorkspaceLabels } from "@/workspace-labels"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; @@ -41,12 +42,15 @@ class FakeDirectoryClient { supportsWorkspaceLabels = false; listWorkspaceLabelsCalls = 0; fetchAgentsCalls = 0; + observeAgentsCalls = 0; lastAgentOptions: unknown; fetchWorkspacesCalls = 0; + observeWorkspacesCalls = 0; lastWorkspaceOptions: unknown; listProjectsCalls = 0; lastProjectOptions: unknown; projectResult: ProjectListResult | null = null; + readonly listSnapshots: Promise[] = []; private pendingAgentFetch: Promise | null = null; private pendingWorkspaceFetch: Promise | null = null; private pendingProjectFetch: Promise | null = null; @@ -151,15 +155,22 @@ class FakeDirectoryClient { } observeAgents(options: Parameters[0]) { - return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) => - this.on("agent_update", receive), + this.observeAgentsCalls += 1; + const subscription = subscriptionFixture( + this.fetchAgents({ ...options, subscribe: {} }), + (receive) => this.on("agent_update", receive), ); + this.listSnapshots.push(subscription.ready); + return subscription; } observeWorkspaces(options: Parameters[0]) { - return subscriptionFixture(this.fetchWorkspaces(options), (receive) => + this.observeWorkspacesCalls += 1; + const subscription = subscriptionFixture(this.fetchWorkspaces(options), (receive) => this.on("workspace_update", receive), ); + this.listSnapshots.push(subscription.ready); + return subscription; } observeEvents(events: readonly SessionOutboundMessage["type"][]) { @@ -592,6 +603,83 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it.each([ + ["full", "before"], + ["full", "after"], + ["route", "before"], + ["route", "after"], + ] as const)( + "restores live directory updates when %s demand returns %s refresh completion", + async (demand, timing) => { + const serverId = `released-directory-${demand}-${timing}`; + const { client, directory } = createDirectory(serverId); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, projectList: true }, + }); + const source = {}; + function setDemand(demanded: boolean): void { + if (demand === "full") directory.setDemand(source, demanded); + else directory.setAgentRouteDemand(demanded ? ["agent-1"] : []); + } + const releaseProjects = client.holdProjectFetch(); + const projects: ProjectListResult = { requestId: "projects", projects: [] }; + + try { + setDemand(true); + const refresh = directory.refreshDemand(); + await expect + .poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls]) + .toEqual([1, 1]); + + await Promise.all(client.listSnapshots); + setDemand(false); + if (timing === "before") setDemand(true); + releaseProjects(projects); + await refresh; + if (timing === "after") setDemand(true); + + await expect + .poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls]) + .toEqual([2, 2]); + client.emit({ + type: "agent_update", + payload: { + kind: "upsert", + agent: { + ...projectAgentSnapshot(createAgent(serverId, "agent-1")), + title: "Live title", + }, + project: deriveProjectPlacementFromCwd("/repo"), + }, + }); + client.emit({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: { ...createWorkspaceEntry("workspace-1"), name: "Live workspace" }, + }, + }); + + await expect + .poll(() => { + const session = useSessionStore.getState().sessions[serverId]; + return [ + session.agents.get("agent-1")?.title, + session.workspaces.get("workspace-1")?.name, + ]; + }) + .toEqual(["Live title", "Live workspace"]); + } finally { + directory.dispose(); + } + }, + ); + it("publishes cached directory data before the authoritative request completes", async () => { const serverId = "cached-directory"; serverIds.add(serverId); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index b59440d782..1ffb4fae5f 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -160,6 +160,7 @@ export class DirectorySync { private readonly fullDemandSources = new Set(); private readonly scriptStatusDemand = new Map(); private demandRefresh: Promise | null = null; + private subscriptionGeneration = 0; private satisfiedDemandSource: DirectorySourceToken | null = null; private cursors: DirectoryCheckpoint = {}; @@ -262,6 +263,7 @@ export class DirectorySync { } private releaseSubscriptions(): void { + this.subscriptionGeneration += 1; for (const subscription of [ this.agentSubscription, this.workspaceSubscription, @@ -362,6 +364,7 @@ export class DirectorySync { this.observeDirectoryEvents(); this.observeScriptStatusEvents(); const source = this.connection.source; + const subscriptionGeneration = this.subscriptionGeneration; if ( !force && this.satisfiedDemandSource?.clientGeneration === source.clientGeneration && @@ -378,17 +381,19 @@ export class DirectorySync { ]).then(() => undefined); this.demandRefresh = refresh .then(() => { - this.satisfiedDemandSource = source; + if (this.subscriptionGeneration === subscriptionGeneration) { + this.satisfiedDemandSource = source; + } return undefined; }) .finally(() => { this.demandRefresh = null; const current = this.connection.source; - if ( - this.hasDemand() && - (current.clientGeneration !== source.clientGeneration || - current.connectionEpoch !== source.connectionEpoch) - ) { + const sourceChanged = + current.clientGeneration !== source.clientGeneration || + current.connectionEpoch !== source.connectionEpoch; + const subscriptionsReleased = this.subscriptionGeneration !== subscriptionGeneration; + if (this.hasDemand() && (sourceChanged || subscriptionsReleased)) { void this.requestDemandRefresh().catch(() => undefined); } }); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index bb953220cb..0c7c9f4660 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,3 +1,4 @@ +import { createHostPluginClientBundleStore } from "@/plugins/client-bundles"; import { watchdog } from "@/watchdog"; import { createHostChannelTransport } from "@getpaseo/client/internal/hosts"; import type { OwnedSubscription } from "@getpaseo/client"; @@ -589,6 +590,7 @@ function createDefaultDeps( capabilities: appCapabilities, trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), providerSnapshots: "wire", + createPluginClientBundleStore: createHostPluginClientBundleStore, ...(host.password ? { password: host.password } : {}), localCredential: () => readDesktopManagedLocalCredential(connection), } satisfies Omit; @@ -668,6 +670,7 @@ function createDefaultDeps( ), reconnect: { enabled: false }, appVersion: resolveAppVersion() ?? undefined, + createPluginClientBundleStore: createHostPluginClientBundleStore, }); try { await client.connect(); @@ -684,6 +687,7 @@ function createDefaultDeps( ...(timeoutMs !== undefined ? { timeoutMs } : {}), capabilities: appCapabilities, trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), + createPluginClientBundleStore: createHostPluginClientBundleStore, }); }, getClientId: () => getOrCreateClientId(), diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index eaad715a52..d91d8e7ba4 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -306,6 +306,21 @@ async function expectLegacyWorkspaceRefresh({ } describe("ReplicaCache", () => { + it("persists the complete newest turn beyond the cache item target", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const items = Array.from({ length: 60 }, (_, index) => ({ + ...timelineItem(`item-${index}`), + id: `item-${index}`, + turnId: "long-run", + })); + writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items }); + await writer.flush(); + const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1"); + expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id)); + expect(restored?.range).toBeNull(); + }); + it("preserves workspace sorting timestamps across cache reopen", 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 59578e65d7..9ad604c8eb 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,3 +1,4 @@ +import { findMountedWindowStart } from "@/agent-stream/history-window"; import { z } from "zod"; import { AgentStatusSchema, @@ -766,7 +767,14 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { const canonicalItems = timeline.items.filter( (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), ); - const items = canonicalItems.map(serializeTimelineItem).filter((item) => item !== null); + const start = findMountedWindowStart({ + items: canonicalItems, + minMountedCount: MAX_TIMELINE_ITEMS, + }); + const items = canonicalItems + .slice(start) + .map(serializeTimelineItem) + .filter((item) => item !== null); const range = timeline.range; const canPersistCoverage = range !== null && @@ -783,7 +791,7 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq); return { agentId: timeline.agentId, - items: items.slice(-MAX_TIMELINE_ITEMS), + items, range: canPersistCoverage ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } : null, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index b90f5ccd24..78425bf20d 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -37,6 +37,7 @@ import { Files, Smartphone, Sparkles, + Mic, Blocks, Globe, PanelLeft, @@ -118,9 +119,14 @@ import { PluginSettingsContent } from "@/plugins/settings"; import { useInstalledPlugins } from "@/plugins/registry"; import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; +import { HostVoicePage } from "@/screens/settings/voice/voice-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; -import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { + SETTINGS_DESKTOP_DETAIL_MIN_WIDTH, + SETTINGS_DESKTOP_SIDEBAR_WIDTH, + useIsCompactFormFactor, +} from "@/constants/layout"; import { densityIconBox, densityIconSize } from "@/styles/density"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { @@ -133,7 +139,11 @@ import { type HostSectionSlug, type SettingsSectionSlug, } from "@/utils/host-routes"; -import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { + selectSettingsHost, + useSettingsHostSelection, + useSettingsSidebarScroll, +} from "@/navigation/settings-presentation"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; import { isWeb } from "@/constants/platform"; @@ -236,6 +246,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone }, { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, { id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles }, + { id: "voice", labelKey: "settings.hostSections.voice", icon: Mic }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, @@ -258,6 +269,8 @@ function renderHostSettingsContent( return ; case "metadata": return ; + case "voice": + return ; case "workspaces": return ; case "providers": @@ -945,6 +958,7 @@ function SettingsSidebar({ const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp)); const insets = useSafeAreaInsets(); const isDesktop = layout === "desktop"; + const sidebarScroll = useSettingsSidebarScroll(isDesktop); const outerContainerStyle = useMemo( () => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer], [isDesktop], @@ -1057,6 +1071,7 @@ function SettingsSidebar({ style={sidebarStyles.scrollBody} showsVerticalScrollIndicator={false} testID="settings-sidebar-scroll-body" + {...sidebarScroll} > {sidebarBody} @@ -1098,21 +1113,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); - const lastWorkspaceSelection = useLastWorkspaceSelection(); - const routedSettingsHostServerId = - view.kind === "host" || view.kind === "project" || view.kind === "plugin" - ? view.serverId - : null; - const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( - routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, - ); - useFocusEffect( - useCallback(() => { - setSelectedSettingsHostServerId( - routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, - ); - }, [lastWorkspaceSelection?.serverId, routedSettingsHostServerId]), - ); + const selectedSettingsHostServerId = useSettingsHostSelection(); // The host the four sections scope to: the host on the active view, otherwise // the picker choice, otherwise the connected local daemon, otherwise the first host. @@ -1232,7 +1233,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti // host detail route, keep that detail section and swap only the host segment. const handleSelectHost = useCallback( (serverId: string) => { - setSelectedSettingsHostServerId(serverId); + selectSettingsHost(serverId); if (view.kind === "project") { const target = buildSettingsHostSectionRoute(serverId, "projects"); if (isCompactLayout) { @@ -1471,7 +1472,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti - + {content} @@ -1543,12 +1548,14 @@ const desktopStyles = StyleSheet.create({ }, contentPane: { flex: 1, + minWidth: SETTINGS_DESKTOP_DETAIL_MIN_WIDTH, }, }); const sidebarStyles = StyleSheet.create((theme) => ({ desktopContainer: { width: SETTINGS_DESKTOP_SIDEBAR_WIDTH, + flexShrink: 1, borderRightWidth: 1, borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, diff --git a/packages/app/src/screens/settings/chat/chat-section.tsx b/packages/app/src/screens/settings/chat/chat-section.tsx index 8a81d30073..4f4254cb8e 100644 --- a/packages/app/src/screens/settings/chat/chat-section.tsx +++ b/packages/app/src/screens/settings/chat/chat-section.tsx @@ -8,29 +8,25 @@ import { SettingsSwitch, } from "@/components/settings"; import { isNative } from "@/constants/platform"; -import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; +import { useAppSettings, type ChatDisplay } from "@/hooks/use-settings"; -const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ - "detailed", - "overview", -]; +const CHAT_DISPLAYS: readonly ChatDisplay[] = ["full", "collapseToolCalls", "collapseTurns"]; export function ChatSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); - const toolCallDetailOptions = useMemo( + const chatDisplayOptions = useMemo( () => - TOOL_CALL_DETAIL_LEVELS.map((value) => ({ + CHAT_DISPLAYS.map((value) => ({ value, - label: t(`settings.general.toolCallDetail.options.${value}`), + label: t(`settings.general.chatDisplay.options.${value}`), })), [t], ); - const changeToolCallDetailLevel = useCallback( - (toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) => - void updateSettings({ toolCallDetailLevel }), + const changeChatDisplay = useCallback( + (chatDisplay: ChatDisplay) => void updateSettings({ chatDisplay }), [updateSettings], ); const changeAutoExpandReasoning = useCallback( @@ -53,11 +49,11 @@ export function ChatSection() { onValueChange={changeAutoExpandReasoning} /> {isNative ? null : ( { + let pid = 10; + const requests: Array<{ killTerminals: boolean }> = []; + const deps = { + getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }), + restartServer: async (_reason: string, options: { killTerminals: boolean }) => { + requests.push(options); + pid += 1; + }, + }; + await restartDaemonFromSettings("daemon", "settings", deps); + await restartDaemonFromSettings("daemon", "settings", deps, { killTerminals: true }); + expect(requests).toEqual([{ killTerminals: false }, { killTerminals: true }]); +}); + +test("a close-terminals restart completes when the replacement reports every terminal closed", async () => { + let pid = 10; + await restartDaemonFromSettings( + "daemon", + "settings", + { + getStatus: async () => ({ + pid, + version: "1.0.0", + serverId: "daemon", + terminalCleanupUnconfirmed: [], + }), + restartServer: async () => { + pid = 11; + }, + }, + { killTerminals: true }, + ); + expect(pid).toBe(11); +}); + +test("a close-terminals restart fails with the processes the replacement could not confirm closed", async () => { + let pid = 10; + const leftover = { + pid: 4242, + role: "terminal program", + reason: "still alive", + detail: "survived SIGKILL", + }; + const deps = { + getStatus: async () => ({ + pid, + version: "1.0.0", + serverId: "daemon", + terminalCleanupUnconfirmed: pid === 10 ? [] : [leftover], + }), + restartServer: async () => { + pid += 1; + }, + }; + + await expect( + restartDaemonFromSettings("daemon", "settings", deps, { killTerminals: true }), + ).rejects.toThrow("pid 4242 (terminal program): still alive (survived SIGKILL)"); + // Keeping terminals asks nothing of the cleanup, so a leftover does not fail it. + await restartDaemonFromSettings("daemon", "settings", deps); +}); + test("a status permission failure prevents the restart request", async () => { let restarted = false; await expect( diff --git a/packages/app/src/screens/settings/daemon-lifecycle.ts b/packages/app/src/screens/settings/daemon-lifecycle.ts index c4e9d83f5a..e848d26af8 100644 --- a/packages/app/src/screens/settings/daemon-lifecycle.ts +++ b/packages/app/src/screens/settings/daemon-lifecycle.ts @@ -1,35 +1,61 @@ +interface UnconfirmedTerminalProcess { + pid: number; + role: string; + reason: string; + detail: string; +} interface WorkerStatus { pid: number; serverId: string; version: string | null; + // Absent on daemons without terminalsSurviveRestart, which have no terminal cleanup to report. + terminalCleanupUnconfirmed?: UnconfirmedTerminalProcess[]; } interface StatusReader { getStatus: () => Promise; } export interface SettingsDaemonRestartDeps extends StatusReader { - restartServer: (reason: string) => Promise; + restartServer: (reason: string, options: { killTerminals: boolean }) => Promise; } -/** Acknowledgment starts the wait; only a different ready worker completes it. */ +/** + * Acknowledgment starts the wait; only a different ready worker completes it. Terminals survive + * the restart unless `killTerminals` is set (daemons advertising terminalsSurviveRestart); then + * the replacement must also report every terminal process gone. + */ export async function restartDaemonFromSettings( hostServerId: string, reason: string, deps: SettingsDaemonRestartDeps, + options: { killTerminals: boolean } = { killTerminals: false }, ): Promise { const previous = await readSelectedWorker(hostServerId, deps); let acknowledged = false; try { - await deps.restartServer(reason); + await deps.restartServer(reason, options); acknowledged = true; } catch (error) { if (!isReconnectFailure(error)) throw error; } + let replacement: WorkerStatus; try { - await observeReplacement(previous, deps); + replacement = await observeReplacement(previous, deps); } catch (error) { throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error }); } + const unconfirmed = replacement.terminalCleanupUnconfirmed ?? []; + if (options.killTerminals && unconfirmed.length > 0) { + throw new Error( + [ + "The daemon restarted, but closing its terminals was not confirmed; these processes may still be running:", + ...unconfirmed.map( + (entry) => `pid ${entry.pid} (${entry.role}): ${entry.reason} (${entry.detail})`, + ), + "Stopping the daemon retries the cleanup.", + ].join("\n"), + ); + } } export async function updateDaemonFromSettings( diff --git a/packages/app/src/screens/settings/general/sending-section.tsx b/packages/app/src/screens/settings/general/sending-section.tsx index 85da0fdf4e..c5d5f01760 100644 --- a/packages/app/src/screens/settings/general/sending-section.tsx +++ b/packages/app/src/screens/settings/general/sending-section.tsx @@ -1,13 +1,17 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings"; -import { useAppSettings, type SendBehavior } from "@/hooks/use-settings"; +import { useAppSettings, type SendShortcut, type SendBehavior } from "@/hooks/use-settings"; +import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { getShortcutOs } from "@/utils/shortcut-platform"; const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"]; export function SendingSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); + const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const modifier = getShortcutOs() === "mac" ? "⌘" : "Ctrl"; const options = useMemo( () => SEND_BEHAVIORS.map((value) => ({ @@ -20,12 +24,37 @@ export function SendingSection() { (sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }), [updateSettings], ); + const shortcutOptions = useMemo( + () => + (["enter", "mod-enter"] as const).map((value) => ({ + value, + label: t(`settings.general.sendShortcut.options.${value}`, { modifier }), + })), + [t, modifier], + ); + const changeSendShortcut = useCallback( + (sendShortcut: SendShortcut) => void updateSettings({ sendShortcut }), + [updateSettings], + ); return ( + {shortcutsAvailable ? ( + + ) : null} + + ) : ( @@ -563,7 +567,15 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { const isConnected = useHostRuntimeIsConnected(host.serverId); const runtime = getHostRuntimeStore(); const [isRestarting, setIsRestarting] = useState(false); + // Rendered in the card, not an Alert.alert: react-native-web's Alert is a no-op, and a failed + // close-terminals restart must name the processes left running until the user retries. + const [restartError, setRestartError] = useState(null); const isMountedRef = useRef(true); + // COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21. + const terminalsSurviveRestart = useSessionStore( + (state) => + state.sessions[host.serverId]?.serverInfo?.features?.terminalsSurviveRestart === true, + ); useEffect(() => { return () => { @@ -576,71 +588,80 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { [host.serverId, runtime], ); - const waitForDaemonRestart = useCallback( - async (request: Promise) => { - try { - await request; - } catch (error) { - Alert.alert( - t("settings.host.daemon.restart.requestFailedTitle"), - error instanceof Error ? error.message : String(error), - ); - } finally { - if (isMountedRef.current) setIsRestarting(false); + const waitForDaemonRestart = useCallback(async (request: Promise) => { + try { + await request; + } catch (error) { + console.error("[HostPage] Daemon restart failed", error); + if (isMountedRef.current) { + setRestartError(error instanceof Error ? error.message : String(error)); } + } finally { + if (isMountedRef.current) setIsRestarting(false); + } + }, []); + + const startRestart = useCallback( + (killTerminals: boolean) => { + if (!daemonClient) { + Alert.alert( + t("settings.host.daemon.restart.unavailableTitle"), + t("settings.host.daemon.restart.unavailableMessage"), + ); + return; + } + if (!isHostConnected()) { + Alert.alert( + t("settings.host.daemon.restart.offlineTitle"), + t("settings.host.daemon.restart.offlineMessage"), + ); + return; + } + + void confirmDialog({ + title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }), + message: killTerminals + ? t("settings.host.daemon.restart.closeTerminalsConfirmMessage") + : t("settings.host.daemon.restart.confirmMessage"), + confirmLabel: killTerminals + ? t("settings.host.daemon.restart.closeTerminals") + : t("settings.host.daemon.restart.confirm"), + cancelLabel: t("common.actions.cancel"), + destructive: true, + }) + .then((confirmed) => { + if (!confirmed) return; + setRestartError(null); + setIsRestarting(true); + const restartRequest = restartDaemonFromSettings( + host.serverId, + `settings_daemon_restart_${host.serverId}`, + { + restartServer: (reason, options) => + daemonClient.restartServer(reason, undefined, options), + getStatus: async () => ({ + ...(await daemonClient.getDaemonStatus()), + serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "", + version: daemonClient.getLastServerInfoMessage()?.version ?? null, + }), + }, + { killTerminals }, + ); + void waitForDaemonRestart(restartRequest); + return; + }) + .catch((error) => { + console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error); + if (isMountedRef.current) { + setRestartError(t("settings.host.daemon.restart.dialogFailedMessage")); + } + }); }, - [t], + [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart], ); - const handleRestart = useCallback(() => { - if (!daemonClient) { - Alert.alert( - t("settings.host.daemon.restart.unavailableTitle"), - t("settings.host.daemon.restart.unavailableMessage"), - ); - return; - } - if (!isHostConnected()) { - Alert.alert( - t("settings.host.daemon.restart.offlineTitle"), - t("settings.host.daemon.restart.offlineMessage"), - ); - return; - } - - void confirmDialog({ - title: t("settings.host.daemon.restart.confirmTitle", { name: host.label }), - message: t("settings.host.daemon.restart.confirmMessage"), - confirmLabel: t("settings.host.daemon.restart.confirm"), - cancelLabel: t("common.actions.cancel"), - destructive: true, - }) - .then((confirmed) => { - if (!confirmed) return; - setIsRestarting(true); - const restartRequest = restartDaemonFromSettings( - host.serverId, - `settings_daemon_restart_${host.serverId}`, - { - restartServer: (reason) => daemonClient.restartServer(reason), - getStatus: async () => ({ - ...(await daemonClient.getDaemonStatus()), - serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "", - version: daemonClient.getLastServerInfoMessage()?.version ?? null, - }), - }, - ); - void waitForDaemonRestart(restartRequest); - return; - }) - .catch((error) => { - console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error); - Alert.alert( - t("settings.host.daemon.restart.requestFailedTitle"), - t("settings.host.daemon.restart.dialogFailedMessage"), - ); - }); - }, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]); + const handleRestart = useCallback(() => startRestart(false), [startRestart]); + const handleRestartAndCloseTerminals = useCallback(() => startRestart(true), [startRestart]); return ( @@ -662,6 +683,35 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { : t("settings.host.daemon.restart.confirm")} + {terminalsSurviveRestart ? ( + + + + {t("settings.host.daemon.restart.closeTerminalsHint")} + + + + + ) : null} + {restartError ? ( + + + + ) : null} ); } @@ -846,7 +896,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { /> ) : null} {updateState.status === "failed" ? ( - + ({ })); const styles = StyleSheet.create((theme) => ({ - updateFailure: { + cardAlert: { marginHorizontal: theme.spacing[4], marginBottom: theme.spacing[4], }, diff --git a/packages/app/src/screens/settings/idle-runtime-cleanup-card.tsx b/packages/app/src/screens/settings/idle-runtime-cleanup-card.tsx new file mode 100644 index 0000000000..4d6fc355e8 --- /dev/null +++ b/packages/app/src/screens/settings/idle-runtime-cleanup-card.tsx @@ -0,0 +1,78 @@ +import { useCallback } from "react"; +import { Text, View } from "react-native"; +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip"; +import { Switch } from "@/components/ui/switch"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { settingsStyles } from "@/styles/settings"; + +// Host-wide, persisted, and applied live by the daemon. +export function IdleRuntimeCleanupCard({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useHostFeature(serverId, "idleRuntimeCleanupSetting"); + const { config, patchConfig } = useDaemonConfig(serverId); + const mutation = useMutation({ + mutationFn: async (next: boolean) => { + const result = await patchConfig({ idleRuntimeCleanup: next }); + if (!result) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + return result; + }, + }); + + const handleValueChange = useCallback( + (next: boolean) => { + mutation.mutate(next); + }, + [mutation], + ); + + if (!isConnected || !isSupported || !config) return null; + + return ( + + + + + + {t("settings.host.agents.idleRuntimeCleanup.title")} + + + + + {t("settings.host.agents.idleRuntimeCleanup.hint")} + + {mutation.error ? ( + + {mutation.error.message} + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/screens/settings/restart-recovery-card.tsx b/packages/app/src/screens/settings/restart-recovery-card.tsx new file mode 100644 index 0000000000..9fa8a872b0 --- /dev/null +++ b/packages/app/src/screens/settings/restart-recovery-card.tsx @@ -0,0 +1,199 @@ +import { useCallback, useMemo, useState } from "react"; +import { Text, View } from "react-native"; +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { SettingsTextAreaCard } from "@/components/settings-textarea"; +import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { settingsStyles } from "@/styles/settings"; + +const DEFAULT_RESUME_PROMPT = "Resume working."; + +// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28. +export function RestartRecoveryCard({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useHostFeature(serverId, "resumeOnStartupSetting"); + const { config, patchConfig } = useDaemonConfig(serverId); + const [isEditing, setIsEditing] = useState(false); + const mutation = useMutation({ + mutationFn: async (next: { resumeOnStartup?: boolean; resumePrompt?: string }) => { + const result = await patchConfig(next); + if (!result) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + return result; + }, + }); + + const handleValueChange = useCallback( + (next: boolean) => { + mutation.mutate({ resumeOnStartup: next }); + }, + [mutation], + ); + + const handleOpen = useCallback(() => { + mutation.reset(); + setIsEditing(true); + }, [mutation]); + + const handleClose = useCallback(() => { + if (mutation.isPending) return; + mutation.reset(); + setIsEditing(false); + }, [mutation]); + + const handleSave = useCallback( + (prompt: string) => { + mutation.mutate({ resumePrompt: prompt }, { onSuccess: () => setIsEditing(false) }); + }, + [mutation], + ); + + if (!isConnected || !isSupported || !config) return null; + + const isEnabled = config.resumeOnStartup !== false; + + return ( + <> + + + + + + {t("settings.host.agents.resumeOnStartup.title")} + + + + + {t("settings.host.agents.resumeOnStartup.hint")} + + {mutation.error && !isEditing ? ( + + {mutation.error.message} + + ) : null} + + + + + + + + + {isEditing ? ( + + ) : null} + + ); +} + +function ResumePromptSheet({ + initialPrompt, + isSaving, + error, + onSave, + onClose, +}: { + initialPrompt: string; + isSaving: boolean; + error: string | null; + onSave: (prompt: string) => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(initialPrompt); + const header = useMemo( + () => ({ title: t("settings.host.agents.resumeOnStartup.prompt") }), + [t], + ); + const canSave = draft !== initialPrompt && draft.trim().length > 0 && !isSaving; + + const handleSave = useCallback(() => onSave(draft), [draft, onSave]); + + return ( + + + {error ? ( + + {error} + + ) : null} + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + sheetActions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/screens/settings/voice/speech-provider-form.test.ts b/packages/app/src/screens/settings/voice/speech-provider-form.test.ts new file mode 100644 index 0000000000..db88fc9bdd --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-form.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { openSpeechProviderForm, type SpeechProviderFormSnapshot } from "./speech-provider-form"; + +function open(overrides: Partial = {}) { + return openSpeechProviderForm({ + siteId: "voiceMode.stt", + site: { provider: "local" }, + envOverrides: {}, + ...overrides, + }); +} + +async function savedPatch( + model: ReturnType, +): Promise { + let sent: SpeechSettingsPatch | undefined; + const result = await model.save(async (patch) => { + sent = patch; + }); + expect(result).toEqual({ status: "saved" }); + return sent; +} + +describe("speech provider form", () => { + it("edits and clears local STT language from the saved snapshot", async () => { + const form = open({ site: { provider: "local", language: "fr" } }); + expect(form.getState()).toMatchObject({ language: "fr", canSave: false }); + form.setLanguage(" de "); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: "local", language: "de" } }, + }); + const clear = open({ site: { provider: "local", language: "fr" } }); + clear.setLanguage(""); + expect(await savedPatch(clear)).toEqual({ + voiceMode: { stt: { provider: "local", language: null } }, + }); + }); + + it("keeps STT language when switching either provider", async () => { + for (const provider of ["local", "openai"] as const) { + const form = open({ + site: + provider === "local" + ? { provider, language: "fr" } + : { provider, hasApiKey: false, language: "fr" }, + }); + form.setProvider(provider === "local" ? "openai" : "local"); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: provider === "local" ? "openai" : "local", language: "fr" } }, + }); + } + }); + + it("keeps env-controlled language read-only after switching providers", async () => { + const form = open({ + site: { provider: "local", language: "fr" }, + envOverrides: { "features.voiceMode.stt.language": "PASEO_VOICE_LANGUAGE" }, + }); + form.setProvider("openai"); + expect(form.getState().fields.language).toEqual({ + envVar: "PASEO_VOICE_LANGUAGE", + readOnly: true, + }); + expect(await savedPatch(form)).toEqual({ voiceMode: { stt: { provider: "openai" } } }); + }); + + it("switches a site to OpenAI with a key and base URL", async () => { + const form = open(); + expect(form.getState().canSave).toBe(false); + + form.setProvider("openai"); + form.setApiKey(" sk-test "); + form.setBaseUrl("http://127.0.0.1:9999/v1"); + + expect(await savedPatch(form)).toEqual({ + voiceMode: { + stt: { provider: "openai", apiKey: "sk-test", baseUrl: "http://127.0.0.1:9999/v1" }, + }, + }); + }); + + it("re-saves OpenAI settings without touching the saved key", async () => { + const form = open({ + siteId: "dictation.stt", + site: { provider: "openai", hasApiKey: true, baseUrl: "https://a.example/v1" }, + }); + expect(form.getState()).toMatchObject({ apiKey: "", hasSavedApiKey: true, canSave: false }); + + form.setOpenAiModel("gpt-4o-transcribe"); + form.setBaseUrl(""); + + expect(await savedPatch(form)).toEqual({ + dictation: { stt: { provider: "openai", model: "gpt-4o-transcribe", baseUrl: null } }, + }); + }); + + it("selects a local model", async () => { + const form = open({ + siteId: "dictation.stt", + site: { provider: "local", model: "whisper-tiny" }, + }); + form.setLocalModel("whisper-tiny"); + expect(form.getState().canSave).toBe(false); + + form.setLocalModel("whisper-base"); + + expect(await savedPatch(form)).toEqual({ + dictation: { stt: { provider: "local", model: "whisper-base" } }, + }); + }); + + it("locks fields an env var decides and keeps fallback fields editable", async () => { + const form = open({ + site: { provider: "openai", hasApiKey: false }, + envOverrides: { + "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER", + "features.voiceMode.stt.model": "STT_MODEL", + "features.voiceMode.stt.apiKey": "OPENAI_API_KEY", + }, + }); + expect(form.getState().fields).toMatchObject({ + provider: { envVar: "PASEO_VOICE_STT_PROVIDER", readOnly: true }, + model: { envVar: "STT_MODEL", readOnly: true }, + apiKey: { envVar: "OPENAI_API_KEY", readOnly: false }, + baseUrl: { envVar: null, readOnly: false }, + }); + + form.setProvider("local"); + form.setOpenAiModel("gpt-4o-transcribe"); + form.setApiKey("sk-saved"); + + expect(form.getState().provider).toBe("openai"); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: "openai", apiKey: "sk-saved" } }, + }); + }); + + it("does not apply the saved provider's env vars to another provider", () => { + const form = open({ + envOverrides: { "features.voiceMode.stt.model": "PASEO_VOICE_LOCAL_STT_MODEL" }, + }); + expect(form.getState().fields.model.readOnly).toBe(true); + + form.setProvider("openai"); + + expect(form.getState().fields.model).toEqual({ envVar: null, readOnly: false }); + }); + + it("keeps the input and allows a retry after a failed save", async () => { + const form = open({ siteId: "voiceMode.tts" }); + form.setProvider("openai"); + form.setApiKey("sk-test"); + form.setVoice("nova"); + const failure = new Error("disk full"); + + const result = await form.save(async () => { + expect(form.getState()).toMatchObject({ saving: true, canSave: false }); + throw failure; + }); + + expect(result).toEqual({ status: "failed", error: failure }); + expect(form.getState()).toMatchObject({ + provider: "openai", + apiKey: "sk-test", + voice: "nova", + saving: false, + canSave: true, + }); + expect(await savedPatch(form)).toEqual({ + voiceMode: { tts: { provider: "openai", apiKey: "sk-test", voice: "nova" } }, + }); + }); +}); diff --git a/packages/app/src/screens/settings/voice/speech-provider-form.ts b/packages/app/src/screens/settings/voice/speech-provider-form.ts new file mode 100644 index 0000000000..b8230104e5 --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-form.ts @@ -0,0 +1,240 @@ +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + SPEECH_SITE_KIND, + buildSpeechSitePatch, + resolveOpenAiModel, + resolveOpenAiVoice, + speechSiteFieldPath, + type SpeechModelKind, + type SpeechProvider, + type SpeechSite, + type SpeechSiteId, + type SpeechSitePatch, +} from "./voice-settings"; + +// The provider sheet's form: one speech site, its provider and that provider's fields. +// Plain TypeScript so the save rules are testable without rendering (docs/forms.md). + +export interface SpeechProviderFormSnapshot { + siteId: SpeechSiteId; + site: Extract; + envOverrides: Record; +} + +export type SpeechProviderField = + | "provider" + | "model" + | "apiKey" + | "baseUrl" + | "language" + | "voice"; + +// An env var set for one of these is only a fallback: a value saved in the sheet wins, +// so the field stays editable. Every other field is decided by its env var. +const FALLBACK_FIELDS: ReadonlySet = new Set(["apiKey", "baseUrl"]); + +export interface SpeechProviderFieldSource { + /** The env var that decides this field's effective value, when one does. */ + envVar: string | null; + readOnly: boolean; +} + +export interface SpeechProviderFormState { + kind: SpeechModelKind; + provider: SpeechProvider; + /** The local model the user picked in this sheet; null keeps the saved or default model. */ + localModel: string | null; + savedLocalModel: string | undefined; + apiKey: string; + hasSavedApiKey: boolean; + baseUrl: string; + openAiModel: string; + language: string; + voice: string; + fields: Record; + saving: boolean; + canSave: boolean; +} + +export type SpeechProviderSaveResult = + | { status: "saved" } + | { status: "skipped" } + | { status: "failed"; error: unknown }; + +export interface SpeechProviderFormModel { + getState: () => SpeechProviderFormState; + subscribe: (listener: () => void) => () => void; + close: () => void; + setProvider: (provider: SpeechProvider) => void; + setLocalModel: (modelId: string) => void; + setApiKey: (value: string) => void; + setBaseUrl: (value: string) => void; + setOpenAiModel: (value: string) => void; + setLanguage: (value: string) => void; + setVoice: (value: string) => void; + /** Sends the changed fields through `save`; the inputs survive a failed save. */ + save: ( + save: (patch: SpeechSettingsPatch) => Promise, + ) => Promise; +} + +interface OpenAiValues { + baseUrl: string; + openAiModel: string; + voice: string; +} + +export function openSpeechProviderForm( + snapshot: SpeechProviderFormSnapshot, +): SpeechProviderFormModel { + const { siteId, site, envOverrides } = snapshot; + const kind = SPEECH_SITE_KIND[siteId]; + const savedOpenAi = site.provider === "openai" ? site : undefined; + const initialOpenAi: OpenAiValues = { + baseUrl: savedOpenAi?.baseUrl ?? "", + openAiModel: resolveOpenAiModel(kind, savedOpenAi?.model), + voice: resolveOpenAiVoice(savedOpenAi?.voice), + }; + const initialLanguage = site.language ?? ""; + // The daemon reports env vars for the provider that is in effect, so they only describe the + // provider-specific fields while that provider is selected. Language applies to either STT provider. + const savedFields = buildFieldSources(siteId, envOverrides); + const switchedFields = { + ...buildFieldSources(siteId, {}), + provider: savedFields.provider, + language: savedFields.language, + }; + const listeners = new Set<() => void>(); + let closed = false; + + let state: SpeechProviderFormState = derive({ + kind, + provider: site.provider, + localModel: null, + savedLocalModel: site.provider === "local" ? site.model : undefined, + apiKey: "", + hasSavedApiKey: savedOpenAi?.hasApiKey ?? false, + ...initialOpenAi, + language: initialLanguage, + fields: savedFields, + saving: false, + canSave: false, + }); + + function derive(next: SpeechProviderFormState): SpeechProviderFormState { + return { + ...next, + fields: next.provider === site.provider ? savedFields : switchedFields, + canSave: !next.saving && isChanged(next), + }; + } + + function isChanged(next: SpeechProviderFormState): boolean { + if (next.provider !== site.provider) return true; + if (kind === "stt" && next.language.trim() !== initialLanguage) return true; + if (next.provider === "local") { + return next.localModel !== null && next.localModel !== next.savedLocalModel; + } + return next.apiKey.trim().length > 0 || changedOpenAiFields(next).length > 0; + } + + function changedOpenAiFields(next: SpeechProviderFormState): (keyof OpenAiValues)[] { + const keys: (keyof OpenAiValues)[] = + kind === "stt" ? ["baseUrl", "openAiModel"] : ["baseUrl", "openAiModel", "voice"]; + return keys.filter((key) => next[key].trim() !== initialOpenAi[key]); + } + + function buildSitePatch(next: SpeechProviderFormState): SpeechSitePatch { + const language = next.language.trim(); + const languagePatch: Record = + kind === "stt" && + !next.fields.language.readOnly && + (language !== initialLanguage || (next.provider !== site.provider && language.length > 0)) + ? { language: language || null } + : {}; + if (next.provider === "local") { + return { + provider: "local", + ...languagePatch, + ...(next.localModel !== null && !next.fields.model.readOnly + ? { model: next.localModel } + : {}), + }; + } + const patch: Record = { provider: "openai", ...languagePatch }; + const apiKey = next.apiKey.trim(); + if (apiKey) patch.apiKey = apiKey; + for (const key of changedOpenAiFields(next)) { + const field = key === "openAiModel" ? "model" : key; + if (next.fields[field].readOnly) continue; + patch[field] = next[key].trim() || null; + } + return patch as SpeechSitePatch; + } + + function publish(next: SpeechProviderFormState): void { + if (closed) return; + state = derive(next); + for (const listener of listeners) listener(); + } + + function edit(change: Partial): void { + if (state.saving) return; + publish({ ...state, ...change }); + } + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + close() { + closed = true; + listeners.clear(); + }, + setProvider: (provider) => { + if (!state.fields.provider.readOnly) edit({ provider }); + }, + setLocalModel: (localModel) => { + if (!state.fields.model.readOnly) edit({ localModel }); + }, + setApiKey: (apiKey) => edit({ apiKey }), + setBaseUrl: (baseUrl) => edit({ baseUrl }), + setOpenAiModel: (openAiModel) => edit({ openAiModel }), + setLanguage: (language) => edit({ language }), + setVoice: (voice) => edit({ voice }), + async save(save) { + if (!state.canSave) return { status: "skipped" }; + const patch = buildSpeechSitePatch(siteId, buildSitePatch(state)); + publish({ ...state, saving: true }); + try { + await save(patch); + return { status: "saved" }; + } catch (error) { + publish({ ...state, saving: false }); + return { status: "failed", error }; + } + }, + }; +} + +function buildFieldSources( + siteId: SpeechSiteId, + envOverrides: Record, +): Record { + const source = (field: SpeechProviderField): SpeechProviderFieldSource => { + const envVar = envOverrides[speechSiteFieldPath(siteId, field)] ?? null; + return { envVar, readOnly: envVar !== null && !FALLBACK_FIELDS.has(field) }; + }; + return { + provider: source("provider"), + model: source("model"), + apiKey: source("apiKey"), + baseUrl: source("baseUrl"), + language: source("language"), + voice: source("voice"), + }; +} diff --git a/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx b/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx new file mode 100644 index 0000000000..7c842053b3 --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx @@ -0,0 +1,585 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check, Download, Languages, Trash2 } from "lucide-react-native"; +import type { LocalSpeechModel, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { + SettingsCard, + SettingsInfoTip, + SettingsInput, + SettingsRow, + SettingsSelect, +} from "@/components/settings"; +import { Button } from "@/components/ui/button"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; +import { foregroundIconColorMapping } from "@/components/ui/icon-color"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-api-context"; +import { ICON_SIZE } from "@/styles/theme"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import type { LocalSpeechModels } from "./use-local-speech-models"; +import { + openSpeechProviderForm, + type SpeechProviderFieldSource, + type SpeechProviderFormModel, + type SpeechProviderFormSnapshot, + type SpeechProviderFormState, +} from "./speech-provider-form"; +import { + SPEECH_PROVIDERS, + buildOpenAiModelOptions, + buildOpenAiVoiceOptions, + formatLanguages, + formatModelSize, + getLocalModelState, + resolveLocalModel, + type LocalModelState, + type SpeechProvider, +} from "./voice-settings"; + +const ThemedCheck = withUnistyles(Check); + +export interface SpeechProviderSheetProps extends SpeechProviderFormSnapshot { + title: string; + localModels: LocalSpeechModels; + visible: boolean; + onClose: () => void; + onDismiss: () => void; + save: (patch: SpeechSettingsPatch) => Promise; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// The form opens from the props of the first render; later config changes do not reset input. +function useSpeechProviderForm(snapshot: SpeechProviderFormSnapshot): SpeechProviderFormModel { + const [model] = useState(() => openSpeechProviderForm(snapshot)); + useEffect(() => () => model.close(), [model]); + return model; +} + +/** One speech site's provider and that provider's settings. Mount it fresh for each open. */ +export function SpeechProviderSheet({ + title, + siteId, + site, + envOverrides, + localModels, + visible, + onClose, + onDismiss, + save, +}: SpeechProviderSheetProps) { + const { t } = useTranslation(); + const toast = useToast(); + const model = useSpeechProviderForm({ siteId, site, envOverrides }); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + + const submit = useCallback(() => { + void model.save(save).then((result) => { + if (result.status === "saved") onClose(); + if (result.status === "failed") { + toast.error(`${t("settings.voice.saveError")}: ${errorMessage(result.error)}`); + } + return undefined; + }); + }, [model, onClose, save, t, toast]); + + const header = useMemo(() => ({ title }), [title]); + const footer = useMemo( + () => ( + + + + + ), + [onClose, state.canSave, state.saving, submit, t], + ); + + return ( + + + + + {state.provider === "local" ? ( + <> + {state.kind === "stt" ? ( + + + + ) : null} + + + ) : ( + + )} + + ); +} + +interface FormProps { + state: SpeechProviderFormState; + model: SpeechProviderFormModel; +} + +function setByHint(t: TFunction, source: SpeechProviderFieldSource): string | undefined { + return source.envVar ? t("settings.voice.setBy", { name: source.envVar }) : undefined; +} + +function ProviderRow({ state, model }: FormProps) { + const { t } = useTranslation(); + const source = state.fields.provider; + const options = useMemo( + () => + SPEECH_PROVIDERS.map((provider) => ({ + value: provider, + label: t(`settings.voice.providers.${provider}`), + disabled: source.readOnly, + })), + [source.readOnly, t], + ); + return ( + + + options={options} + value={state.provider} + onValueChange={model.setProvider} + testID="voice-provider-picker" + /> + + ); +} + +function LanguageField({ state, model }: FormProps) { + const { t } = useTranslation(); + return ( + + ); +} + +// --------------------------------------------------------------------------- +// OpenAI +// --------------------------------------------------------------------------- + +function OpenAiFields({ state, model }: FormProps) { + const { t } = useTranslation(); + const modelOptions = useMemo( + () => buildOpenAiModelOptions(state.kind, state.openAiModel), + [state.kind, state.openAiModel], + ); + const voiceOptions = useMemo(() => buildOpenAiVoiceOptions(state.voice), [state.voice]); + const apiKeyInfo = useMemo( + () => ( + + ), + [t], + ); + const baseUrlInfo = useMemo( + () => ( + + ), + [t], + ); + + return ( + + + + + {state.kind === "stt" ? ( + + ) : ( + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Local: the catalog, with the selected model checked +// --------------------------------------------------------------------------- + +function LocalModelList({ + state, + model, + localModels, +}: FormProps & { localModels: LocalSpeechModels }) { + const { t } = useTranslation(); + const { models, isLoading, error, refresh, download, remove } = localModels; + const kindModels = useMemo( + () => models.filter((entry) => entry.kind === state.kind), + [models, state.kind], + ); + const selectedId = + state.localModel ?? + resolveLocalModel({ kind: state.kind, model: state.savedLocalModel, localModels: models })?.id; + const source = state.fields.model; + + if (kindModels.length === 0) { + if (error) { + return ( + + + + + + ); + } + return ( + + + + ); + } + return ( + + {kindModels.map((entry) => ( + + ))} + + ); +} + +function progressLabel( + t: TFunction, + state: Extract, +): string { + if (state.kind === "extracting") return t("settings.voice.models.extracting"); + if (state.percent === null) return t("settings.voice.models.downloading"); + return t("settings.voice.models.downloadingPercent", { percent: state.percent }); +} + +type HoverState = PressableStateCallbackType & { hovered?: boolean }; + +function LocalModelRow({ + model, + selected, + selectionLocked, + hint, + onSelect, + onDownload, + onDelete, +}: { + model: LocalSpeechModel; + selected: boolean; + selectionLocked: boolean; + /** Why the selection is fixed, shown on the selected row. */ + hint: string | undefined; + onSelect: (modelId: string) => void; + onDownload: (modelId: string) => Promise; + onDelete: (modelId: string) => Promise; +}) { + const { t, i18n } = useTranslation(); + const state = getLocalModelState(model); + const [actionError, setActionError] = useState(null); + + const select = useCallback(() => onSelect(model.id), [model.id, onSelect]); + const download = useCallback(() => { + setActionError(null); + onDownload(model.id).catch((error: unknown) => setActionError(errorMessage(error))); + }, [model.id, onDownload]); + const remove = useCallback(() => { + void confirmDialog({ + title: t("settings.voice.models.deleteTitle", { label: model.label }), + message: t("settings.voice.models.deleteMessage", { + size: formatModelSize(model.downloadSizeBytes), + }), + confirmLabel: t("settings.voice.models.delete"), + destructive: true, + }) + .then((confirmed) => { + if (!confirmed) return undefined; + setActionError(null); + return onDelete(model.id); + }) + .catch((error: unknown) => setActionError(errorMessage(error))); + }, [model.downloadSizeBytes, model.id, model.label, onDelete, t]); + + const languages = formatLanguages({ + languages: model.languages, + locale: i18n.language, + formatMore: (listed, count) => + t("settings.voice.models.moreLanguages", { languages: listed, count }), + }); + const details = useMemo( + () => ( + + + + {formatModelSize(model.downloadSizeBytes)} + {state.kind === "installed" ? ( + + ) : null} + {hint ? {hint} : null} + + ), + [ + hint, + languages, + model.description, + model.downloadSizeBytes, + model.id, + model.inUse, + model.label, + state.kind, + t, + ], + ); + + const recommended = useMemo( + () => + model.isDefault ? ( + + ) : undefined, + [model.isDefault, t], + ); + + // Phone rows have no room beside the label for a labelled button, so actions show their icon. + const compact = useIsCompactFormFactor(); + const downloadLabel = + state.kind === "failed" ? t("settings.voice.retry") : t("settings.voice.models.download"); + let control = null; + if (state.kind === "downloading" || state.kind === "extracting") { + control = ( + + + {progressLabel(t, state)} + + ); + } else if (state.kind === "installed") { + control = model.inUse ? null : ( + + ); + } else { + control = ( + + ); + } + + const rowStyle = useCallback( + ({ hovered, pressed }: HoverState) => [ + !selectionLocked && hovered && styles.rowHovered, + !selectionLocked && pressed && styles.rowPressed, + ], + [selectionLocked], + ); + const accessibilityState = useMemo( + () => ({ checked: selected, disabled: selectionLocked }), + [selected, selectionLocked], + ); + + return ( + + + + {control} + + {selected ? ( + + ) : null} + + + + + ); +} + +// Every catalog row is as tall as one holding a button, so a row does not shrink when its +// download finishes and the button goes away. +function TrailingControls({ children }: { children: ReactNode }) { + const size = useContainedControlSize() ?? "sm"; + const style = useMemo(() => [styles.trailing, { minHeight: buttonControlHeight[size] }], [size]); + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + footer: { + flex: 1, + flexDirection: "row", + gap: theme.spacing[3], + }, + footerButton: { + flex: 1, + }, + rowHovered: { + backgroundColor: theme.colors.surface2, + }, + rowPressed: { + backgroundColor: theme.colors.surface3, + }, + // The model's details sit on the hint line, so every catalog row has the same two lines. + details: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: theme.spacing[2], + marginTop: theme.spacing[1], + }, + metaText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontVariant: ["tabular-nums"], + }, + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + checkSlot: { + width: ICON_SIZE.sm, + alignItems: "center", + }, + progress: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/settings/voice/use-local-speech-models.ts b/packages/app/src/screens/settings/voice/use-local-speech-models.ts new file mode 100644 index 0000000000..b68a7b8135 --- /dev/null +++ b/packages/app/src/screens/settings/voice/use-local-speech-models.ts @@ -0,0 +1,71 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; +import { useFetchQuery } from "@/data/query"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { isLocalModelBusy } from "./voice-settings"; + +const DOWNLOAD_POLL_INTERVAL_MS = 1000; + +const localSpeechModelsQueryKey = (serverId: string) => ["speech-local-models", serverId] as const; + +export interface LocalSpeechModels { + models: LocalSpeechModel[]; + isLoading: boolean; + error: Error | null; + refresh: () => void; + download: (modelId: string) => Promise; + remove: (modelId: string) => Promise; +} + +// The daemon reports download progress through the list, so the list polls while any +// model is downloading or extracting. +export function useLocalSpeechModels(serverId: string): LocalSpeechModels { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const query = useFetchQuery({ + queryKey: localSpeechModelsQueryKey(serverId), + enabled: Boolean(client && isConnected), + dataShape: "list", + staleTimeMs: 0, + queryFn: () => { + if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); + return client.listLocalSpeechModels(); + }, + refetchInterval: (current) => + current.state.data?.some(isLocalModelBusy) ? DOWNLOAD_POLL_INTERVAL_MS : false, + }); + const { refetch } = query; + + const refresh = useCallback(() => { + void refetch(); + }, [refetch]); + + const download = useCallback( + async (modelId: string) => { + if (!client) return; + await client.downloadLocalSpeechModel(modelId); + await refetch(); + }, + [client, refetch], + ); + + const remove = useCallback( + async (modelId: string) => { + if (!client) return; + await client.deleteLocalSpeechModel(modelId); + await refetch(); + }, + [client, refetch], + ); + + return { + models: query.data ?? [], + isLoading: query.isLoading, + error: query.error, + refresh, + download, + remove, + }; +} diff --git a/packages/app/src/screens/settings/voice/voice-page.tsx b/packages/app/src/screens/settings/voice/voice-page.tsx new file mode 100644 index 0000000000..b8b6402b76 --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-page.tsx @@ -0,0 +1,252 @@ +import { useCallback, useState } from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { StyleSheet } from "react-native-unistyles"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + SettingsCard, + SettingsDetailRow, + SettingsRow, + SettingsSection, +} from "@/components/settings"; +import { Alert } from "@/components/ui/alert"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/contexts/toast-api-context"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { SpeechProviderSheet } from "./speech-provider-sheet"; +import { useLocalSpeechModels } from "./use-local-speech-models"; +import { + getSpeechSite, + summarizeSpeechSite, + type SpeechSiteId, + type SpeechSiteSummary, +} from "./voice-settings"; + +type SavePatch = (patch: SpeechSettingsPatch) => Promise; +const EMPTY_ENV_OVERRIDES: Record = {}; + +// The sheet mounts fresh for every open (docs/forms.md); `openCount` keys each mount. +interface OpenSheet { + siteId: SpeechSiteId; + visible: boolean; + openCount: number; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function HostVoicePage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const toast = useToast(); + const isConnected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "speechSettings"); + const { config, patchConfig } = useDaemonConfig(serverId); + const localModels = useLocalSpeechModels(serverId); + const { refresh: refreshModels } = localModels; + const [sheet, setSheet] = useState(null); + + // Selecting a model changes which models are in use, so the catalog is re-read after a save. + const save = useCallback( + async (patch) => { + await patchConfig({ features: patch }); + refreshModels(); + }, + [patchConfig, refreshModels], + ); + const saveSwitch = useCallback( + (patch: SpeechSettingsPatch) => { + save(patch).catch((error: unknown) => { + toast.error(`${t("settings.voice.saveError")}: ${errorMessage(error)}`); + }); + }, + [save, t, toast], + ); + const setDictationEnabled = useCallback( + (enabled: boolean) => saveSwitch({ dictation: { enabled } }), + [saveSwitch], + ); + const setVoiceModeEnabled = useCallback( + (enabled: boolean) => saveSwitch({ voiceMode: { enabled } }), + [saveSwitch], + ); + const openSheet = useCallback((siteId: SpeechSiteId) => { + setSheet((current) => ({ + siteId, + visible: true, + openCount: (current?.openCount ?? 0) + 1, + })); + }, []); + const closeSheet = useCallback(() => { + setSheet((current) => (current ? { ...current, visible: false } : null)); + }, []); + const dismissSheet = useCallback(() => setSheet(null), []); + + if (!isConnected) { + return ; + } + if (!supported) { + return ; + } + const features = config?.features; + if (!features) { + return ( + + + + ); + } + const envOverrides = config?.envOverrides ?? EMPTY_ENV_OVERRIDES; + const siteRow = (siteId: SpeechSiteId, label: string) => ( + + ); + const sheetSite = sheet ? getSpeechSite(features, sheet.siteId) : null; + + return ( + + + + + {siteRow("dictation.stt", t("settings.voice.sites.dictationStt"))} + + + + + + {siteRow("voiceMode.stt", t("settings.voice.sites.voiceModeStt"))} + {siteRow("voiceMode.tts", t("settings.voice.sites.voiceModeTts"))} + + + {sheet && sheetSite && sheetSite.provider !== "unknown" ? ( + + ) : null} + + ); +} + +function siteLabel(t: TFunction, siteId: SpeechSiteId): string { + switch (siteId) { + case "dictation.stt": + return t("settings.voice.sites.dictationStt"); + case "voiceMode.stt": + return t("settings.voice.sites.voiceModeStt"); + case "voiceMode.tts": + return t("settings.voice.sites.voiceModeTts"); + } +} + +function FeatureSwitchRow({ + label, + hint, + envVar, + value, + onChange, +}: { + label: string; + hint: string; + envVar: string | undefined; + value: boolean; + onChange: (value: boolean) => void; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} + +function SpeechSiteRow({ + siteId, + label, + summary, + onOpen, +}: { + siteId: SpeechSiteId; + label: string; + summary: SpeechSiteSummary; + onOpen: (siteId: SpeechSiteId) => void; +}) { + const { t } = useTranslation(); + const open = useCallback(() => onOpen(siteId), [onOpen, siteId]); + return ( + + ); +} + +function summaryLabel(t: TFunction, summary: SpeechSiteSummary): string { + switch (summary.kind) { + case "local": + return summary.model + ? `${t("settings.voice.providers.local")} · ${summary.model}` + : t("settings.voice.providers.local"); + case "openai": + return `${t("settings.voice.providers.openai")} · ${ + summary.missingKey ? t("settings.voice.openai.missingKey") : summary.model + }`; + case "unknown": + return t("settings.voice.unknownProvider", { provider: summary.provider }); + } +} + +const styles = StyleSheet.create((theme) => ({ + loading: { + paddingVertical: theme.spacing[8], + alignItems: "center", + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/settings/voice/voice-settings.test.ts b/packages/app/src/screens/settings/voice/voice-settings.test.ts new file mode 100644 index 0000000000..2541c4892c --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-settings.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + buildOpenAiModelOptions, + getSpeechSite, + formatLanguages, + formatModelSize, + getLocalModelState, + summarizeSpeechSite, +} from "./voice-settings"; + +function model(overrides: Partial): LocalSpeechModel { + return { + id: "parakeet-tdt-0.6b-v2-int8", + kind: "stt", + label: "Parakeet v2", + description: "Accurate English transcription.", + languages: ["en"], + downloadSizeBytes: 482_468_385, + isDefault: true, + inUse: true, + status: "installed", + ...overrides, + }; +} + +const catalog = [ + model({}), + model({ + id: "parakeet-tdt-110m-en-int8", + label: "Parakeet Small", + isDefault: false, + status: "not_installed", + inUse: false, + }), + model({ id: "kokoro-en-v0_19", kind: "tts", label: "Kokoro", inUse: false }), +]; + +describe("voice settings", () => { + it("keeps local STT language from the wire when opening a provider sheet", () => { + const features = { + dictation: { enabled: true, stt: { provider: "local", language: "fr" } }, + voiceMode: { + enabled: false, + stt: { provider: "local", language: "de" }, + tts: { provider: "local" }, + }, + }; + expect(getSpeechSite(features, "dictation.stt")).toMatchObject({ + provider: "local", + language: "fr", + }); + expect(getSpeechSite(features, "voiceMode.stt")).toMatchObject({ + provider: "local", + language: "de", + }); + }); + + it("summarizes a local site by its model, falling back to the catalog default", () => { + const summary = (modelId?: string) => + summarizeSpeechSite({ + siteId: "dictation.stt", + site: { provider: "local", model: modelId }, + envOverrides: {}, + localModels: catalog, + }); + expect(summary()).toEqual({ kind: "local", model: "Parakeet v2" }); + expect(summary("parakeet-tdt-110m-en-int8")).toEqual({ + kind: "local", + model: "Parakeet Small", + }); + }); + + it("flags an OpenAI site with no saved key and no env key", () => { + const summary = (envOverrides: Record) => + summarizeSpeechSite({ + siteId: "voiceMode.tts", + site: { provider: "openai", hasApiKey: false }, + envOverrides, + localModels: catalog, + }); + expect(summary({})).toEqual({ kind: "openai", model: "tts-1", missingKey: true }); + expect(summary({ "features.voiceMode.tts.apiKey": "OPENAI_API_KEY" })).toEqual({ + kind: "openai", + model: "tts-1", + missingKey: false, + }); + }); + + it("keeps a custom OpenAI-compatible model id selectable", () => { + expect(buildOpenAiModelOptions("stt", "my-whisper").at(-1)).toEqual({ + value: "my-whisper", + label: "my-whisper", + }); + }); + + it("reports download progress as a percentage", () => { + expect( + getLocalModelState(model({ status: "downloading", receivedBytes: 241, totalBytes: 482 })), + ).toEqual({ kind: "downloading", percent: 50 }); + expect(getLocalModelState(model({ status: "downloading" }))).toEqual({ + kind: "downloading", + percent: null, + }); + }); + + it("formats download sizes", () => { + expect(formatModelSize(482_468_385)).toBe("482 MB"); + expect(formatModelSize(1_250_000_000)).toBe("1.3 GB"); + }); + + it("names a short language list and counts the rest of a long one", () => { + const formatMore = (languages: string, count: number) => `${languages} and ${count} more`; + expect(formatLanguages({ languages: ["en", "de"], locale: "en", formatMore })).toBe( + "English, German", + ); + const whisper = ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca"]; + expect(formatLanguages({ languages: whisper, locale: "en", formatMore })).toBe( + "English, Chinese, German, Spanish, Russian, Korean, French, Japanese, Portuguese, Turkish and 2 more", + ); + }); +}); diff --git a/packages/app/src/screens/settings/voice/voice-settings.ts b/packages/app/src/screens/settings/voice/voice-settings.ts new file mode 100644 index 0000000000..79c1062b49 --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-settings.ts @@ -0,0 +1,240 @@ +import type { + LocalSpeechModel, + SpeechSettings, + SpeechSettingsPatch, +} from "@getpaseo/protocol/speech/rpc-schemas"; + +// What the Voice page needs to know about speech providers and models, kept free of React +// so the option lists and model states are testable on their own. + +export type SpeechProvider = "local" | "openai"; +export type SpeechModelKind = "stt" | "tts"; + +export const SPEECH_PROVIDERS: readonly SpeechProvider[] = ["local", "openai"]; + +// The three places that use speech. Each carries its own provider settings in config.json +// under `features.`. +export type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts"; + +export const SPEECH_SITE_KIND: Record = { + "dictation.stt": "stt", + "voiceMode.stt": "stt", + "voiceMode.tts": "tts", +}; + +/** The config path the daemon names in `envOverrides` for a field of a site. */ +export function speechSiteFieldPath(siteId: SpeechSiteId, field: string): string { + return `features.${siteId}.${field}`; +} + +/** One site's settings as the daemon reports them, narrowed to what the app can edit. */ +export type SpeechSite = + | { provider: "local"; model?: string; language?: string } + | { + provider: "openai"; + hasApiKey: boolean; + baseUrl?: string; + model?: string; + language?: string; + voice?: string; + } + | { provider: "unknown"; id: string }; + +type SpeechSiteSettings = SpeechSettings["dictation"]["stt"] | SpeechSettings["voiceMode"]["tts"]; + +export function getSpeechSite(features: SpeechSettings, siteId: SpeechSiteId): SpeechSite { + switch (siteId) { + case "dictation.stt": + return toSpeechSite(features.dictation.stt); + case "voiceMode.stt": + return toSpeechSite(features.voiceMode.stt); + case "voiceMode.tts": + return toSpeechSite(features.voiceMode.tts); + } +} + +function toSpeechSite(settings: SpeechSiteSettings): SpeechSite { + const text = (value: unknown) => (typeof value === "string" && value ? value : undefined); + if (settings.provider === "local") + return { provider: "local", model: text(settings.model), language: text(settings.language) }; + if (settings.provider === "openai") { + return { + provider: "openai", + hasApiKey: settings.hasApiKey === true, + baseUrl: text(settings.baseUrl), + model: text(settings.model), + language: text(settings.language), + voice: text(settings.voice), + }; + } + return { provider: "unknown", id: settings.provider }; +} + +export type SpeechSitePatch = NonNullable< + | NonNullable["stt"] + | NonNullable["tts"] +>; + +export function buildSpeechSitePatch( + siteId: SpeechSiteId, + patch: SpeechSitePatch, +): SpeechSettingsPatch { + switch (siteId) { + case "dictation.stt": + return { dictation: { stt: patch } }; + case "voiceMode.stt": + return { voiceMode: { stt: patch } }; + case "voiceMode.tts": + return { voiceMode: { tts: patch } }; + } +} + +// OpenAI accepts free-form ids so self-hosted servers can use their own; these are the +// ones the page offers. A saved id outside the list is still shown as an option. +const OPENAI_MODELS: Record = { + stt: { + ids: ["whisper-1", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"], + defaultId: "whisper-1", + }, + tts: { ids: ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"], defaultId: "tts-1" }, +}; + +const OPENAI_VOICES = ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"]; +const DEFAULT_OPENAI_VOICE = "alloy"; + +export interface SpeechModelOption { + value: string; + label: string; +} + +export function resolveOpenAiModel(kind: SpeechModelKind, model: string | undefined): string { + return model ?? OPENAI_MODELS[kind].defaultId; +} + +export function resolveLocalModel(input: { + kind: SpeechModelKind; + model: string | undefined; + localModels: readonly LocalSpeechModel[]; +}): LocalSpeechModel | undefined { + const kindModels = input.localModels.filter((model) => model.kind === input.kind); + if (input.model) return kindModels.find((model) => model.id === input.model); + return kindModels.find((model) => model.isDefault); +} + +export function buildOpenAiModelOptions( + kind: SpeechModelKind, + selected: string, +): SpeechModelOption[] { + return withSelected( + OPENAI_MODELS[kind].ids.map((id) => ({ value: id, label: id })), + selected, + ); +} + +export function resolveOpenAiVoice(voice: string | undefined): string { + return voice ?? DEFAULT_OPENAI_VOICE; +} + +export function buildOpenAiVoiceOptions(selected: string): SpeechModelOption[] { + return withSelected( + OPENAI_VOICES.map((voice) => ({ value: voice, label: voice })), + selected, + ); +} + +function withSelected(options: SpeechModelOption[], selected: string): SpeechModelOption[] { + if (!selected || options.some((option) => option.value === selected)) return options; + return [...options, { value: selected, label: selected }]; +} + +// What a page row says about a site before it is opened. +export type SpeechSiteSummary = + | { kind: "local"; model: string | null } + | { kind: "openai"; model: string; missingKey: boolean } + | { kind: "unknown"; provider: string }; + +export function summarizeSpeechSite(input: { + siteId: SpeechSiteId; + site: SpeechSite; + envOverrides: Record; + localModels: readonly LocalSpeechModel[]; +}): SpeechSiteSummary { + const { site, siteId } = input; + const kind = SPEECH_SITE_KIND[siteId]; + switch (site.provider) { + case "local": { + const model = resolveLocalModel({ kind, model: site.model, localModels: input.localModels }); + return { kind: "local", model: model?.label ?? site.model ?? null }; + } + case "openai": + return { + kind: "openai", + model: resolveOpenAiModel(kind, site.model), + missingKey: !site.hasApiKey && !input.envOverrides[speechSiteFieldPath(siteId, "apiKey")], + }; + case "unknown": + return { kind: "unknown", provider: site.id }; + } +} + +export type LocalModelState = + | { kind: "installed" } + | { kind: "not_installed" } + | { kind: "downloading"; percent: number | null } + | { kind: "extracting" } + | { kind: "failed"; error: string }; + +export function getLocalModelState(model: LocalSpeechModel): LocalModelState { + switch (model.status) { + case "installed": + return { kind: "installed" }; + case "downloading": { + const total = model.totalBytes ?? 0; + const received = model.receivedBytes ?? 0; + return { + kind: "downloading", + percent: total > 0 ? Math.min(100, Math.floor((received / total) * 100)) : null, + }; + } + case "extracting": + return { kind: "extracting" }; + case "failed": + return { kind: "failed", error: model.error ?? "" }; + default: + return { kind: "not_installed" }; + } +} + +export function isLocalModelBusy(model: LocalSpeechModel): boolean { + return model.status === "downloading" || model.status === "extracting"; +} + +export function formatModelSize(bytes: number): string { + const megabytes = bytes / 1_000_000; + if (megabytes >= 1000) return `${(megabytes / 1000).toFixed(1)} GB`; + return `${Math.round(megabytes)} MB`; +} + +// Multilingual models list close to a hundred languages; past this many the tooltip names +// the first ones and counts the rest. +const MAX_LISTED_LANGUAGES = 10; + +export function formatLanguages(input: { + languages: readonly string[]; + locale: string; + formatMore: (languages: string, count: number) => string; +}): string { + const names = languageNames(input.locale); + const listed = input.languages + .slice(0, MAX_LISTED_LANGUAGES) + .map((code) => names?.of(code) ?? code.toUpperCase()) + .join(", "); + const hidden = input.languages.length - MAX_LISTED_LANGUAGES; + return hidden > 0 ? input.formatMore(listed, hidden) : listed; +} + +// Hermes ships without Intl.DisplayNames; the language code is the fallback label there. +function languageNames(locale: string): Intl.DisplayNames | null { + if (typeof Intl.DisplayNames !== "function") return null; + return new Intl.DisplayNames([locale], { type: "language" }); +} diff --git a/packages/app/src/screens/workspace/sheet-pane/host.tsx b/packages/app/src/screens/workspace/sheet-pane/host.tsx new file mode 100644 index 0000000000..3f8e60783a --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-pane/host.tsx @@ -0,0 +1,84 @@ +import React, { createContext, useCallback, useContext, type ReactNode } from "react"; +import invariant from "tiny-invariant"; +import { NavigationContext, NavigationRouteContext } from "expo-router/react-navigation"; +import { useAssistantFileLinkEnvironmentBridge } from "@/assistant-file-links"; +import { useComposerEditingEnvironmentBridge } from "@/composer/editing-session"; +import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel"; +import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal"; +import { useDiffDocumentWorkspaceCacheBridge } from "@/git/diff-document/workspace-cache"; +import { HostRouteProvider, useHostRouteServerId } from "@/navigation/host-route-context"; +import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +/** What a sheet pane needs from the workspace it shows a tab of. */ +export interface SheetPaneHost { + workspaceKey: string; + serverId: string; + workspaceId: string; + isWorkspaceFocused: boolean; + /** The pane content for a sheet's tab. Closing the tab from inside it runs `closeCurrentTab`. */ + buildContent: ( + tab: WorkspaceTabDescriptor, + closeCurrentTab: () => void, + ) => WorkspacePaneContentModel; +} + +const SheetPaneHostContext = createContext(null); + +/** `null` while there is no workspace to show sheets for. */ +export function SheetPaneHostProvider({ + value, + children, +}: { + value: SheetPaneHost | null; + children: ReactNode; +}) { + return {children}; +} + +/** The host of the workspace the caller is in, or null while there is none. */ +export function useSheetPaneHostIfAny(): SheetPaneHost | null { + return useContext(SheetPaneHostContext); +} + +export function useSheetPaneHost(): SheetPaneHost { + const host = useSheetPaneHostIfAny(); + invariant(host, "SheetPaneHostProvider is required"); + return host; +} + +/** + * Carries a sheet pane's environment across the portal into the app overlay host: the workspace + * host, and the environment of the built-in panels. Pane identity is supplied by the pane itself. + */ +export function useSheetPaneBridge(): ContextBridge { + const host = useSheetPaneHost(); + const editing = useComposerEditingEnvironmentBridge(); + const cache = useDiffDocumentWorkspaceCacheBridge(); + const files = useAssistantFileLinkEnvironmentBridge(); + const active = useRetainedPanelActive(); + const serverId = useHostRouteServerId(); + const navigation = useContext(NavigationContext); + const route = useContext(NavigationRouteContext); + return useCallback( + (children) => { + const content = editing(cache(files(children))); + return ( + + + + + {serverId ? ( + {content} + ) : ( + content + )} + + + + + ); + }, + [host, editing, cache, files, active, serverId, navigation, route], + ); +} diff --git a/packages/app/src/screens/workspace/sheet-pane/index.ts b/packages/app/src/screens/workspace/sheet-pane/index.ts new file mode 100644 index 0000000000..1e6f6d1c40 --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-pane/index.ts @@ -0,0 +1,16 @@ +/** + * A workspace tab shown in a sheet. One pane — header, body, bottom inset, focus — behind every + * sheet container: the workspace's sheet tabs, and pages of a menu that shows a sheet in place. + * + * See docs/explorer-sidebar.md. + */ + +export { + SheetPaneHostProvider, + useSheetPaneBridge, + useSheetPaneHost, + useSheetPaneHostIfAny, + type SheetPaneHost, +} from "./host"; +export { SheetPane, useLeaveSheet, type SheetPaneProps } from "./sheet-pane"; +export { useSheetPanePage } from "./page"; diff --git a/packages/app/src/screens/workspace/sheet-pane/page.tsx b/packages/app/src/screens/workspace/sheet-pane/page.tsx new file mode 100644 index 0000000000..1b076510b9 --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-pane/page.tsx @@ -0,0 +1,38 @@ +import { useCallback } from "react"; +import { useMenuContext, type MenuFillPageDefinition } from "@/components/ui/menu"; +import { usePaneContext } from "@/panels/pane-context"; +import type { SheetTab } from "@/stores/workspace-layout-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; +import { useSheetPaneBridge } from "./host"; +import { SheetPane } from "./sheet-pane"; + +/** + * Shows sheet tabs as pages of a menu opened from a pane, so a row's sheet replaces the list it + * was chosen from instead of stacking a second sheet over it. Back returns to the list; Close + * closes the menu, which uncovers the pane the menu was opened from. + */ +export function useSheetPanePage(): (sheet: SheetTab) => MenuFillPageDefinition { + const bridge = useSheetPaneBridge(); + const { target: opener } = usePaneContext(); + return useCallback( + (sheet) => ({ + id: sheet.tab.tabId, + fill: true, + content: bridge( + // Keyed like the detached container, so the panel's descriptor hook stays fixed per page. + , + ), + }), + [bridge, opener], + ); +} + +function MenuSheetPane({ sheet, opener }: { sheet: SheetTab; opener: WorkspaceTabTarget }) { + const { setOpen, goBack } = useMenuContext("MenuSheetPane"); + const close = useCallback(() => setOpen(false), [setOpen]); + return ; +} diff --git a/packages/app/src/screens/workspace/sheet-pane/sheet-pane.tsx b/packages/app/src/screens/workspace/sheet-pane/sheet-pane.tsx new file mode 100644 index 0000000000..7b93f84746 --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-pane/sheet-pane.tsx @@ -0,0 +1,207 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + type ComponentType, +} from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Maximize2 } from "lucide-react-native"; +import { + SheetBottomInset, + SheetDragArea, + SheetHeaderIconButton, + SheetHeaderView, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import type { PanelIconProps } from "@/panels/panel-registry"; +import { getPanelRegistration } from "@/panels/panel-registry"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content"; +import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; +import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; +import { useSheetPaneHost } from "./host"; + +function PanelIcon({ + icon: Icon, + size, + color, +}: { icon: ComponentType } & PanelIconProps) { + return ; +} + +const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +export interface SheetPaneProps { + sheet: SheetTab; + /** Closes the sheet, everything it was chosen from included. */ + onClose: () => void; + /** Steps back to the list the sheet was chosen from, when there is one. */ + onBack?: () => void; + /** The pane closing the sheet uncovers, when the container knows it. */ + opener?: WorkspaceTabTarget | null; + /** Only the top sheet's pane is focused; a covered one stays as live as the pane under it. */ + isTop: boolean; +} + +interface SheetPaneExit { + close: () => void; + opener: WorkspaceTabTarget | null; +} + +const SheetPaneExitContext = createContext(null); + +/** + * A tab shown in a sheet: its header (icon, title, Back, Open as tab, Close), which drags the + * sheet, over its pane. Every sheet container renders this; only the container differs. + */ +export function SheetPane({ sheet, onClose, onBack, opener = null, isTop }: SheetPaneProps) { + const { t } = useTranslation(); + const host = useSheetPaneHost(); + const { tab } = sheet; + const registration = getPanelRegistration(tab.target.kind); + if (!registration) { + throw new Error(`No panel registration for kind: ${tab.target.kind}`); + } + const descriptor = registration.useDescriptor(tab.target as never, { + serverId: host.serverId, + workspaceId: host.workspaceId, + tabId: tab.tabId, + }); + + const openTabInFront = useWorkspaceLayoutStore((state) => state.openTabInFront); + const { workspaceKey } = host; + const handleOpenAsTab = useCallback( + () => openTabInFront(workspaceKey, sheet), + [openTabInFront, sheet, workspaceKey], + ); + const header = useMemo( + () => ({ + title: descriptor.label, + leading: , + back: onBack ? { onPress: onBack } : undefined, + // The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into + // the switcher, where it gets its composer back. + actions: sheet.policy.openAsTab ? ( + + ) : undefined, + }), + [descriptor.icon, descriptor.label, handleOpenAsTab, onBack, sheet.policy.openAsTab, t], + ); + + const tabDescriptor = useMemo( + () => ({ + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + state: tab.state, + }), + [tab], + ); + const { buildContent } = host; + const content = useMemo( + () => buildContent(tabDescriptor, onClose), + [buildContent, onClose, tabDescriptor], + ); + const exit = useMemo(() => ({ close: onClose, opener }), [onClose, opener]); + const isPaneFocused = isTop && host.isWorkspaceFocused; + useViewedSheetAgent({ + sourceId: `${workspaceKey}:sheet:${tab.tabId}`, + serverId: host.serverId, + agentId: isPaneFocused && tab.target.kind === "agent" ? tab.target.agentId : null, + }); + + return ( + + + {/* The body scrolls on its own, so the header is where the sheet drags from. */} + + + + + + + + + ); +} + +/** + * The agent of the sheet on top is viewed, alongside the pane under it: its timeline catches up + * and stays live. The workspace only publishes the agents of its panes. + */ +function useViewedSheetAgent({ + sourceId, + serverId, + agentId, +}: { + sourceId: string; + serverId: string; + agentId: string | null; +}) { + const viewedTimelineSync = useSessionStore( + (state) => state.sessions[serverId]?.viewedTimelineSync ?? null, + ); + useEffect(() => { + if (!agentId) return; + void getHostRuntimeStore() + .prepareAgentTimeline(serverId, agentId) + .catch(() => undefined); + }, [agentId, serverId]); + useLayoutEffect(() => { + if (!agentId || !viewedTimelineSync) return; + viewedTimelineSync.replaceVisibleAgentIds(sourceId, [agentId]); + return () => viewedTimelineSync.replaceVisibleAgentIds(sourceId, []); + }, [agentId, sourceId, viewedTimelineSync]); +} + +/** + * Leaves the sheet the calling pane is shown in, if any, on the way to `destination`. Answers + * whether closing the sheet already lands there, so the caller navigates only when it does not. + */ +export function useLeaveSheet(): (destination: WorkspaceTabTarget) => boolean { + const exit = useContext(SheetPaneExitContext); + return useCallback( + (destination) => { + if (!exit) return false; + exit.close(); + return exit.opener !== null && workspaceTabTargetsEqual(exit.opener, destination); + }, + [exit], + ); +} + +const styles = StyleSheet.create({ + pane: { + flex: 1, + minHeight: 0, + }, + body: { + flex: 1, + minHeight: 0, + }, +}); diff --git a/packages/app/src/screens/workspace/sheet-tabs.tsx b/packages/app/src/screens/workspace/sheet-tabs.tsx index e7c8005233..30bebaac66 100644 --- a/packages/app/src/screens/workspace/sheet-tabs.tsx +++ b/packages/app/src/screens/workspace/sheet-tabs.tsx @@ -1,131 +1,62 @@ -import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet"; import { useIsFocused } from "expo-router/react-navigation"; +import { useCallback, useEffect, useRef } from "react"; import { - type ComponentType, - type FC, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { View } from "react-native"; -import { useTranslation } from "react-i18next"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useStore } from "zustand"; -import { createStore } from "zustand/vanilla"; -import { Maximize2 } from "lucide-react-native"; -import { + FULL_SHEET_SNAP_POINTS, SheetBackground, SheetHandle, - SheetHeaderIconButton, - SheetHeaderView, - type SheetHeader, } from "@/components/adaptive-modal-sheet"; import { IsolatedBottomSheetModal, type IsolatedBottomSheetModalRef, systemBackPress, } from "@/components/ui/isolated-bottom-sheet-modal"; -import type { PanelIconProps } from "@/panels/panel-registry"; -import { getPanelRegistration } from "@/panels/panel-registry"; -import { ensurePanelsRegistered } from "@/panels/register-panels"; -import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { - WorkspacePaneContent, - type WorkspacePaneContentModel, -} from "@/screens/workspace/workspace-pane-content"; + SheetPane, + useSheetPaneBridge, + useSheetPaneHost, + useSheetPaneHostIfAny, +} from "@/screens/workspace/sheet-pane"; import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; -import { useWorkspaceSheetEnvironment } from "./workspace-sheet-environment"; -const SNAP_POINTS = ["92%"]; const NO_SHEETS: readonly SheetTab[] = []; -function PanelIcon({ - icon: Icon, - size, - color, -}: { icon: ComponentType } & PanelIconProps) { - return ; -} - -const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ - color: theme.colors.foregroundMuted, -})); - -interface SheetTabHandleValue { - header: SheetHeader; - onClose: () => void; -} - -/** - * The sheet's handle, showing its header. Gorhom takes `handleComponent` as a component type and - * renders it on the far side of the portal: a new function per render would remount the header on - * every change, and no context from here reaches it. So each sheet gets one handle component for - * its lifetime, which reads the header through a store the sheet keeps current. - */ -function useSheetTabHandle(value: SheetTabHandleValue): FC { - const [store] = useState(() => createStore(() => value)); - useEffect(() => store.setState(value, true), [store, value]); - const [Handle] = useState( - () => - function SheetTabHandle(props: BottomSheetHandleProps) { - const { header, onClose } = useStore(store); - return ( - - - - ); - }, - ); - return Handle; -} - export interface SheetTabsProps { - workspaceKey: string | null; - serverId: string; - workspaceId: string; - isWorkspaceFocused: boolean; - buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; /** The workspace's ordinary tab close, including its unsaved-changes guard. */ onCloseTab: (tabId: string) => Promise; } -interface SheetTabSheetProps extends Omit { +interface SheetTabSheetProps { sheet: SheetTab; - /** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */ + /** Only the top sheet answers Android Back; the ones below wait their turn. */ isTop: boolean; /** Runs the guarded close and resolves to whether the tab is still open. */ onClose: (tabId: string) => Promise; - onOpenAsTab: (tabId: string) => void; } /** * The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. Their environment - * crosses into the app overlay host so compact sheets cover both sidebars, and a - * sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of - * it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`, - * `SheetHeaderView`) keep them looking and dragging like every other sheet. + * crosses into the app overlay host so compact sheets cover both sidebars, and a sheet opened + * from another one lands on top of it and uncovers it on close. */ -export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) { - const sheets = useWorkspaceLayoutStore((state) => - workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS, - ); - const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab); - const handleOpenAsTab = useCallback( - (tabId: string) => { - if (workspaceKey) openSheetAsTab(workspaceKey, tabId); - }, - [openSheetAsTab, workspaceKey], +export function SheetTabs({ onCloseTab }: SheetTabsProps) { + const host = useSheetPaneHostIfAny(); + return host ? ( + + ) : null; +} + +function WorkspaceSheetTabs({ + workspaceKey, + onCloseTab, +}: SheetTabsProps & { workspaceKey: string }) { + const sheets = useWorkspaceLayoutStore( + (state) => state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS, ); const handleClose = useCallback( async (tabId: string) => { await onCloseTab(tabId); // A declined unsaved-changes prompt leaves the tab in its sheet. - const open = workspaceKey - ? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey] - : undefined; + const open = useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]; return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false; }, [onCloseTab, workspaceKey], @@ -136,42 +67,21 @@ export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) ))} ); } -function SheetTabSheet({ - sheet, - isTop, - serverId, - workspaceId, - isWorkspaceFocused, - buildContent, - onClose, - onOpenAsTab, -}: SheetTabSheetProps) { - const { t } = useTranslation(); +function SheetTabSheet({ sheet, isTop, onClose }: SheetTabSheetProps) { const { tab } = sheet; const sheetRef = useRef(null); const isScreenFocused = useIsFocused(); - const contextBridge = useWorkspaceSheetEnvironment(); - ensurePanelsRegistered(); - const registration = getPanelRegistration(tab.target.kind); - if (!registration) { - throw new Error(`No panel registration for kind: ${tab.target.kind}`); - } - const descriptor = registration.useDescriptor(tab.target as never, { - serverId, - workspaceId, - tabId: tab.tabId, - }); + const { isWorkspaceFocused } = useSheetPaneHost(); + const contextBridge = useSheetPaneBridge(); // The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off // the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still @@ -209,105 +119,23 @@ function SheetTabSheet({ }); }, [close, ownsBackPress]); - const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]); - const header = useMemo( - () => ({ - title: descriptor.label, - leading: , - // The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into - // the switcher, where it gets its composer back. - actions: sheet.policy.openAsTab ? ( - - ) : undefined, - }), - [descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t], - ); - const handleValue = useMemo( - () => ({ header, onClose: close }), - [close, header], - ); - // The header is the sheet's handle: the panel scrolls on its own, so dragging the content would - // fight it, and the header is where people reach to pull the sheet down. - const SheetTabHandle = useSheetTabHandle(handleValue); - return ( - {/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */} - + ); } - -function SheetTabBody({ - tab, - label, - isPaneFocused, - isWorkspaceFocused, - buildContent, -}: { - tab: SheetTab["tab"]; - label: string; - /** Only the top sheet's pane is focused; the ones below are covered. */ - isPaneFocused: boolean; - isWorkspaceFocused: boolean; - buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; -}) { - const tabDescriptor = useMemo( - () => ({ - key: tab.tabId, - tabId: tab.tabId, - kind: tab.target.kind, - target: tab.target, - state: tab.state, - }), - [tab], - ); - const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]); - // The sheet reaches the bottom of the screen, so it owns the system navigation inset. - const { bottom: bottomInset } = useSafeAreaInsets(); - const contentStyle = useMemo( - () => [styles.content, { paddingBottom: bottomInset }], - [bottomInset], - ); - - return ( - - - - ); -} - -const styles = StyleSheet.create({ - content: { - flex: 1, - minHeight: 0, - }, -}); diff --git a/packages/app/src/screens/workspace/visible-agent-ids.test.ts b/packages/app/src/screens/workspace/visible-agent-ids.test.ts index 1378d6cc20..37f470bb3f 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.test.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.test.ts @@ -111,28 +111,6 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to }).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] }); }); -test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => { - const layout: WorkspaceLayout = { - focusedPaneId: "main", - root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } }, - }; - const tabs: WorkspaceTab[] = [ - { tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, - ]; - const sheetTab: WorkspaceTab = { - tabId: "sheet", - target: { kind: "agent", agentId: "agent-child" }, - createdAt: 2, - }; - - expect( - selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }), - ).toEqual(["agent-a", "agent-child"]); - expect( - selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }), - ).toEqual([]); -}); - test("single-pane tablet includes its visible Explorer agent without including other splits", () => { const layout: WorkspaceLayout = { focusedPaneId: "main", diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts index 8643e84940..95a5ec84f8 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -6,8 +6,6 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state"; export function selectVisibleAgentIds(input: { layout: WorkspaceLayout | null; tabs: WorkspaceTab[]; - /** The workspace sheet's tab, shown over the panes. */ - sheetTab?: WorkspaceTab | null; routeFocused: boolean; focusedPaneOnly: boolean; explorerPaneId?: string | null; @@ -29,7 +27,6 @@ export function selectVisibleAgentIds(input: { .target; return target ? [target] : []; }); - if (input.sheetTab) targets.push(input.sheetTab.target); return [ ...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))), ].sort(); @@ -38,7 +35,6 @@ export function selectVisibleAgentIds(input: { export function useVisibleAgentIds(input: { layout: WorkspaceLayout | null; tabs: WorkspaceTab[]; - sheetTab: WorkspaceTab | null; routeFocused: boolean; focusedPaneOnly: boolean; explorerPaneId?: string | null; diff --git a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx index 84ec2634b9..6726c4d686 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx @@ -11,18 +11,7 @@ import { } from "@/screens/workspace/workspace-pane-content"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { usePaneContext, usePaneFocus, type PaneContextValue } from "@/panels/pane-context"; - -vi.mock("@/panels/register-panels", () => ({ - ensurePanelsRegistered: vi.fn(), -})); - -vi.mock("@/panels/panel-registry", () => ({ - getPanelRegistration: () => ({ - kind: "agent", - component: ProbePanel, - useDescriptor: vi.fn(), - }), -})); +import { definePanel, registerPanel } from "@/panels/panel-registry"; interface ProbeSnapshot { paneContextValue: PaneContextValue; @@ -48,6 +37,18 @@ function ProbePanel() { return null; } +// The workspace screen registers the real panels; this probe stands in for each kind the tests open. +for (const kind of ["agent", "draft"] as const) { + registerPanel( + definePanel(kind, { + component: ProbePanel, + useDescriptor: () => { + throw new Error("The probe panel has no descriptor"); + }, + }), + ); +} + const agentTab: WorkspaceTabDescriptor = { key: "agent_agent-a", tabId: "agent_agent-a", diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index 21476a85a4..95368240e9 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -9,7 +9,6 @@ import { } from "@/panels/pane-context"; import { useStableEvent } from "@/hooks/use-stable-event"; import { getPanelRegistration } from "@/panels/panel-registry"; -import { ensurePanelsRegistered } from "@/panels/register-panels"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { RenderProfile } from "@/utils/render-profiler"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; @@ -56,7 +55,6 @@ export function buildWorkspacePaneContentModel({ onOpenWorkspaceFile, onOpenImportSheet, }: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel { - ensurePanelsRegistered(); const registration = getPanelRegistration(tab.kind); invariant(registration, `No panel registration for kind: ${tab.kind}`); return { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index bcc2593611..dd806e1fa1 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,4 @@ +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useHostFeature } from "@/runtime/host-features"; import { AssistantFileLinkResolverProvider, @@ -63,6 +64,7 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useNavigateToImportedAgent } from "@/hooks/use-import-session"; import { useToast } from "@/contexts/toast-context"; +import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder"; import { getOrCreateClientId } from "@/utils/client-id"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; @@ -125,7 +127,6 @@ import { useWorkspaceSetupStore, } from "@/stores/workspace-setup-store"; import { useWorkspace } from "@/stores/session-store-hooks"; -import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import type { CheckoutStatusPayload } from "@/git/use-status-query"; import { confirmDialog } from "@/utils/confirm-dialog"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; @@ -178,6 +179,8 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; +import { ensurePanelsRegistered } from "@/panels/register-panels"; +import { SheetPaneHostProvider, type SheetPaneHost } from "@/screens/workspace/sheet-pane"; import { SheetTabs } from "@/screens/workspace/sheet-tabs"; import type { PaneHost } from "@/panels/panel-manifest"; import { WorkspaceFocusProvider } from "@/workspace/focus"; @@ -419,6 +422,10 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId, isRouteFocused, }: WorkspaceScreenProps) { + // Every reader of the panel registry renders inside the workspace screen, so it registers the + // panels before any of them renders. A reader importing the registration instead would import + // every panel, and the panels render readers. + ensurePanelsRegistered(); const navigationFocused = useIsFocused(); useEffect(() => { traceInstant("paseo.workspace.mount", { serverId, workspaceId }); @@ -762,13 +769,17 @@ function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { function WorkspaceContentProviders({ children, workspaceKey, + sheetPaneHost, }: { children: ReactNode; workspaceKey: string | null; + sheetPaneHost: SheetPaneHost | null; }) { return ( - {children} + + {children} + ); } @@ -869,13 +880,6 @@ function shouldShowWorkspaceScreenHeader(input: { return !input.isFocusModeEnabled || input.isMobile; } -function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { - if (!serverId || !workspaceId) { - return null; - } - return `${serverId}:${workspaceId}`; -} - /** * A pane the user acted inside owns the tab: it opens there, and an existing tab * moves there. No pane means the open has no opinion beyond the focused pane. @@ -1054,14 +1058,6 @@ function WorkspaceScreenContent({ const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = useWorkspaceRouteActions(normalizedServerId); - const workspaceTerminalScopeKey = useMemo( - () => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId), - [normalizedServerId, normalizedWorkspaceId], - ); - useWorkspaceTerminalSessionRetention({ - scopeKey: workspaceTerminalScopeKey, - }); - const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); const supportsProvidersSnapshot = useSessionStore( @@ -1340,10 +1336,6 @@ function WorkspaceScreenContent({ const sheets = useWorkspaceLayoutStore((state) => persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS, ); - // Only the top sheet is on screen. - const sheetTab = useWorkspaceLayoutStore((state) => - persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null, - ); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState); @@ -1415,7 +1407,6 @@ function WorkspaceScreenContent({ const visibleAgentIds = useVisibleAgentIds({ layout: workspaceLayout, tabs: uiTabs, - sheetTab, routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, explorerPaneId: explorerSidebarPaneId, @@ -2179,8 +2170,7 @@ function WorkspaceScreenContent({ const sessionState = useSessionStore.getState().sessions[normalizedServerId]; const currentCursor = sessionState?.agentTimelineCursor.get(agentId); await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, { - direction: "tail", - projection: "projected", + ...planTimelineTailFetch(), ...(currentCursor ? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } } : {}), @@ -2915,6 +2905,8 @@ function WorkspaceScreenContent({ paneId?: string | null; focusPaneBeforeOpen?: boolean; host?: PaneHost; + /** Overrides the guarded tab close, for a host that closes the tab its own way. */ + onCloseCurrentTab?: () => void; }) => buildWorkspacePaneContentModel({ tab: input.tab, @@ -2964,9 +2956,11 @@ function WorkspaceScreenContent({ }); } : undefined, - onCloseCurrentTab: () => { - void handleCloseTabById(input.tab.tabId); - }, + onCloseCurrentTab: + input.onCloseCurrentTab ?? + (() => { + void handleCloseTabById(input.tab.tabId); + }), onRetargetCurrentTab: (target) => { if (!persistenceKey) { return; @@ -3041,16 +3035,36 @@ function WorkspaceScreenContent({ [buildPaneContentModel], ); const buildSheetPaneContentModel = useCallback( - function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor) { + function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor, closeCurrentTab: () => void) { return buildPaneContentModel({ tab, paneId: null, focusPaneBeforeOpen: false, host: "sheet", + onCloseCurrentTab: closeCurrentTab, }); }, [buildPaneContentModel], ); + const sheetPaneHost = useMemo( + () => + persistenceKey + ? { + workspaceKey: persistenceKey, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + isWorkspaceFocused: isRouteFocused, + buildContent: buildSheetPaneContentModel, + } + : null, + [ + buildSheetPaneContentModel, + isRouteFocused, + normalizedServerId, + normalizedWorkspaceId, + persistenceKey, + ], + ); const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) { if (!persistenceKey || paneFocusSuppressedRef.current) { return; @@ -3187,6 +3201,7 @@ function WorkspaceScreenContent({ @@ -3538,14 +3553,7 @@ function WorkspaceScreenContent({ )} - + ) : ( @@ -3681,6 +3689,7 @@ function WorkspaceScreenContent({ view: "files", }); }); + const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId); const markdownPathOpener = useMemo( () => createInlinePathOpener({ @@ -3688,8 +3697,15 @@ function WorkspaceScreenContent({ client, openFile: openMarkdownFile, openDirectory: openMarkdownDirectory, + openOutsideDirectory: openMarkdownOutsideDirectory, }), - [workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory], + [ + workspaceDirectory, + client, + openMarkdownFile, + openMarkdownDirectory, + openMarkdownOutsideDirectory, + ], ); useEffect(() => { if (!isRouteFocused) markdownPathOpener.cancel(); @@ -3757,7 +3773,11 @@ function WorkspaceScreenContent({ return gatedWorkspaceScreen; } return ( - + { - const content = editing(cache(files(children))); - return ( - - - - {serverId ? ( - {content} - ) : ( - content - )} - - - - ); - }, - [editing, cache, files, active, serverId, navigation, route], - ); -} diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index fb73376b8b..03f0254099 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -6,7 +6,6 @@ import { Check } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; -import { ensurePanelsRegistered } from "@/panels/register-panels"; import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-registry"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; @@ -43,7 +42,6 @@ export function WorkspaceTabPresentationResolver({ workspaceId, children, }: WorkspaceTabPresentationResolverProps): ReactElement { - ensurePanelsRegistered(); const registration = getPanelRegistration(tab.kind); invariant(registration, `No panel registration for kind: ${tab.kind}`); diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 86ea81afc5..fb38605220 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -5,6 +5,7 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { normalizeWorkspaceDescriptor, + selectAgentSentMessages, selectAgentTurnPresentation, selectAgentTimelineState, useSessionStore, @@ -235,6 +236,25 @@ describe("agent timeline state", () => { ).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" }); }); + it("lists sent messages newest first, including the live head", () => { + initializeTestSession(); + const sent = (id: string, text: string) => ({ ...submittedMessage(id), text }); + useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", { + items: [sent("m1", "fix the auth bug"), sent("m2", "add tests"), sent("m3", "add tests")], + head: [sent("m4", " "), sent("m5", "refactor the wrong module")], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 5 }, + older: "none", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); + + expect( + selectAgentSentMessages(useSessionStore.getState().sessions["test-server"], "agent-1"), + ).toEqual(["refactor the wrong module", "add tests", "fix the auth bug"]); + expect(selectAgentSentMessages(undefined, "agent-1")).toEqual([]); + }); + it("preserves older availability when an applied response leaves it unchanged", () => { initializeTestSession(); const store = useSessionStore.getState(); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index aed06eef11..911573762f 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -332,6 +332,26 @@ export function selectAgentTimelineState( return items.length > 0 ? { status: "painted", items } : { status: "cold" }; } +/** Text of the loaded user messages, newest first, with back-to-back repeats collapsed. */ +export function selectAgentSentMessages( + session: SessionState | undefined, + agentId: string, +): string[] { + if (!session) return []; + const items = [ + ...(session.agentStreamTail.get(agentId) ?? []), + ...(session.agentStreamHead.get(agentId) ?? []), + ]; + const messages: string[] = []; + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item.kind !== "user_message" || item.text.trim().length === 0) continue; + if (messages[messages.length - 1] === item.text) continue; + messages.push(item.text); + } + return messages; +} + export function selectAgentTurnPresentation( session: SessionState | undefined, agentId: string, diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 79e0b4fc76..cd727a6915 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -271,6 +271,7 @@ export interface WorkspaceTabSnapshot { terminalsHydrated: boolean; activeAgentIds: Iterable; autoOpenAgentIds: Iterable; + foreignAgentIds?: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingTerminalCreate?: boolean; @@ -2443,13 +2444,16 @@ export function reconcileWorkspaceTabs( const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); - // 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 foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []); + // Explicit opens survive absent directory entries, but not known foreign ownership. + // Once an agent joins this workspace's active directory, normal archive rules apply. const pinnedAgentIds = new Set( - [...(state.pinnedAgentIds ?? [])].filter( - (agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId), - ), + [...(state.pinnedAgentIds ?? [])].filter((agentId) => { + if (!snapshot.agentsHydrated) { + return true; + } + return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId); + }), ); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const knownTerminalIds = snapshot.knownTerminalIds diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 431d550823..8b478b7d27 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -41,6 +41,7 @@ import { normalizeLayout, removePaneFromTree, removeTabFromTree, + selectSheetTab, stripEphemeralTabsFromLayout, type SplitNode, type SplitPane, @@ -173,7 +174,6 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), onCatchUpEnded: () => undefined, reportError: () => undefined, - schedule: () => () => undefined, }); // The edge session-context.tsx owns. Pointing it at subscription instead would resume every // agent behind a restored tab on the daemon. @@ -4496,6 +4496,12 @@ describe("compact workspace sheet", () => { return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1); } + function requireTopSheet(store: ReturnType) { + const sheet = topSheet(store); + if (!sheet) throw new Error("Expected a sheet"); + return sheet; + } + function sheetTargets(store: ReturnType) { return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map( (sheet) => sheet.tab.target, @@ -4647,7 +4653,7 @@ describe("compact workspace sheet", () => { .getState() .openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" }); - store.getState().openSheetAsTab(workspaceKey, nextTabId ?? ""); + store.getState().openTabInFront(workspaceKey, requireTopSheet(store)); expect(sheetTargets(store)).toEqual([]); expect(tabKinds(store)).toEqual(["terminal", "file"]); @@ -4683,7 +4689,7 @@ describe("compact workspace sheet", () => { if (!sheetTabId) throw new Error("Expected a sheet tab"); store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 }); - const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId); + const tabId = store.getState().openTabInFront(workspaceKey, requireTopSheet(store)); expect(tabId).toBe(sheetTabId); expect(topSheet(store)).toBeUndefined(); @@ -4787,6 +4793,40 @@ describe("compact workspace sheet", () => { expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]); }); + it("describes the sheet a reveal would show without opening it, and opens it as a tab", () => { + seedAgents([ + { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, + { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" }, + ]); + const store = createCompactStore(); + const rootTabId = store + .getState() + .openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" }); + + const sheet = selectSheetTab(store.getState(), { + workspaceKey, + target: { kind: "agent", agentId: "child" }, + parentTabId: rootTabId ?? undefined, + }); + expect(sheet?.parentTabId).toBe(rootTabId); + expect( + selectSheetTab(store.getState(), { + workspaceKey, + target: { kind: "agent", agentId: "root" }, + }), + ).toBeNull(); + expect(topSheet(store)).toBeUndefined(); + if (!sheet) throw new Error("Expected a sheet tab"); + + const tabId = store.getState().openTabInFront(workspaceKey, sheet); + + expect(tabId).toBe(sheet.tab.tabId); + expect(topSheet(store)).toBeUndefined(); + const layout = store.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(tabId); + expect(layout.parentTabIdByTabId?.[tabId ?? ""]).toBe(rootTabId); + }); + it("gives a sheet target opened from inside the sheet the sheet's own parent", () => { seedAgents([ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" }, @@ -4811,7 +4851,7 @@ describe("compact workspace sheet", () => { }); expect(topSheet(store)?.parentTabId).toBe(rootTabId); - store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? ""); + store.getState().openTabInFront(workspaceKey, requireTopSheet(store)); const layout = store.getState().layoutByWorkspace[workspaceKey]; expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId); }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 1c0b7a5037..43387bc743 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -156,13 +156,12 @@ interface WorkspaceLayoutStore { * `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id. */ openTab: (input: OpenWorkspaceTabInput) => string | null; - /** Whether `openTab` with this input would show the target in a sheet. */ - opensInSheet: ( - input: Pick, - ) => boolean; setLayoutMode: (mode: WorkspaceLayoutMode) => void; - /** Moves a sheet's tab, keeping its tab id, into the focused pane. */ - openSheetAsTab: (workspaceKey: string, tabId: string) => string | null; + /** + * Moves a sheet's tab, keeping its tab id, into the focused pane and dismisses the sheets that + * would cover it. The sheet need not be one of the workspace's sheet tabs. + */ + openTabInFront: (workspaceKey: string, sheet: SheetTab) => string | null; /** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */ autoOpenPullRequestTab: ( workspaceKey: string, @@ -521,6 +520,29 @@ export function selectExplorerSidebarPaneId( return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]); } +/** + * The sheet tab a reveal of this target would show, or null when it opens as a tab. A caller + * that hosts the sheet itself (a list showing it in place) renders this without opening it. + */ +export function selectSheetTab( + state: Pick, + input: Pick, +): SheetTab | null { + const workspaceKey = trimNonEmpty(input.workspaceKey); + const target = normalizeWorkspaceTabTarget(input.target); + if (!workspaceKey || !target) return null; + const policy = resolveSheetPolicy(state, workspaceKey, target, "reveal"); + if (!policy) return null; + return placeTargetInSheet({ + kept: undefined, + target, + intent: "reveal", + policy, + parentTabId: resolveOpenParentTabId(state, workspaceKey, input.parentTabId), + state: undefined, + }); +} + /** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */ export function selectPaneFocusSuspended( state: Pick, @@ -822,10 +844,8 @@ function placeTargetInSheet(input: { function promoteSheetToTab( state: WorkspaceLayoutStore, workspaceKey: string, - tabId: string, + sheet: SheetTab, ): { tabId: string; patch: Partial } | null { - const sheet = findSheet(state, workspaceKey, tabId); - if (!sheet) return null; const { tab } = sheet; const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT); const input = { @@ -910,9 +930,9 @@ function dismissSheets( function openSheetTabInFront( state: WorkspaceLayoutStore, workspaceKey: string, - tabId: string, + sheet: SheetTab, ): { tabId: string; patch: Partial } | null { - const promoted = promoteSheetToTab(state, workspaceKey, tabId); + const promoted = promoteSheetToTab(state, workspaceKey, sheet); if (!promoted) return null; const next = { ...state, ...promoted.patch }; return { @@ -1208,15 +1228,6 @@ export function createWorkspaceLayoutStore( })); return result.tabId; }, - opensInSheet: (input) => { - const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey); - const normalizedTarget = normalizeWorkspaceTabTarget(input.target); - if (!normalizedWorkspaceKey || !normalizedTarget) return false; - return ( - resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !== - null - ); - }, setLayoutMode: (mode) => { set((state) => { if (state.layoutMode === mode) return state; @@ -1225,7 +1236,7 @@ export function createWorkspaceLayoutStore( const next: WorkspaceLayoutStore = { ...state, layoutMode: mode }; for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) { for (const sheet of sheets) { - const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId); + const promoted = promoteSheetToTab(next, workspaceKey, sheet); Object.assign( next, promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId), @@ -1235,11 +1246,10 @@ export function createWorkspaceLayoutStore( return next; }); }, - openSheetAsTab: (workspaceKey, tabId) => { + openTabInFront: (workspaceKey, sheet) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedTabId = trimNonEmpty(tabId); - if (!normalizedWorkspaceKey || !normalizedTabId) return null; - const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId); + if (!normalizedWorkspaceKey) return null; + const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, sheet); if (!promoted) return null; set(promoted.patch); return promoted.tabId; @@ -1569,7 +1579,10 @@ export function createWorkspaceLayoutStore( return normalizedTabId; } // A target sheets do not host becomes the tab it would have opened as. - const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId); + const retargeted = findSheet(next, normalizedWorkspaceKey, normalizedTabId); + const promoted = retargeted + ? openSheetTabInFront(next, normalizedWorkspaceKey, retargeted) + : null; set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId)); return promoted?.tabId ?? null; } diff --git a/packages/app/src/styles/unistyles-props.test.ts b/packages/app/src/styles/unistyles-props.test.ts new file mode 100644 index 0000000000..05d66a7381 --- /dev/null +++ b/packages/app/src/styles/unistyles-props.test.ts @@ -0,0 +1,86 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { build } from "esbuild"; +import { describe, expect, it } from "vitest"; + +// Bypass the app test aliases: they replace all three libraries involved in the leak. +async function renderWebProbe(contents: string) { + const { outputFiles } = await build({ + stdin: { + contents, + resolveDir: path.resolve(import.meta.dirname, "../.."), + loader: "jsx", + }, + bundle: true, + platform: "node", + format: "cjs", + write: false, + mainFields: ["browser", "module", "main"], + conditions: ["browser"], + resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".js", ".json"], + alias: { "react-native": "react-native-web", "@": path.resolve(import.meta.dirname, "..") }, + jsx: "automatic", + define: { __DEV__: "true", "process.env.NODE_ENV": '"development"' }, + }); + return spawnSync(process.execPath, ["-"], { + input: outputFiles[0].text, + encoding: "utf8", + }); +} + +describe("Unistyles web component props", () => { + it("consumes uniProps while preserving mapped colors and explicit overrides", async () => { + const result = await renderWebProbe(` + import React from 'react'; + import { renderToStaticMarkup } from 'react-dom/server'; + import { StyleSheet, UnistylesRuntime, withUnistyles } from 'react-native-unistyles'; + import { Server } from 'lucide-react-native'; + StyleSheet.configure({ + themes: { light: { color: 'red' }, dark: { color: 'blue' } }, + settings: { initialTheme: 'light' }, + }); + const Icon = withUnistyles(Server); + const mapping = theme => ({ color: theme.color }); + const render = props => renderToStaticMarkup(React.createElement(Icon, { uniProps: mapping, ...props })); + const light = render(); + UnistylesRuntime.setTheme('dark'); + const dark = render(); + const overridden = render({ color: 'green' }); + const received = []; + const Probe = withUnistyles(props => { received.push(props); return null; }); + renderToStaticMarkup(React.createElement(Probe, { uniProps: mapping, id: 'probe' })); + console.log(JSON.stringify({ light, dark, overridden, keys: Object.keys(received[0]).sort() })); + `); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).not.toContain("React does not recognize"); + const rendered = JSON.parse(result.stdout); + expect(rendered.keys).toEqual(["color", "id", "ref"]); + expect(rendered.light).toContain('stroke="red"'); + expect(rendered.dark).toContain('stroke="blue"'); + expect(rendered.overridden).toContain('stroke="green"'); + }); +}); + +describe("decorative checks ring", () => { + it("hides its SVG from web accessibility without forwarding native props", async () => { + const result = await renderWebProbe(` + import React from 'react'; + import { renderToStaticMarkup } from 'react-dom/server'; + import { StyleSheet } from 'react-native-unistyles'; + import { ChecksRing } from './src/git/pull-request-panel/checks-ring'; + StyleSheet.configure({ + themes: { light: { colors: { surface3: 'gray', statusSuccess: 'green' } } }, + settings: { initialTheme: 'light' }, + }); + console.log(renderToStaticMarkup(React.createElement(ChecksRing, { + size: 20, summary: { total: 1, parts: [{ status: 'success', count: 1 }] }, + }))); + `); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('aria-hidden="true"'); + expect(result.stdout).not.toContain("importantForAccessibility"); + expect(result.stdout).not.toContain("accessibilityElementsHidden"); + expect(result.stdout).toContain('stroke="green"'); + expect(result.stderr).not.toContain("React does not recognize"); + }); +}); diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index 407faafdf6..96510c59f2 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -6,8 +6,7 @@ StyleSheet.configure({ breakpoints: { xs: 0, sm: 576, - medium: 600, - md: 720, + md: 600, lg: 992, xl: 1200, }, @@ -22,7 +21,6 @@ type AppThemes = typeof REGISTERED_THEMES; interface AppBreakpoints { xs: number; sm: number; - medium: number; md: number; lg: number; xl: number; diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index 6c539931e1..0b72ccfcf5 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -9,4 +9,4 @@ export { type ArchiveFinishedStatus, type UseArchiveFinishedSubagentsInput, } from "./use-archive-finished"; -export { useSubagentOpensInSheet } from "./opens-in-sheet"; +export { useSubagentSheetTab } from "./sheet-tab"; diff --git a/packages/app/src/subagents/opens-in-sheet.ts b/packages/app/src/subagents/opens-in-sheet.ts deleted file mode 100644 index 26432ea039..0000000000 --- a/packages/app/src/subagents/opens-in-sheet.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; -import type { SubagentRow } from "./select"; - -/** The workspace target a subagent row opens. */ -export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget { - return row.kind === "provider" - ? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id } - : { kind: "agent", agentId: row.id }; -} - -/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */ -export function useSubagentOpensInSheet( - workspaceKey: string | null, -): (row: SubagentRow) => boolean { - // The rows render the answer, so this subscribes to what the answer reads: the layout mode and - // the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet, - // and a row rendered while that tab existed must re-render once it closes, or choosing the row - // would still close the list it sits in. - useWorkspaceLayoutStore((state) => state.layoutMode); - useWorkspaceLayoutStore((state) => - workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, - ); - return (row) => - workspaceKey !== null && - useWorkspaceLayoutStore - .getState() - .opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" }); -} diff --git a/packages/app/src/subagents/parent/callout.tsx b/packages/app/src/subagents/parent/callout.tsx new file mode 100644 index 0000000000..7665c0d12a --- /dev/null +++ b/packages/app/src/subagents/parent/callout.tsx @@ -0,0 +1,92 @@ +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { CornerUpLeft } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StatusIcon } from "@/components/status-icon"; +import { Button } from "@/components/ui/button"; +import { FOOTER_HEIGHT } from "@/constants/layout"; +import type { Theme } from "@/styles/theme"; +import { useParentAgent, type ParentAgent } from "./use-parent-agent"; + +function ParentIcon({ + icon: Icon, + size, + color = "", +}: { + icon: ParentAgent["icon"]; + size: number; + color?: string; +}) { + return ; +} + +const ThemedParentIcon = withUnistyles(ParentIcon); +const ICON_SIZE = 16; +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); + +/** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */ +export function ParentAgentCallout({ + serverId, + parentAgentId, +}: { + serverId: string; + parentAgentId: string; +}): ReactElement { + const { t } = useTranslation(); + const parent = useParentAgent(serverId, parentAgentId); + + return ( + + + + + + + {parent.label ?? t("subagents.parent.parentAgent")} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // The composer's rail, so the callout sits where the composer would. + rail: { + minHeight: FOOTER_HEIGHT, + alignItems: "center", + width: "100%", + paddingHorizontal: theme.spacing[4], + paddingBottom: theme.spacing[4], + }, + callout: { + width: "100%", + maxWidth: theme.contentMaxWidth, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius["2xl"], + padding: theme.spacing[3], + paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] }, + }, + // Shrinks before the button does, so Open parent never wraps or leaves the row. + title: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/subagents/parent/index.ts b/packages/app/src/subagents/parent/index.ts new file mode 100644 index 0000000000..daca7d3d16 --- /dev/null +++ b/packages/app/src/subagents/parent/index.ts @@ -0,0 +1,3 @@ +export { ParentAgentCallout } from "./callout"; +export { ParentAgentPill } from "./pill"; +export { useParentAgentId } from "./use-parent-agent"; diff --git a/packages/app/src/subagents/parent/pill.tsx b/packages/app/src/subagents/parent/pill.tsx new file mode 100644 index 0000000000..63f9407f8b --- /dev/null +++ b/packages/app/src/subagents/parent/pill.tsx @@ -0,0 +1,79 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { Text } from "react-native"; +import { useTranslation } from "react-i18next"; +import { ChevronLeft } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { composerPillStyles } from "@/composer/pill-styles"; +import { ComposerTrackMark } from "@/composer/tracks"; +import type { Theme } from "@/styles/theme"; +import { useParentAgent } from "./use-parent-agent"; + +const ThemedChevronLeft = withUnistyles(ChevronLeft); +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); + +/** + * The way back from a subagent to the agent that started it. It leads the pill rail and names + * the parent rather than saying "parent": in a stack of subagents the title is what tells you + * where you will land. + */ +export function ParentAgentPill({ + serverId, + parentAgentId, +}: { + serverId: string; + parentAgentId: string; +}): ReactElement { + const { t } = useTranslation(); + const parent = useParentAgent(serverId, parentAgentId); + const [isHovered, setIsHovered] = useState(false); + const handleHoverIn = useCallback(() => setIsHovered(true), []); + const handleHoverOut = useCallback(() => setIsHovered(false), []); + const bodyStyle = useMemo( + () => [composerPillStyles.body, styles.body, isHovered && composerPillStyles.bodyActive], + [isHovered], + ); + const labelStyle = useMemo( + () => [composerPillStyles.label, styles.label, isHovered && composerPillStyles.labelActive], + [isHovered], + ); + const label = parent.label ?? t("subagents.parent.backToParent"); + + return ( + + + + + {label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // The chevron's box is wider than its stroke, so the pill pulls it toward the edge to keep the + // visible glyph on the same inset as other pills' leading marks. + body: { + flexShrink: 1, + minWidth: 0, + gap: theme.spacing[1.5], + paddingLeft: theme.spacing[2], + }, + label: { + flexShrink: 1, + maxWidth: { xs: 140, md: 240 }, + }, +})); diff --git a/packages/app/src/subagents/parent/use-parent-agent.ts b/packages/app/src/subagents/parent/use-parent-agent.ts new file mode 100644 index 0000000000..86f9db06cb --- /dev/null +++ b/packages/app/src/subagents/parent/use-parent-agent.ts @@ -0,0 +1,43 @@ +import { useCallback } from "react"; +import { useAgentDescriptor } from "@/agent-presentation"; +import type { PanelDescriptor } from "@/panels/panel-registry"; +import { useLeaveSheet } from "@/screens/workspace/sheet-pane"; +import { useSessionStore } from "@/stores/session-store"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; + +export interface ParentAgent { + /** The parent's title, or null while it has none — callers fall back to a generic name. */ + label: string | null; + icon: PanelDescriptor["icon"]; + statusBucket: SidebarStateBucket | null; + open: () => void; +} + +/** The parent of a managed agent, or null for a root or detached agent. */ +export function useParentAgentId(serverId: string, agentId: string): string | null { + return useSessionStore((state) => { + const session = state.sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return agent?.parentAgentId ?? null; + }); +} + +/** The parent as its own tab presents it, and the way back to it. */ +export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent { + const descriptor = useAgentDescriptor({ kind: "agent", agentId: parentAgentId }, { serverId }); + const leaveSheet = useLeaveSheet(); + // Going back to the parent leaves the sheet the child is shown in. When the child was chosen + // from the parent's list, closing the sheet already uncovers the parent; otherwise the parent + // may live anywhere, even another workspace, and navigateToAgent opens it wherever it is. + const open = useCallback(() => { + if (leaveSheet({ kind: "agent", agentId: parentAgentId })) return; + navigateToAgent({ serverId, agentId: parentAgentId }); + }, [leaveSheet, parentAgentId, serverId]); + return { + label: descriptor.label || null, + icon: descriptor.icon, + statusBucket: descriptor.statusBucket, + open, + }; +} diff --git a/packages/app/src/subagents/opens-in-sheet.test.tsx b/packages/app/src/subagents/sheet-tab.test.tsx similarity index 80% rename from packages/app/src/subagents/opens-in-sheet.test.tsx rename to packages/app/src/subagents/sheet-tab.test.tsx index c9b915f787..e295f7b7c7 100644 --- a/packages/app/src/subagents/opens-in-sheet.test.tsx +++ b/packages/app/src/subagents/sheet-tab.test.tsx @@ -9,7 +9,7 @@ import { useSessionStore, type Agent } from "@/stores/session-store"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; -import { useSubagentOpensInSheet } from "./opens-in-sheet"; +import { useSubagentSheetTab } from "./sheet-tab"; import { selectSubagentsForParent, type SubagentRow } from "./select"; vi.mock("@react-native-async-storage/async-storage", () => { @@ -116,28 +116,40 @@ afterEach(() => { }); }); -describe("useSubagentOpensInSheet", () => { - it("re-renders the row's answer as the child's tab opens and closes", () => { +describe("useSubagentSheetTab", () => { + it("re-renders the row's sheet as the child's tab opens and closes", () => { const row = seedParentWithChild(); const store = useWorkspaceLayoutStore.getState(); store.setLayoutMode("compact"); - const target = { kind: "agent", agentId: "child" } as const; // The row renders its answer, as the track does, so a stale render is a stale answer. - const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row)); - expect(result.current).toBe(true); + const { result } = renderHook(() => useSubagentSheetTab(workspaceKey, "parent-tab")(row)); + expect(result.current?.tab.target).toEqual({ kind: "agent", agentId: "child" }); let tabId: string | null = null; act(() => { - const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" }); - tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null; + tabId = result.current ? store.openTabInFront(workspaceKey, result.current) : null; }); expect(tabId).not.toBeNull(); - expect(result.current).toBe(false); + expect(result.current).toBeNull(); act(() => { store.closeTab(workspaceKey, tabId ?? ""); }); - expect(result.current).toBe(true); + expect(result.current?.tab.tabId).toBe(tabId); + }); + + it("hands a re-rendered row the same sheet", () => { + const row = seedParentWithChild(); + useWorkspaceLayoutStore.getState().setLayoutMode("compact"); + + const { result, rerender } = renderHook(() => + useSubagentSheetTab(workspaceKey, "parent-tab")(row), + ); + const first = result.current; + rerender(); + + expect(first).not.toBeNull(); + expect(result.current).toBe(first); }); }); diff --git a/packages/app/src/subagents/sheet-tab.ts b/packages/app/src/subagents/sheet-tab.ts new file mode 100644 index 0000000000..c81be94ced --- /dev/null +++ b/packages/app/src/subagents/sheet-tab.ts @@ -0,0 +1,58 @@ +import { useMemo } from "react"; +import { + selectSheetTab, + useWorkspaceLayoutStore, + type SheetTab, +} from "@/stores/workspace-layout-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; +import type { SubagentRow } from "./select"; + +/** The workspace target a subagent row opens. */ +export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget { + return row.kind === "provider" + ? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id } + : { kind: "agent", agentId: row.id }; +} + +/** + * Answers, per subagent row, the sheet that shows it in place of the list, or null when choosing + * it reveals a tab instead. `parentTabId` is the tab of the pane the list belongs to. + */ +export function useSubagentSheetTab( + workspaceKey: string | null, + parentTabId: string, +): (row: SubagentRow) => SheetTab | null { + // The rows render the answer, so this subscribes to everything the answer reads. A subagent + // already open as a tab reveals that tab instead, and a row rendered while that tab existed + // must re-render once it closes. + const layoutMode = useWorkspaceLayoutStore((state) => state.layoutMode); + const layout = useWorkspaceLayoutStore((state) => + workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, + ); + const sheets = useWorkspaceLayoutStore((state) => + workspaceKey ? state.sheetsByWorkspace[workspaceKey] : undefined, + ); + return useMemo(() => { + // One answer per row until the state it reads changes, so a re-render of the list hands an + // open sheet the same tab rather than a fresh one. + const answers = new Map(); + return (row) => { + if (!workspaceKey) return null; + const state = { + layoutMode, + layoutByWorkspace: layout ? { [workspaceKey]: layout } : {}, + sheetsByWorkspace: sheets ? { [workspaceKey]: sheets } : {}, + }; + const key = `${row.kind}:${row.id}`; + const known = answers.get(key); + if (known !== undefined) return known; + const sheet = selectSheetTab(state, { + workspaceKey, + target: subagentRowTarget(row), + parentTabId, + }); + answers.set(key, sheet); + return sheet; + }; + }, [layout, layoutMode, parentTabId, sheets, workspaceKey]); +} diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index d759da431e..dd8c088e63 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -18,6 +18,8 @@ import { import type { Theme } from "@/styles/theme"; import { useAgentFactsLine } from "@/panels/agent-facts-line"; import { getPanelManifest } from "@/panels/panel-manifest"; +import { useSheetPanePage } from "@/screens/workspace/sheet-pane"; +import type { SheetTab } from "@/stores/workspace-layout-store"; import type { SubagentRow } from "./select"; import type { ArchiveFinishedStatus } from "./use-archive-finished"; import { @@ -40,10 +42,10 @@ export interface SubagentsTrackProps { onOpenSubagent: (address: AgentAddress) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; /** - * Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on - * close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list. + * The sheet a row opens, or null when it opens as a tab. A sheet replaces the list in place and + * Back returns to it; a tab replaces the list's pane, so choosing it ends the list. */ - opensInSheet: (row: SubagentRow) => boolean; + sheetTabFor: (row: SubagentRow) => SheetTab | null; onArchiveSubagent: (address: AgentAddress) => void; onArchiveFinished?: () => void; archiveFinishedStatus?: ArchiveFinishedStatus; @@ -71,13 +73,14 @@ export function SubagentsTrack({ rows, onOpenSubagent, onOpenProviderSubagent, - opensInSheet, + sheetTabFor, onArchiveSubagent, onArchiveFinished, archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS, onDetachSubagent, }: SubagentsTrackProps): ReactElement | null { const { t } = useTranslation(); + const sheetPanePage = useSheetPanePage(); const isArchivingFinished = archiveFinishedStatus.kind === "archiving"; const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed"; @@ -88,9 +91,12 @@ export function SubagentsTrack({ const pill = buildSubagentPillPresentation(t, rows); const finishedCount = countFinishedSubagents(rows); const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed; + const sheets = rows.map(sheetTabFor); + const pages = sheets.flatMap((sheet) => (sheet ? [sheetPanePage(sheet)] : [])); return ( ) : null} - {rows.map((row) => ( + {rows.map((row, index) => ( @@ -184,7 +190,8 @@ interface SubagentsTrackRowProps { row: SubagentRow; onOpenSubagent: (address: AgentAddress) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; - opensInSheet: boolean; + /** The sheet page the row opens in place of the list, when it opens in a sheet. */ + sheetTabId: string | null; onArchiveSubagent: (address: AgentAddress) => void; onDetachSubagent?: (address: AgentAddress) => void; } @@ -194,7 +201,7 @@ function SubagentsTrackRow({ row, onOpenSubagent, onOpenProviderSubagent, - opensInSheet, + sheetTabId, onArchiveSubagent, onDetachSubagent, }: SubagentsTrackRowProps): ReactElement { @@ -269,7 +276,7 @@ function SubagentsTrackRow({ {renderRow} diff --git a/packages/app/src/terminal/character-widths.ts b/packages/app/src/terminal/character-widths.ts new file mode 100644 index 0000000000..efb64da6d8 --- /dev/null +++ b/packages/app/src/terminal/character-widths.ts @@ -0,0 +1,9 @@ +/** + * The table a terminal buffer measures characters with. + * + * The daemon answers cursor reports and replays the screen from its own buffer, + * so the app measures with the table the host advertises. `graphemes` gives each + * emoji cluster one two-column cell. `legacy` is what each renderer used before + * hosts advertised grapheme widths. See docs/terminal-rendering.md. + */ +export type TerminalCharacterWidths = "graphemes" | "legacy"; diff --git a/packages/app/src/terminal/hooks/use-workspace-terminal-session-retention.ts b/packages/app/src/terminal/hooks/use-workspace-terminal-session-retention.ts deleted file mode 100644 index 506a5baab3..0000000000 --- a/packages/app/src/terminal/hooks/use-workspace-terminal-session-retention.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useEffect } from "react"; -import { - releaseWorkspaceTerminalSession, - retainWorkspaceTerminalSession, -} from "@/terminal/runtime/workspace-terminal-session"; - -export function useWorkspaceTerminalSessionRetention(input: { scopeKey: string | null }): void { - useEffect(() => { - if (!input.scopeKey) { - return; - } - - retainWorkspaceTerminalSession({ scopeKey: input.scopeKey }); - return () => { - releaseWorkspaceTerminalSession({ scopeKey: input.scopeKey! }); - }; - }, [input.scopeKey]); -} diff --git a/packages/app/src/terminal/native-renderer/colors.ts b/packages/app/src/terminal/native-renderer/colors.ts index dfebe4eb4d..3804660b56 100644 --- a/packages/app/src/terminal/native-renderer/colors.ts +++ b/packages/app/src/terminal/native-renderer/colors.ts @@ -1,6 +1,6 @@ import type { ITheme } from "@xterm/xterm"; -import type { TextStyle } from "react-native"; import type { TerminalCell } from "@getpaseo/protocol/messages"; +import { PASEO_TERMINAL_STYLE_FLAGS } from "../../../modules/paseo-terminal/contract"; import { darkTheme } from "@/styles/theme"; import { toXtermTheme } from "@/utils/to-xterm-theme"; @@ -29,77 +29,114 @@ const DEFAULT_COLOR_MODE = 0; const ANSI_COLOR_MODE = 1; const INDEXED_COLOR_MODE = 2; const RGB_COLOR_MODE = 3; -const FLAG_BOLD = 1 << 0; -const FLAG_ITALIC = 1 << 1; -const FLAG_UNDERLINE = 1 << 2; -const FLAG_DIM = 1 << 3; -const FLAG_INVERSE = 1 << 4; -const FLAG_STRIKETHROUGH = 1 << 5; +const OPAQUE_BLACK = 0xff000000; +const DIM_ALPHA = 0.65; -interface ResolvedCellStyle { - key: string; - style: TextStyle; - foregroundColor: string; -} - -interface ResolvedCellColors { - foreground: string; - background: string; - paintsBackground: boolean; +/** Colors are unsigned ARGB. A background of 0 is the terminal background. */ +export interface ResolvedCellStyle { + foreground: number; + background: number; + flags: number; } export interface TerminalCellStyleResolver { - readonly themeKey: string; - readonly backgroundColor: string; - readonly cursorColor: string; + readonly backgroundColor: number; + readonly cursorColor: number; resolve(cell: TerminalCell): ResolvedCellStyle; } export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal); export const NATIVE_TERMINAL_SELECTION_COLORS = { - background: "#ffff00", - foreground: "#000000", + background: 0x472684ff, } as const; -function build256Palette(theme: ITheme): string[] { - const palette = Array.from({ length: 256 }, () => ""); +function build256Palette(theme: ITheme): number[] { + const palette = Array.from({ length: 256 }, () => OPAQUE_BLACK); for (let index = 0; index < ANSI_THEME_KEYS.length; index += 1) { const key = ANSI_THEME_KEYS[index]; - palette[index] = theme[key] ?? DEFAULT_TERMINAL_THEME[key] ?? "#000000"; + palette[index] = parseTerminalColor(theme[key] ?? DEFAULT_TERMINAL_THEME[key], OPAQUE_BLACK); } for (let red = 0; red < CUBE_LEVELS.length; red += 1) { for (let green = 0; green < CUBE_LEVELS.length; green += 1) { for (let blue = 0; blue < CUBE_LEVELS.length; blue += 1) { const index = 16 + red * 36 + green * 6 + blue; - palette[index] = `rgb(${CUBE_LEVELS[red]}, ${CUBE_LEVELS[green]}, ${CUBE_LEVELS[blue]})`; + palette[index] = rgbColor( + (CUBE_LEVELS[red] << 16) | (CUBE_LEVELS[green] << 8) | CUBE_LEVELS[blue], + ); } } } for (let index = 0; index < 24; index += 1) { const value = 8 + index * 10; - palette[232 + index] = `rgb(${value}, ${value}, ${value})`; + palette[232 + index] = rgbColor((value << 16) | (value << 8) | value); } return palette; } -function rgbColor(value: number): string { - const red = (value >> 16) & 0xff; - const green = (value >> 8) & 0xff; - const blue = value & 0xff; - return `rgb(${red}, ${green}, ${blue})`; +function rgbColor(value: number): number { + return (OPAQUE_BLACK | (value & 0xffffff)) >>> 0; +} + +function argb(input: { alpha: number; red: number; green: number; blue: number }): number { + const channel = (value: number) => Math.max(0, Math.min(255, Math.round(value))); + return ( + ((channel(input.alpha) << 24) | + (channel(input.red) << 16) | + (channel(input.green) << 8) | + channel(input.blue)) >>> + 0 + ); +} + +/** Parses the CSS color forms terminal themes use: `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`. */ +export function parseTerminalColor(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const color = value.trim().toLowerCase(); + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(color)?.[1]; + if (hex) { + const digits = hex.length === 3 ? [...hex].map((digit) => digit + digit).join("") : hex; + const channel = (offset: number) => Number.parseInt(digits.slice(offset, offset + 2), 16); + return argb({ + alpha: digits.length === 8 ? channel(6) : 255, + red: channel(0), + green: channel(2), + blue: channel(4), + }); + } + const functional = /^rgba?\(([^)]*)\)$/.exec(color)?.[1]; + if (functional) { + const parts = functional + .split(/[\s,/]+/) + .filter(Boolean) + .map(Number); + if (parts.length >= 3 && parts.every((part) => Number.isFinite(part))) { + return argb({ + alpha: parts.length >= 4 ? parts[3] * 255 : 255, + red: parts[0], + green: parts[1], + blue: parts[2], + }); + } + } + return fallback; +} + +function withDimAlpha(color: number): number { + const alpha = (color >>> 24) & 0xff; + return (((Math.round(alpha * DIM_ALPHA) & 0xff) << 24) | (color & 0xffffff)) >>> 0; } function resolveColor(input: { value: number | undefined; mode: number | undefined; - fallback: string; - palette: string[]; -}): string { + fallback: number; + palette: number[]; +}): number { const mode = input.mode ?? DEFAULT_COLOR_MODE; const value = input.value ?? 0; @@ -114,131 +151,50 @@ function resolveColor(input: { function cellFlags(cell: TerminalCell): number { let flags = 0; - if (cell.bold) flags |= FLAG_BOLD; - if (cell.italic) flags |= FLAG_ITALIC; - if (cell.underline) flags |= FLAG_UNDERLINE; - if (cell.dim) flags |= FLAG_DIM; - if (cell.inverse) flags |= FLAG_INVERSE; - if (cell.strikethrough) flags |= FLAG_STRIKETHROUGH; + if (cell.bold) flags |= PASEO_TERMINAL_STYLE_FLAGS.bold; + if (cell.italic) flags |= PASEO_TERMINAL_STYLE_FLAGS.italic; + if (cell.underline) flags |= PASEO_TERMINAL_STYLE_FLAGS.underline; + if (cell.strikethrough) flags |= PASEO_TERMINAL_STYLE_FLAGS.strikethrough; + if (cell.dim) flags |= PASEO_TERMINAL_STYLE_FLAGS.dim; return flags; } -function resolveCellColors(input: { - cell: TerminalCell; - palette: string[]; - foregroundColor: string; - backgroundColor: string; -}): ResolvedCellColors { - const foreground = resolveColor({ - value: input.cell.fg, - mode: input.cell.fgMode, - fallback: input.foregroundColor, - palette: input.palette, - }); - const background = resolveColor({ - value: input.cell.bg, - mode: input.cell.bgMode, - fallback: input.backgroundColor, - palette: input.palette, - }); - - const resolvedForeground = input.cell.inverse ? background : foreground; - const resolvedBackground = input.cell.inverse ? foreground : background; - return { - foreground: resolvedForeground, - background: resolvedBackground, - paintsBackground: resolvedBackground !== input.backgroundColor, - }; -} - -function textDecorationLine(flags: number): TextStyle["textDecorationLine"] | undefined { - const underline = (flags & FLAG_UNDERLINE) !== 0; - const strikethrough = (flags & FLAG_STRIKETHROUGH) !== 0; - if (underline && strikethrough) return "underline line-through"; - if (underline) return "underline"; - if (strikethrough) return "line-through"; - return undefined; -} - -function createTextStyle(input: { - foreground: string; - background: string; - paintsBackground: boolean; - flags: number; -}): TextStyle { - const style: TextStyle = { - color: input.foreground, - }; - - if (input.paintsBackground) { - style.backgroundColor = input.background; - } - if ((input.flags & FLAG_BOLD) !== 0) { - style.fontWeight = "700"; - } - if ((input.flags & FLAG_ITALIC) !== 0) { - style.fontStyle = "italic"; - } - if ((input.flags & FLAG_DIM) !== 0) { - style.opacity = 0.65; - } - - const decorationLine = textDecorationLine(input.flags); - if (decorationLine) { - style.textDecorationLine = decorationLine; - style.textDecorationColor = input.foreground; - } - - return style; -} - -function buildStyleKey(input: { - foreground: string; - background: string; - paintsBackground: boolean; - flags: number; -}): string { - const background = input.paintsBackground ? input.background : "default"; - return `${input.foreground}|${background}|${input.flags}`; -} - -function buildThemeKey(theme: ITheme): string { - return JSON.stringify(theme); -} - export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyleResolver { const palette = build256Palette(theme); - const foregroundColor = theme.foreground ?? DEFAULT_TERMINAL_THEME.foreground ?? "#fafafa"; - const backgroundColor = theme.background ?? DEFAULT_TERMINAL_THEME.background ?? "#181B1A"; - const cursorColor = theme.cursor ?? foregroundColor; - const styleCache = new Map(); + const foregroundColor = parseTerminalColor( + theme.foreground ?? DEFAULT_TERMINAL_THEME.foreground, + 0xfffafafa, + ); + const backgroundColor = parseTerminalColor( + theme.background ?? DEFAULT_TERMINAL_THEME.background, + 0xff181b1a, + ); + const cursorColor = parseTerminalColor(theme.cursor, foregroundColor); return { - themeKey: buildThemeKey(theme), backgroundColor, cursorColor, resolve(cell: TerminalCell): ResolvedCellStyle { + const foreground = resolveColor({ + value: cell.fg, + mode: cell.fgMode, + fallback: foregroundColor, + palette, + }); + const background = resolveColor({ + value: cell.bg, + mode: cell.bgMode, + fallback: backgroundColor, + palette, + }); + const resolvedForeground = cell.inverse ? background : foreground; + const resolvedBackground = cell.inverse ? foreground : background; const flags = cellFlags(cell); - const colors = resolveCellColors({ cell, palette, foregroundColor, backgroundColor }); - const key = buildStyleKey({ - foreground: colors.foreground, - background: colors.background, - paintsBackground: colors.paintsBackground, + return { + foreground: cell.dim ? withDimAlpha(resolvedForeground) : resolvedForeground, + background: resolvedBackground === backgroundColor ? 0 : resolvedBackground, flags, - }); - const cachedStyle = styleCache.get(key); - if (cachedStyle) { - return { key, style: cachedStyle, foregroundColor: colors.foreground }; - } - - const style = createTextStyle({ - foreground: colors.foreground, - background: colors.background, - paintsBackground: colors.paintsBackground, - flags, - }); - styleCache.set(key, style); - return { key, style, foregroundColor: colors.foreground }; + }; }, }; } diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts index fb94588a01..4d238b98c6 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.test.ts @@ -1,8 +1,8 @@ +import { createTerminalResizePolicy, updateTerminalResizePolicy } from "./terminal-resize-policy"; import { describe, expect, test } from "vitest"; import { createNativeHeadlessTerminal, - type TerminalCellRow, type TerminalViewportState, } from "./headless-terminal-state"; import { createNativeTerminalBenchmarkPayload } from "./native-terminal-benchmark-payload"; @@ -11,7 +11,8 @@ import { NATIVE_TERMINAL_PARSE_CHUNK_CHARS, } from "./headless-terminal-output-drain"; import { createNativeTerminalScreenModel, type TerminalScreenState } from "./terminal-screen-model"; -import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot"; +import { Terminal as ReplaySource } from "@xterm/headless"; +import { SerializeAddon } from "@xterm/addon-serialize"; const ROWS = 12; const COLS = 80; @@ -41,33 +42,6 @@ function createFrameRecorder() { }; } -function lineCells(line: string, cols: number): TerminalCellRow { - const cells: TerminalCellRow = []; - for (let index = 0; index < cols; index += 1) { - cells.push({ char: line[index] ?? " ", width: 1 }); - } - return cells; -} - -function terminalStateFromLines(input: { - lines: string[]; - rows: number; - cols: number; - cursor: TerminalViewportState["cursor"]; -}) { - const grid: TerminalCellRow[] = []; - for (let row = 0; row < input.rows; row += 1) { - grid.push(lineCells(input.lines[row] ?? "", input.cols)); - } - return { - rows: input.rows, - cols: input.cols, - grid, - scrollback: [], - cursor: input.cursor, - }; -} - async function runScheduledWork(input: { callbacks: Array<() => void>; flush: Promise; @@ -80,11 +54,13 @@ async function runScheduledWork(input: { } async function benchmarkSnapshot(lineCount: number) { - const terminal = createNativeHeadlessTerminal({ rows: ROWS, cols: COLS, scrollbackLines: 100 }); - await terminal.write( - createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }), - ); - return terminal.getState(); + return { + rows: ROWS, + cols: COLS, + data: new TextEncoder().encode( + createNativeTerminalBenchmarkPayload({ startLine: 0, lineCount, cols: COLS }), + ), + }; } function createScreenRestorer( @@ -94,6 +70,7 @@ function createScreenRestorer( const frameRecorder = createFrameRecorder(); let screen: TerminalScreenState | null = null; const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: (chunk) => terminal.write(chunk), reset: () => terminal.reset(), getViewportState: () => { @@ -107,7 +84,12 @@ function createScreenRestorer( return { async restore(snapshot: Awaited>) { - drain.restoreSnapshot(snapshot); + drain.replay({ type: "begin", rows: snapshot.rows, cols: snapshot.cols }); + drain.replay({ + type: "data", + data: snapshot.data, + }); + drain.replay({ type: "end" }); await drain.flush(); frameRecorder.frames[0]?.(); return screen; @@ -122,6 +104,7 @@ describe("native terminal output drain", () => { const frameRecorder = createFrameRecorder(); const payload = "x".repeat(NATIVE_TERMINAL_PARSE_CHUNK_CHARS * 2); const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: async (chunk) => { writes.push(chunk); }, @@ -170,6 +153,7 @@ describe("native terminal output drain", () => { const paintedStates: TerminalViewportState[] = []; const frameRecorder = createFrameRecorder(); const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: (chunk) => terminal.write(chunk), reset: () => terminal.reset(), getViewportState: () => terminal.getViewportState(), @@ -202,6 +186,7 @@ describe("native terminal output drain", () => { const scheduledWork: Array<() => void> = []; const frameRecorder = createFrameRecorder(); const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, write: (chunk) => terminal.write(chunk), reset: () => terminal.reset(), getViewportState: () => terminal.getViewportState(), @@ -210,21 +195,22 @@ describe("native terminal output drain", () => { scheduleFrame: frameRecorder.scheduleFrame, cancelFrame: frameRecorder.cancelFrame, }); - const restoredSnapshot = terminalStateFromLines({ - rows: 4, - cols: 32, - lines: ["restored-one", "restored-two"], - cursor: { row: 1, col: "restored-two".length }, - }); + const restoredCursor = { row: 1, col: "restored-two".length }; + const replay = new TextEncoder().encode("restored-one\r\nrestored-two"); await terminal.write("old-one\r\nold-two\r\nold-three\r\n"); drain.enqueueText("stale-queued\r\n"); - drain.restoreText(renderTerminalSnapshotToAnsi(restoredSnapshot)); + drain.replay({ type: "begin" }); + drain.replay({ + type: "data", + data: replay, + }); + drain.replay({ type: "end" }); await runScheduledWork({ callbacks: scheduledWork, flush: drain.flush() }); const state = terminal.getViewportState(); expect(state.grid.map(rowText)).toEqual(["restored-one", "restored-two", "", ""]); - expect(state.cursor).toEqual(restoredSnapshot.cursor); + expect(state.cursor).toEqual(restoredCursor); }); test("snapshot restore preserves a scrolled viewport anchor while replacing terminal state", async () => { @@ -242,7 +228,7 @@ describe("native terminal output drain", () => { paintedMode: screen?.scroll.mode, paintedTopRow: rowText(screen?.viewport.grid[0] ?? []), beforeTopRow, - restoredBottomRows: terminal.getState().grid.slice(-2).map(rowText), + restoredBottomRows: terminal.getViewportState().grid.slice(-2).map(rowText), }).toEqual({ paintedMode: "scrolled", paintedTopRow: beforeTopRow, @@ -268,3 +254,255 @@ describe("native terminal output drain", () => { }); }); }); + +test("replays split Unicode and ANSI with one reset then live output", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 40, scrollbackLines: 1000 }); + let resets = 0; + const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, + write: (text) => terminal.write(text), + reset: (size) => { + resets++; + if (size) terminal.resize(size); + terminal.reset(); + }, + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 4, cols: 40, retainedHistoryLines: 400 }); + const bytes = new TextEncoder().encode("\x1b[31m界🙂 replay\x1b[0m\r\n"); + for (let i = 0; i < bytes.length; i++) + drain.replay({ type: "data", data: bytes.subarray(i, i + 1) }); + drain.replay({ type: "end" }); + drain.enqueueText("live"); + await drain.flush(); + expect(resets).toBe(1); + expect( + terminal + .getBufferWindow({ startRow: 0, rowCount: 4 }) + .rows.map((row) => + row + .filter((cell) => cell.width !== 0) + .map((cell) => cell.char) + .join(""), + ) + .join("\n"), + ).toContain("界🙂 replay"); + expect( + terminal + .getBufferWindow({ startRow: 0, rowCount: 4 }) + .rows.map((row) => + row + .filter((cell) => cell.width !== 0) + .map((cell) => cell.char) + .join(""), + ) + .join("\n"), + ).toContain("live"); + } finally { + drain.dispose(); + terminal.dispose(); + } +}); + +test("an empty replacement replay resets after an in-flight write finishes", async () => { + let finish!: () => void; + let started!: () => void; + const writing = new Promise((resolve) => { + started = resolve; + }); + const blocked = new Promise((resolve) => { + finish = resolve; + }); + let resets = 0; + const drain = createNativeTerminalOutputDrain({ + restoreSize: () => {}, + write: async () => { + started(); + await blocked; + }, + reset: () => { + resets++; + }, + getViewportState: () => null, + onPaint: () => {}, + }); + try { + drain.enqueueText("old terminal"); + await writing; + drain.replay({ type: "begin" }); + drain.replay({ type: "end" }); + finish(); + await drain.flush(); + expect(resets).toBe(1); + expect(drain.getQueuedChars()).toBe(0); + } finally { + finish(); + drain.dispose(); + } +}); + +test("replay geometry returns to measured layout before live writes and unchanged-layout focus", async () => { + const measured = { rows: 24, cols: 40 }; + const terminal = createNativeHeadlessTerminal({ ...measured, scrollbackLines: 1000 }); + const policy = createTerminalResizePolicy(measured); + const sizes: number[] = []; + const drain = createNativeTerminalOutputDrain({ + write: async (text) => { + sizes.push(terminal.getViewportState().cols); + await terminal.write(text); + }, + reset: (size) => { + if (size) terminal.resize(size); + terminal.reset(); + }, + restoreSize: () => terminal.resize(measured), + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 30, cols: 120 }); + drain.replay({ type: "data", data: new TextEncoder().encode("x".repeat(100) + "\r\n") }); + drain.replay({ type: "end" }); + drain.enqueueText("LIVE-" + "y".repeat(60)); + await drain.flush(); + expect(sizes).toEqual([120, 40]); + const focus = updateTerminalResizePolicy(policy, { + source: "claim", + size: measured, + claimToken: 1, + }); + expect(focus.measuredSizeChanged).toBe(false); + expect(focus.resizeClaim).toMatchObject(measured); + expect(terminal.getViewportState()).toMatchObject(measured); + expect(terminal.getBufferBounds().cols).toBe(40); + const lines = terminal + .getViewportState() + .grid.map((row) => + row + .map((cell) => cell.char) + .join("") + .trimEnd(), + ) + .join("\n"); + expect(lines).toContain("LIVE-" + "y".repeat(35) + "\n" + "y".repeat(25)); + } finally { + drain.dispose(); + terminal.dispose(); + } +}); + +test.each(["clear", "replacement", "dispose"] as const)( + "cancels queued replay geometry on %s", + async (action) => { + const local = { rows: 24, cols: 40 }; + const terminal = createNativeHeadlessTerminal({ ...local, scrollbackLines: 1000 }); + let finish!: () => void; + const block = new Promise((resolve) => { + finish = resolve; + }); + let started!: () => void; + const writing = new Promise((resolve) => { + started = resolve; + }); + let first = true; + let restores = 0; + const drain = createNativeTerminalOutputDrain({ + restoreSize: () => { + restores++; + terminal.resize(local); + }, + reset: (size) => { + if (size) terminal.resize(size); + terminal.reset(); + }, + write: async (text) => { + await terminal.write(text); + if (first) { + first = false; + started(); + await block; + } + }, + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 30, cols: 120 }); + drain.replay({ type: "data", data: new TextEncoder().encode("old") }); + await writing; + local.cols = 60; + drain.resize(); + expect(terminal.getViewportState().cols).toBe(120); + drain.replay({ type: "end" }); + if (action === "clear") drain.clear(); + else if (action === "dispose") { + drain.dispose(); + drain.clear(); + } else { + drain.replay({ type: "begin", rows: 20, cols: 80 }); + drain.replay({ type: "data", data: new TextEncoder().encode("new") }); + drain.replay({ type: "end" }); + } + finish(); + await drain.flush(); + expect(restores).toBe(action === "dispose" ? 0 : 1); + if (action !== "dispose") expect(terminal.getViewportState()).toMatchObject(local); + } finally { + finish(); + drain.dispose(); + terminal.dispose(); + } + }, +); + +test("holds captured geometry across mid-replay measurement changes", async () => { + const source = new ReplaySource({ rows: 4, cols: 8, scrollback: 100, allowProposedApi: true }); + const serializer = new SerializeAddon(); + source.loadAddon(serializer); + await new Promise((resolve) => + source.write("ABCDEFGH\r\n12345678\r\nabcdefgh\r\nxyz\x1b[2;8H", resolve), + ); + const ansi = new TextEncoder().encode(serializer.serialize()); + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 }); + const expected = createNativeHeadlessTerminal({ rows: 4, cols: 8, scrollbackLines: 100 }); + await expected.write(new TextDecoder().decode(ansi)); + expected.resize({ rows: 4, cols: 4 }); + let measured = { rows: 4, cols: 8 }; + const drain = createNativeTerminalOutputDrain({ + write: (chunk) => terminal.write(chunk), + reset: (size) => { + if (size) terminal.resize(size); + terminal.reset(); + }, + restoreSize: () => terminal.resize(measured), + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 4, cols: 8 }); + await drain.flush(); + measured = { rows: 4, cols: 4 }; + drain.resize(); + drain.replay({ type: "data", data: ansi }); + drain.replay({ type: "end" }); + await drain.flush(); + expect(terminal.getViewportState()).toEqual(expected.getViewportState()); + const actualBounds = terminal.getBufferBounds(); + const expectedBounds = expected.getBufferBounds(); + expect( + terminal.getBufferWindow({ + startRow: actualBounds.oldestRow, + rowCount: actualBounds.newestRow - actualBounds.oldestRow + 1, + }), + ).toEqual( + expected.getBufferWindow({ + startRow: expectedBounds.oldestRow, + rowCount: expectedBounds.newestRow - expectedBounds.oldestRow + 1, + }), + ); + } finally { + drain.dispose(); + } +}); diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts index d853165371..00b9865be9 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-output-drain.ts @@ -1,5 +1,3 @@ -import type { TerminalState } from "@getpaseo/protocol/messages"; -import { renderTerminalSnapshotToAnsi } from "../runtime/terminal-snapshot"; import type { TerminalViewportState } from "./headless-terminal-state"; import { nativeTerminalPerformanceNow } from "./terminal-performance"; @@ -14,9 +12,9 @@ export interface NativeTerminalParseSample { } export interface NativeTerminalOutputDrain { + replay(event: import("@getpaseo/client").TerminalReplayEvent): void; enqueueText(text: string): void; - restoreText(text: string): void; - restoreSnapshot(snapshot: TerminalState): void; + resize(): void; clear(): void; flush(): Promise; dispose(): void; @@ -25,7 +23,8 @@ export interface NativeTerminalOutputDrain { export interface NativeTerminalOutputDrainOptions { write: (chunk: string) => Promise; - reset: () => void; + restoreSize: () => void; + reset: (size?: { rows: number; cols: number }) => void; getViewportState: () => TerminalViewportState | null; onPaint: (state: TerminalViewportState) => void; onParse?: (sample: NativeTerminalParseSample) => void; @@ -68,16 +67,22 @@ export function createNativeTerminalOutputDrain( const chunkChars = options.chunkChars ?? NATIVE_TERMINAL_PARSE_CHUNK_CHARS; const chunksPerTurn = options.chunksPerTurn ?? NATIVE_TERMINAL_PARSE_CHUNKS_PER_TURN; const idleWaiters: Array<() => void> = []; - let pendingText = ""; + type Operation = + | { type: "write"; text: string } + | { type: "reset"; size?: { rows: number; cols: number } } + | { type: "restore-size" }; + let operations: Operation[] = []; + let queuedChars = 0; let drainScheduled = false; let draining = false; let disposed = false; + let replaying = false; let paintFrame: number | null = null; let generation = 0; - let resetBeforeNextWrite = false; + const replayDecoder = new TextDecoder(); function resolveIdleIfReady(): void { - if (drainScheduled || draining || pendingText.length > 0) { + if (drainScheduled || draining || operations.length > 0) { return; } while (idleWaiters.length > 0) { @@ -119,15 +124,6 @@ export function createNativeTerminalOutputDrain( }); } - function replacePendingText(text: string): void { - generation += 1; - pendingText = text; - resetBeforeNextWrite = true; - cancelPendingPaint(); - scheduleDrain(); - resolveIdleIfReady(); - } - async function drainPending(): Promise { if (disposed || draining) { return; @@ -138,16 +134,23 @@ export function createNativeTerminalOutputDrain( let parsedChunks = 0; try { - while (pendingText.length > 0 && parsedChunks < chunksPerTurn) { - if (disposed || activeGeneration !== generation) { - return; + while (operations.length > 0 && parsedChunks < chunksPerTurn) { + if (disposed || activeGeneration !== generation) return; + const operation = operations[0]!; + if (operation.type !== "write") { + operations.shift(); + if (operation.type === "reset") options.reset(operation.size); + else { + options.restoreSize(); + replaying = false; + } + requestPaint(); + continue; } - if (resetBeforeNextWrite) { - options.reset(); - resetBeforeNextWrite = false; - } - const chunk = pendingText.slice(0, chunkChars); - pendingText = pendingText.slice(chunk.length); + const chunk = operation.text.slice(0, chunkChars); + operation.text = operation.text.slice(chunk.length); + queuedChars -= chunk.length; + if (!operation.text) operations.shift(); const parseStart = nativeTerminalPerformanceNow(); await options.write(chunk); if (disposed || activeGeneration !== generation) { @@ -156,14 +159,14 @@ export function createNativeTerminalOutputDrain( options.onParse?.({ durationMs: nativeTerminalPerformanceNow() - parseStart, parsedChars: chunk.length, - queuedChars: pendingText.length, + queuedChars, }); requestPaint(); parsedChunks += 1; } } finally { draining = false; - if (!disposed && pendingText.length > 0) { + if (!disposed && operations.length > 0) { scheduleDrain(); } resolveIdleIfReady(); @@ -171,38 +174,56 @@ export function createNativeTerminalOutputDrain( } return { - enqueueText(text: string): void { - if (disposed || text.length === 0) { - return; + replay(event): void { + if (disposed) return; + if (event.type === "begin") { + replaying = true; + generation++; + replayDecoder.decode(); + operations = [ + { + type: "reset", + size: event.rows && event.cols ? { rows: event.rows, cols: event.cols } : undefined, + }, + ]; + queuedChars = 0; + cancelPendingPaint(); + } else if (event.type === "data") { + this.enqueueText(replayDecoder.decode(event.data, { stream: true })); + } else { + this.enqueueText(replayDecoder.decode()); + operations.push({ type: "restore-size" }); } - pendingText += text; + scheduleDrain(); + }, + enqueueText(text: string): void { + if (disposed || !text) return; + const last = operations.at(-1); + if (last?.type === "write") last.text += text; + else operations.push({ type: "write", text }); + queuedChars += text.length; scheduleDrain(); }, - restoreText(text: string): void { - if (disposed) { - return; - } - replacePendingText(text); - }, - - restoreSnapshot(snapshot: TerminalState): void { - if (disposed) { - return; - } - replacePendingText(renderTerminalSnapshotToAnsi(snapshot)); + resize(): void { + if (disposed || replaying) return; + operations.push({ type: "restore-size" }); + scheduleDrain(); }, clear(): void { + if (disposed) return; generation += 1; - pendingText = ""; - resetBeforeNextWrite = false; + operations = [{ type: "restore-size" }, { type: "reset" }]; + queuedChars = 0; + replayDecoder.decode(); cancelPendingPaint(); + scheduleDrain(); resolveIdleIfReady(); }, flush(): Promise { - if (!drainScheduled && !draining && pendingText.length === 0) { + if (!drainScheduled && !draining && operations.length === 0) { return Promise.resolve(); } scheduleDrain(); @@ -214,8 +235,8 @@ export function createNativeTerminalOutputDrain( dispose(): void { disposed = true; generation += 1; - pendingText = ""; - resetBeforeNextWrite = false; + operations = []; + queuedChars = 0; cancelPendingPaint(); while (idleWaiters.length > 0) { idleWaiters.shift()?.(); @@ -223,7 +244,7 @@ export function createNativeTerminalOutputDrain( }, getQueuedChars(): number { - return pendingText.length; + return queuedChars; }, }; } diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts b/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts index c1025568c9..546982906f 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-state.test.ts @@ -159,4 +159,52 @@ describe("native headless terminal state", () => { { char: "B", width: 1 }, ]); }); + + // Apple Color Emoji and Noto Color Emoji are two cells wide. The native + // renderer scales a cluster to its column slot, so a one-column emoji + // renders at half size. Each emoji cluster must occupy one wide cell. + test.each([ + ["presentation emoji", "😀"], + ["thumbs up", "👍"], + ["check mark", "✅"], + ["VS16 heart", "❤️"], + ["skin-tone modifier", "👋🏽"], + ["regional-indicator flag", "🇰🇷"], + ["ZWJ family", "👨‍👩‍👧"], + ["ZWJ technologist", "👩‍💻"], + ])("gives a %s cluster one two-column cell", async (_name, cluster) => { + const terminal = createNativeHeadlessTerminal({ rows: 2, cols: 12 }); + + await terminal.write(`A${cluster}B`); + const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows; + + expect(row.slice(0, 4).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([ + { char: "A", width: 1 }, + { char: cluster, width: 2 }, + { char: " ", width: 0 }, + { char: "B", width: 1 }, + ]); + expect(terminal.getViewportState().cursor.col).toBe(4); + }); + + // A host without grapheme widths answers cursor reports and replays with + // xterm's default table. The native buffer matches it so the cursor lands + // where the host put it. + test("measures with xterm's default table for hosts without grapheme widths", async () => { + const terminal = createNativeHeadlessTerminal({ + rows: 2, + cols: 12, + characterWidths: "legacy", + }); + + await terminal.write("A😀B"); + const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows; + + expect(row.slice(0, 3).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([ + { char: "A", width: 1 }, + { char: "😀", width: 1 }, + { char: "B", width: 1 }, + ]); + expect(terminal.getViewportState().cursor.col).toBe(3); + }); }); diff --git a/packages/app/src/terminal/native-renderer/headless-terminal-state.ts b/packages/app/src/terminal/native-renderer/headless-terminal-state.ts index c25399c9b2..4347588861 100644 --- a/packages/app/src/terminal/native-renderer/headless-terminal-state.ts +++ b/packages/app/src/terminal/native-renderer/headless-terminal-state.ts @@ -1,3 +1,5 @@ +import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"; +import type { TerminalCharacterWidths } from "../character-widths"; import { Terminal as HeadlessTerminal, type IBufferCell, @@ -17,11 +19,12 @@ export interface NativeHeadlessTerminalOptions { rows: number; cols: number; scrollbackLines?: number; + /** Defaults to `graphemes`, the table current daemons use. */ + characterWidths?: TerminalCharacterWidths; } export interface NativeHeadlessTerminal { write(data: NativeTerminalWriteData): Promise; - getState(): TerminalState; getViewportState(input?: TerminalViewportStateInput): TerminalViewportState; getInputModeState(): NativeHeadlessTerminalInputModeState; getBufferBounds(): TerminalBufferBounds; @@ -165,35 +168,6 @@ function extractRow( return rowCells; } -function extractGrid(terminal: HeadlessTerminalInstance): TerminalCellRow[] { - const grid: TerminalCellRow[] = []; - const baseY = terminal.buffer.active.baseY; - const reusableCell = terminal.buffer.active.getNullCell(); - for (let row = 0; row < terminal.rows; row += 1) { - grid.push(extractRow(terminal, baseY + row, reusableCell)); - } - return grid; -} - -function extractScrollback( - terminal: HeadlessTerminalInstance, - options: { scrollbackLines?: number }, -): TerminalCellRow[] { - const scrollback: TerminalCellRow[] = []; - const scrollbackLines = terminal.buffer.active.baseY; - const startRow = - typeof options.scrollbackLines === "number" - ? Math.max(0, scrollbackLines - options.scrollbackLines) - : 0; - const reusableCell = terminal.buffer.active.getNullCell(); - - for (let row = startRow; row < scrollbackLines; row += 1) { - scrollback.push(extractRow(terminal, row, reusableCell)); - } - - return scrollback; -} - function extractBufferWindow( terminal: HeadlessTerminalInstance, oldestRow: number, @@ -290,19 +264,6 @@ function extractInputModeState( }; } -function extractState( - terminal: HeadlessTerminalInstance, - options: { scrollbackLines?: number }, -): TerminalState { - return { - rows: terminal.rows, - cols: terminal.cols, - grid: extractGrid(terminal), - scrollback: extractScrollback(terminal, options), - cursor: extractCursorState(terminal), - }; -} - function extractViewportState( terminal: HeadlessTerminalInstance, coordinateEpoch: number, @@ -340,6 +301,12 @@ export function createNativeHeadlessTerminal( scrollback: options.scrollbackLines ?? 1000, allowProposedApi: true, }); + // Grapheme widths give each emoji cluster one two-column cell, the width the + // native renderer draws it at. Legacy hosts measure with xterm's default + // table, so their cursor reports and replay stay consistent with it. + if ((options.characterWidths ?? "graphemes") === "graphemes") { + terminal.loadAddon(new UnicodeGraphemesAddon()); + } const decoder = new TextDecoder(); let coordinateEpoch = 0; const coordinateStorage = new Map(); @@ -380,9 +347,6 @@ export function createNativeHeadlessTerminal( }); }); }, - getState(): TerminalState { - return extractState(terminal, { scrollbackLines: options.scrollbackLines }); - }, getViewportState(input?: TerminalViewportStateInput): TerminalViewportState { return extractViewportState( terminal, diff --git a/packages/app/src/terminal/native-renderer/terminal-frame.test.ts b/packages/app/src/terminal/native-renderer/terminal-frame.test.ts new file mode 100644 index 0000000000..850fd4d183 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-frame.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, test } from "vitest"; + +import graphemeRows from "../../../modules/paseo-terminal/android/src/androidTest/assets/grapheme-rows.json"; +import { PASEO_TERMINAL_STYLE_FLAGS } from "../../../modules/paseo-terminal/contract"; +import { encodeTerminalFrame } from "../../../modules/paseo-terminal/frame-encoding"; +import { + createTerminalCellStyleResolver, + DEFAULT_TERMINAL_THEME, + parseTerminalColor, +} from "./colors"; +import { createNativeHeadlessTerminal } from "./headless-terminal-state"; +import { buildTerminalFrame } from "./terminal-frame"; +import { createNativeTerminalOutputDrain } from "./headless-terminal-output-drain"; +import { resolveMeasuredNativeTerminalSize } from "./terminal-size-measurement"; + +const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); +const RED = parseTerminalColor(DEFAULT_TERMINAL_THEME.red, 0); +const FOREGROUND = parseTerminalColor(DEFAULT_TERMINAL_THEME.foreground, 0); + +async function frameFor(output: string, options: { cols?: number; rows?: number } = {}) { + const terminal = createNativeHeadlessTerminal({ + cols: options.cols ?? 20, + rows: options.rows ?? 4, + }); + await terminal.write(output); + const state = terminal.getViewportState({ firstRow: 0, rowCount: options.rows ?? 4 }); + terminal.dispose(); + return { + state, + frame: buildTerminalFrame({ state, columns: state.cols, resolver, selection: null }), + }; +} + +function cellLayout(row: { text: string; cells: number[] }) { + const layout: Array<{ text: string; col: number; span: number }> = []; + let offset = 0; + let col = 0; + for (let index = 0; index < row.cells.length; index += 2) { + const [length, span] = [row.cells[index], row.cells[index + 1]]; + layout.push({ text: row.text.slice(offset, offset + length), col, span }); + offset += length; + col += span; + } + return layout; +} + +/** Indexes of the cells that start a grapheme cluster. */ +function clusterStartCells(row: { text: string; cells: number[] }, segmenter: Intl.Segmenter) { + const boundaries = new Set(Array.from(segmenter.segment(row.text), (segment) => segment.index)); + const starts: number[] = []; + let offset = 0; + for (let cell = 0; cell < row.cells.length / 2; cell += 1) { + if (boundaries.has(offset)) starts.push(cell); + offset += row.cells[cell * 2]; + } + return starts; +} + +describe("native terminal frame", () => { + test("keeps each Hangul syllable on xterm's two-column allocation", async () => { + const { frame } = await frameFor("> 안녕하세요"); + + expect(cellLayout(frame.rows[0]).slice(0, 7)).toEqual([ + { text: ">", col: 0, span: 1 }, + { text: " ", col: 1, span: 1 }, + { text: "안", col: 2, span: 2 }, + { text: "녕", col: 4, span: 2 }, + { text: "하", col: 6, span: 2 }, + { text: "세", col: 8, span: 2 }, + { text: "요", col: 10, span: 2 }, + ]); + expect(frame.cursor).toEqual({ row: 0, col: 12 }); + }); + + test("covers every column once, including trailing blanks", async () => { + const corpus = ["안녕하세요NEXT", "日本語 中文", "áé é x̸", "क्ष कि ❤️ 👩‍💻 🇰🇷"]; + const { frame } = await frameFor(corpus.join("\r\n"), { rows: 4, cols: 24 }); + + for (const row of frame.rows) { + const spans = row.cells.filter((_, index) => index % 2 === 1); + const styleColumns = row.styles.filter((_, index) => index % 4 === 0); + expect(spans.reduce((total, span) => total + span, 0)).toBe(24); + expect(styleColumns.reduce((total, span) => total + span, 0)).toBe(24); + expect(row.cells.filter((_, index) => index % 2 === 0).reduce((a, b) => a + b, 0)).toBe( + row.text.length, + ); + } + }); + + test("keeps combining marks and joiners inside the cell xterm assigned them", async () => { + const { frame } = await frameFor("éx̸ 각 क्ष 👩‍💻"); + + expect(cellLayout(frame.rows[0]).slice(0, 9)).toEqual([ + { text: "é", col: 0, span: 1 }, + { text: "x̸", col: 1, span: 1 }, + { text: " ", col: 2, span: 1 }, + { text: "각", col: 3, span: 2 }, + { text: " ", col: 5, span: 1 }, + { text: "क्ष", col: 6, span: 1 }, + { text: " ", col: 7, span: 1 }, + { text: "👩‍💻", col: 8, span: 2 }, + { text: " ", col: 10, span: 1 }, + ]); + }); + + test("splits style spans at SGR changes without moving cells", async () => { + const { frame } = await frameFor("안녕\x1b[31mNEXT\x1b[0m\x1b[1;4m!\x1b[0m"); + const row = frame.rows[0]; + + expect( + cellLayout(row) + .slice(0, 7) + .map((cell) => cell.col), + ).toEqual([0, 2, 4, 5, 6, 7, 8]); + expect(row.styles.slice(0, 12)).toEqual([ + 4, + FOREGROUND, + 0, + 0, + 4, + RED, + 0, + 0, + 1, + FOREGROUND, + 0, + PASEO_TERMINAL_STYLE_FLAGS.bold | PASEO_TERMINAL_STYLE_FLAGS.underline, + ]); + }); + + test("resolves inverse and dim into paint colors", async () => { + const { frame } = await frameFor("\x1b[7mI\x1b[0m\x1b[2mD\x1b[0m"); + const background = parseTerminalColor(DEFAULT_TERMINAL_THEME.background, 0); + + expect(frame.rows[0].styles.slice(0, 8)).toEqual([ + 1, + background, + FOREGROUND, + 0, + 1, + (((Math.round(255 * 0.65) & 0xff) << 24) | (FOREGROUND & 0xffffff)) >>> 0, + 0, + PASEO_TERMINAL_STYLE_FLAGS.dim, + ]); + }); + + test("rows follow content and style but not cursor or selection", async () => { + const plain = await frameFor("same\r\nrow"); + const moved = await frameFor("same\r\nrow\x1b[1;1H"); + const recolored = await frameFor("\x1b[31msame\x1b[0m\r\nrow"); + + expect(moved.frame.rows[0]).toEqual(plain.frame.rows[0]); + expect(moved.frame.cursor).not.toEqual(plain.frame.cursor); + expect(recolored.frame.rows[0]).not.toEqual(plain.frame.rows[0]); + + const selected = buildTerminalFrame({ + state: plain.state, + columns: plain.state.cols, + resolver, + selection: { + start: { row: 0, col: 1 }, + end: { row: 1, col: 0 }, + coordinateEpoch: 0, + }, + }); + expect(selected.rows).toEqual(plain.frame.rows); + expect(selected.selection).toEqual({ startRow: 0, startCol: 1, endRow: 1, endCol: 0 }); + }); + + test("clips the frame to the visible columns", async () => { + const { state } = await frameFor("0123456789abcdefghij"); + const frame = buildTerminalFrame({ state, columns: 8, resolver, selection: null }); + + expect(frame.columns).toBe(8); + expect(frame.rows[0].text).toBe("01234567"); + }); + + // The Android instrumented test draws these rows and compares them with each cluster drawn on its + // own. This keeps its input equal to what xterm produces, and its clusters equal to UAX #29 + // grapheme clusters. `contrastStarts` is a wrong clustering the comparison must reject. + test("Android cluster fixture matches xterm cells and grapheme clusters", async () => { + const segmenter = new Intl.Segmenter("und", { granularity: "grapheme" }); + const actual = await Promise.all( + graphemeRows.map(async ({ output, contrastStarts }) => { + const { frame } = await frameFor(output, { cols: 8, rows: 1 }); + const row = frame.rows[0]; + const [encoded] = encodeTerminalFrame({ ...frame, rows: [row] }).rows; + return { + output, + row: encoded, + clusterStarts: clusterStartCells(row, segmenter), + contrastStarts, + }; + }), + ); + + expect(actual).toEqual(graphemeRows); + }); +}); + +test("builds native frames after replay commits at captured geometry and the latest measurement precedes live text", async () => { + let measured = resolveMeasuredNativeTerminalSize({ + layout: { width: 400, height: 240 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + })!; + const terminal = createNativeHeadlessTerminal({ ...measured, scrollbackLines: 10000 }); + const parsedColumns: number[] = []; + const drain = createNativeTerminalOutputDrain({ + write: async (text) => { + parsedColumns.push(terminal.getViewportState().cols); + await terminal.write(text); + }, + reset: (size) => { + if (size) terminal.resize(size); + terminal.reset(); + }, + restoreSize: () => terminal.resize(measured), + getViewportState: () => terminal.getViewportState(), + onPaint: () => {}, + }); + try { + drain.replay({ type: "begin", rows: 30, cols: 120 }); + drain.replay({ type: "data", data: new TextEncoder().encode("x".repeat(100) + "\r\n") }); + measured = resolveMeasuredNativeTerminalSize({ + layout: { width: 500, height: 100 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + })!; + drain.resize(); + drain.replay({ type: "end" }); + drain.enqueueText("\x1b[H\x1b[2K\x1b[31mLIVE-界🙂\x1b[0m"); + await drain.flush(); + expect(parsedColumns).toEqual([120, 50]); + const state = terminal.getViewportState(); + const frame = buildTerminalFrame({ state, columns: state.cols, resolver, selection: null }); + expect(frame.columns).toBe(50); + expect(frame.rows).toHaveLength(10); + expect(frame.rows[0].text.trimEnd()).toBe("LIVE-界🙂"); + expect(frame.rows[0].styles.slice(0, 4)).toEqual([9, RED, 0, 0]); + expect(encodeTerminalFrame(frame).rows[0]).toContain("LIVE-界🙂"); + } finally { + drain.dispose(); + terminal.dispose(); + } +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-frame.ts b/packages/app/src/terminal/native-renderer/terminal-frame.ts new file mode 100644 index 0000000000..584e09a274 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-frame.ts @@ -0,0 +1,84 @@ +import type { + PaseoTerminalFrame, + PaseoTerminalRow, + PaseoTerminalSelection, +} from "../../../modules/paseo-terminal/contract"; +import type { TerminalCellStyleResolver } from "./colors"; +import type { NativeTerminalCell, TerminalViewportState } from "./headless-terminal-state"; +import type { TerminalSelectionRange } from "./terminal-selection"; + +/** + * Converts the xterm viewport into the native renderer's frame. Every cell keeps its own text and + * xterm's column span; continuation cells of wide characters (width 0) belong to the cell before + * them. Nothing here measures or joins text: the renderer shapes rows and places the result on + * these columns. + */ +export function buildTerminalFrame(input: { + state: TerminalViewportState; + columns: number; + resolver: TerminalCellStyleResolver; + selection: TerminalSelectionRange | null; +}): PaseoTerminalFrame { + const grid = input.state.grid as NativeTerminalCell[][]; + return { + columns: input.columns, + rows: grid.map((cells) => + buildRow({ cells, columns: input.columns, resolver: input.resolver }), + ), + cursor: input.state.cursor.hidden + ? null + : { row: input.state.cursor.row, col: input.state.cursor.col }, + selection: input.selection + ? toFrameSelection({ range: input.selection, firstRow: input.state.firstRow }) + : null, + }; +} + +function buildRow(input: { + cells: NativeTerminalCell[]; + columns: number; + resolver: TerminalCellStyleResolver; +}): PaseoTerminalRow { + let text = ""; + const cells: number[] = []; + const styles: number[] = []; + const lastColumn = Math.min(input.columns, input.cells.length); + + for (let col = 0; col < lastColumn; col += 1) { + const cell = input.cells[col]; + const span = cell.width ?? 1; + if (span <= 0) { + continue; + } + const cellText = cell.char || " "; + text += cellText; + cells.push(cellText.length, span); + + const style = input.resolver.resolve(cell); + const previous = styles.length - 4; + if ( + previous >= 0 && + styles[previous + 1] === style.foreground && + styles[previous + 2] === style.background && + styles[previous + 3] === style.flags + ) { + styles[previous] += span; + } else { + styles.push(span, style.foreground, style.background, style.flags); + } + } + + return { text, cells, styles }; +} + +function toFrameSelection(input: { + range: TerminalSelectionRange; + firstRow: number; +}): PaseoTerminalSelection { + return { + startRow: input.range.start.row - input.firstRow, + startCol: input.range.start.col, + endRow: input.range.end.row - input.firstRow, + endCol: input.range.end.col, + }; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts index 0c4d7bc25f..c0fef99104 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.test.ts @@ -1,68 +1,8 @@ import { describe, expect, it } from "vitest"; -import { - resolveTerminalCustomGlyphCellTransform, - resolveTerminalGridMetricsMeasurement, - resolveMeasuredTerminalCellMetrics, - resolveTerminalCursorOffset, -} from "./terminal-grid-metrics"; +import { resolveTerminalGridMetricsMeasurement } from "./terminal-grid-metrics"; describe("native terminal grid metrics", () => { - it("places each normalized custom glyph in run viewport coordinates", () => { - expect( - resolveTerminalCustomGlyphCellTransform({ - cellOffset: 57, - cellWidth: 7.4, - cellHeight: 16.2, - }), - ).toBe("matrix(7.4 0 0 16.2 421.8 0)"); - }); - - it("preserves fractional measured cell width for horizontal layout", () => { - expect( - resolveMeasuredTerminalCellMetrics({ - measuredTextWidth: 72.246, - measuredTextHeight: 16.2, - measureTextLength: 10, - roundToNearestPixel: (value) => value, - }), - ).toEqual({ - cellWidth: 7.2246, - cellHeight: 16.2, - }); - }); - - it.each([1, 2, 2.75, 3, 3.5])( - "aligns cells to the physical pixel grid at density %s", - (density) => { - const metrics = resolveMeasuredTerminalCellMetrics({ - measuredTextWidth: 72.246, - measuredTextHeight: 16.2, - measureTextLength: 10, - roundToNearestPixel: (value) => Math.round(value * density) / density, - }); - - expect(metrics.cellWidth * density).toBeCloseTo(Math.round(metrics.cellWidth * density), 10); - expect(metrics.cellHeight * density).toBeCloseTo( - Math.round(metrics.cellHeight * density), - 10, - ); - }, - ); - - it("positions the cursor with the measured fractional cell width", () => { - expect( - resolveTerminalCursorOffset({ - cursorCol: 80, - cursorRow: 3, - metrics: { cellWidth: 7.2246, cellHeight: 16 }, - }), - ).toEqual({ - x: 577.968, - y: 48, - }); - }); - it("emits measured metrics once until the measurement changes", () => { const measured = { cellWidth: 7.2, cellHeight: 16 }; diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts index cb14111bf2..806c149c60 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts +++ b/packages/app/src/terminal/native-renderer/terminal-grid-metrics.ts @@ -3,47 +3,6 @@ export interface TerminalGridCellMetrics { cellHeight: number; } -export interface TerminalGridCellMetricsInput { - measuredTextWidth: number; - measuredTextHeight: number; - measureTextLength: number; - roundToNearestPixel: (value: number) => number; -} - -export interface TerminalCursorOffsetInput { - cursorCol: number; - cursorRow: number; - metrics: TerminalGridCellMetrics; -} - -export interface TerminalCursorOffset { - x: number; - y: number; -} - -export interface TerminalCustomGlyphCellTransformInput { - cellOffset: number; - cellWidth: number; - cellHeight: number; -} - -export function resolveTerminalCustomGlyphCellTransform( - input: TerminalCustomGlyphCellTransformInput, -): string { - const translateX = input.cellOffset * input.cellWidth; - return `matrix(${input.cellWidth} 0 0 ${input.cellHeight} ${translateX} 0)`; -} - -export function resolveMeasuredTerminalCellMetrics( - input: TerminalGridCellMetricsInput, -): TerminalGridCellMetrics { - const textLength = Math.max(1, input.measureTextLength); - return { - cellWidth: snapCellMetric(input.measuredTextWidth / textLength, input.roundToNearestPixel), - cellHeight: snapCellMetric(input.measuredTextHeight, input.roundToNearestPixel), - }; -} - export function resolveTerminalGridMetricsMeasurement( previous: TerminalGridCellMetrics | null, next: TerminalGridCellMetrics, @@ -53,16 +12,3 @@ export function resolveTerminalGridMetricsMeasurement( } return next; } - -export function resolveTerminalCursorOffset( - input: TerminalCursorOffsetInput, -): TerminalCursorOffset { - return { - x: input.cursorCol * input.metrics.cellWidth, - y: input.cursorRow * input.metrics.cellHeight, - }; -} - -function snapCellMetric(value: number, roundToNearestPixel: (value: number) => number): number { - return Math.max(1, roundToNearestPixel(value)); -} diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx index 04f82c1011..167a33e0ab 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx +++ b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx @@ -1,71 +1,34 @@ -import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { - PixelRatio, - Text, + useWindowDimensions, View, type LayoutChangeEvent, type StyleProp, - type TextStyle, type ViewStyle, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import Svg, { G, Path } from "react-native-svg"; import type { ITheme } from "@xterm/xterm"; +import { + PaseoTerminalView, + type PaseoTerminalCellMetrics, + type PaseoTerminalTheme, +} from "../../../modules/paseo-terminal"; import { createTerminalCellStyleResolver, DEFAULT_TERMINAL_THEME, NATIVE_TERMINAL_SELECTION_COLORS, } from "./colors"; -import type { TerminalGlyphRect } from "./terminal-custom-glyph"; import { resolveNativeTerminalFontFamily } from "./font.native"; import type { TerminalViewportState } from "./headless-terminal-state"; import { - resolveMeasuredTerminalCellMetrics, - resolveTerminalCustomGlyphCellTransform, resolveTerminalGridMetricsMeasurement, - resolveTerminalCursorOffset, type TerminalGridCellMetrics, } from "./terminal-grid-metrics"; -import { - buildRows, - type TerminalCustomGlyphCell, - type TerminalCustomGlyphRun, - type TerminalRowModel, - type TerminalRun, -} from "./terminal-row-model"; +import { buildTerminalFrame } from "./terminal-frame"; import type { TerminalSelectionRange } from "./terminal-selection"; -const MEASURE_TEXT = "mmmmmmmmmm"; const DEFAULT_FONT_SIZE = 12; -const INITIAL_CELL_WIDTH_RATIO = 0.62; -const INITIAL_CELL_HEIGHT_RATIO = 1.35; - -interface CellMetrics { - cellWidth: number; - cellHeight: number; -} - -interface TerminalGridViewport { - width: number; - height: number; -} - -interface TerminalGridRowProps { - row: TerminalRowModel; - cellWidth: number; - cellHeight: number; - fontFamily?: string; - fontSize: number; - styleEpoch: string; -} - -interface TerminalGridRunProps { - run: TerminalRun; - cellWidth: number; - cellHeight: number; - textStyle: StyleProp; -} export interface TerminalGridViewProps { state: TerminalViewportState; @@ -77,221 +40,20 @@ export interface TerminalGridViewProps { onCellMetricsChange?: (metrics: TerminalGridCellMetrics) => void; } -function estimateCellMetrics(fontSize: number): CellMetrics { - return { - cellWidth: snapPixel(fontSize * INITIAL_CELL_WIDTH_RATIO), - cellHeight: snapPixel(fontSize * INITIAL_CELL_HEIGHT_RATIO), - }; -} - -function snapPixel(value: number): number { - return Math.max(1, PixelRatio.roundToNearestPixel(value)); -} - function resolveVisibleCols(input: { - viewportWidth: number; - cellWidth: number; + viewportWidth: number | null; + cellWidth: number | null; gridCols: number; }): number { + if (input.viewportWidth === null || input.cellWidth === null) { + return input.gridCols; + } if (input.viewportWidth <= 0 || input.cellWidth <= 0 || input.gridCols <= 0) { return 0; } return Math.min(input.gridCols, Math.max(1, Math.floor(input.viewportWidth / input.cellWidth))); } -function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGridRunProps) { - const runStyle = useMemo>( - () => [ - styles.run, - { - backgroundColor: run.style.backgroundColor, - height: cellHeight, - width: run.cellCount * cellWidth, - }, - ], - [cellHeight, cellWidth, run.cellCount, run.style.backgroundColor], - ); - const runTextStyle = useMemo>( - () => [textStyle, run.style], - [run.style, textStyle], - ); - - return ( - - {run.renderKind === "custom-glyph" ? ( - - ) : ( - - {run.text} - - )} - - ); -} - -interface TerminalGridCustomGlyphRunProps { - run: TerminalCustomGlyphRun; - cellWidth: number; - cellHeight: number; -} - -interface TerminalGridCustomGlyphRectProps { - cell: TerminalCustomGlyphCell; - rect: TerminalGlyphRect; - cellWidth: number; - cellHeight: number; - foregroundColor: string; -} - -function TerminalGridCustomGlyphRect({ - cell, - rect, - cellWidth, - cellHeight, - foregroundColor, -}: TerminalGridCustomGlyphRectProps) { - const style = useMemo( - () => ({ - position: "absolute", - backgroundColor: foregroundColor, - left: (cell.offset + rect.x) * cellWidth, - top: rect.y * cellHeight, - width: rect.width * cellWidth, - height: rect.height * cellHeight, - }), - [cell.offset, cellHeight, cellWidth, foregroundColor, rect], - ); - - return ; -} - -function TerminalGridCustomGlyphRun({ - run, - cellWidth, - cellHeight, -}: TerminalGridCustomGlyphRunProps) { - const opacity = typeof run.style.opacity === "number" ? run.style.opacity : 1; - - return ( - - {run.glyphs.map((cell) => - cell.glyph.kind === "rects" - ? cell.glyph.rects.map((rect) => ( - - )) - : null, - )} - - - {run.glyphs.map((cell) => - cell.glyph.kind === "path" ? ( - - ) : null, - )} - - - - ); -} - -const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => { - return ( - previous.run === next.run && - previous.cellWidth === next.cellWidth && - previous.cellHeight === next.cellHeight && - previous.textStyle === next.textStyle - ); -}); - -function TerminalGridRow({ - row, - cellWidth, - cellHeight, - fontFamily, - fontSize, -}: TerminalGridRowProps) { - const rowStyle = useMemo>( - () => [styles.row, { height: cellHeight }], - [cellHeight], - ); - const textStyle = useMemo>( - () => [ - styles.rowText, - { - height: cellHeight, - lineHeight: cellHeight, - fontFamily, - fontSize, - }, - ], - [cellHeight, fontFamily, fontSize], - ); - const accessibilityLabel = useMemo( - () => - row.runs - .map((run) => run.text) - .join("") - .trimEnd(), - [row.runs], - ); - - return ( - 0} - style={rowStyle} - testID={`terminal-row-${row.index}`} - > - {row.runs.map((run) => ( - - ))} - - ); -} - -const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => { - return ( - previous.row.hash === next.row.hash && - previous.cellWidth === next.cellWidth && - previous.cellHeight === next.cellHeight && - previous.fontFamily === next.fontFamily && - previous.fontSize === next.fontSize && - previous.styleEpoch === next.styleEpoch - ); -}); - export function TerminalGridView({ state, xtermTheme = DEFAULT_TERMINAL_THEME, @@ -301,129 +63,60 @@ export function TerminalGridView({ selection = null, onCellMetricsChange, }: TerminalGridViewProps) { - const [metrics, setMetrics] = useState(() => estimateCellMetrics(fontSize)); + const { fontScale } = useWindowDimensions(); + const [cellWidth, setCellWidth] = useState(null); + const [viewportWidth, setViewportWidth] = useState(null); const measuredMetricsRef = useRef(null); - const [viewport, setViewport] = useState(null); const resolvedFontFamily = useMemo( () => resolveNativeTerminalFontFamily(fontFamily), [fontFamily], ); const resolver = useMemo(() => createTerminalCellStyleResolver(xtermTheme), [xtermTheme]); - const visibleCols = useMemo(() => { - const viewportWidth = viewport?.width ?? state.cols * metrics.cellWidth; - return resolveVisibleCols({ - viewportWidth, - cellWidth: metrics.cellWidth, - gridCols: state.cols, - }); - }, [metrics.cellWidth, state.cols, viewport?.width]); - const projectedGrid = useMemo( - () => state.grid.map((row) => row.slice(0, visibleCols)), - [state.grid, visibleCols], + const theme = useMemo( + () => ({ + background: resolver.backgroundColor, + cursor: resolver.cursorColor, + selectionBackground: NATIVE_TERMINAL_SELECTION_COLORS.background, + }), + [resolver], ); - const rows = useMemo( - () => - buildRows({ - grid: projectedGrid, - resolver, - selection: selection - ? { - range: selection, - firstRow: state.firstRow, - backgroundColor: NATIVE_TERMINAL_SELECTION_COLORS.background, - foregroundColor: NATIVE_TERMINAL_SELECTION_COLORS.foreground, - } - : undefined, - }), - [projectedGrid, resolver, selection, state.firstRow], + const columns = resolveVisibleCols({ viewportWidth, cellWidth, gridCols: state.cols }); + const frame = useMemo( + () => buildTerminalFrame({ state, columns, resolver, selection }), + [columns, resolver, selection, state], ); - const containerStyle = useMemo>( - () => [styles.root, { backgroundColor: resolver.backgroundColor }, style], - [resolver.backgroundColor, style], - ); - const gridStyle = useMemo>( - () => [ - styles.grid, - { - width: visibleCols * metrics.cellWidth, - height: state.grid.length * metrics.cellHeight, - }, - ], - [metrics.cellHeight, metrics.cellWidth, state.grid.length, visibleCols], - ); - const measureStyle = useMemo>( - () => [styles.measureText, { fontFamily: resolvedFontFamily, fontSize }], - [resolvedFontFamily, fontSize], - ); - const cursorStyle = useMemo>(() => { - const cursorOffset = resolveTerminalCursorOffset({ - cursorCol: state.cursor.col, - cursorRow: state.cursor.row, - metrics, - }); - return [ - styles.cursor, - { - backgroundColor: resolver.cursorColor, - width: metrics.cellWidth, - height: metrics.cellHeight, - transform: [{ translateX: cursorOffset.x }, { translateY: cursorOffset.y }], - }, - ]; - }, [metrics, state.cursor.col, state.cursor.row, resolver.cursorColor]); - - const handleMeasure = useCallback( - (event: LayoutChangeEvent) => { - const nextMetrics = resolveMeasuredTerminalCellMetrics({ - measuredTextWidth: event.nativeEvent.layout.width, - measuredTextHeight: event.nativeEvent.layout.height, - measureTextLength: MEASURE_TEXT.length, - roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value), - }); + const handleCellMetrics = useCallback( + (metrics: PaseoTerminalCellMetrics) => { const changedMetrics = resolveTerminalGridMetricsMeasurement( measuredMetricsRef.current, - nextMetrics, + metrics, ); if (!changedMetrics) { return; } measuredMetricsRef.current = changedMetrics; - setMetrics(changedMetrics); + setCellWidth(changedMetrics.cellWidth); onCellMetricsChange?.(changedMetrics); }, [onCellMetricsChange], ); const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { - const { width, height } = event.nativeEvent.layout; - setViewport((current) => { - if (current?.width === width && current.height === height) { - return current; - } - return { width, height }; - }); + const { width } = event.nativeEvent.layout; + setViewportWidth((current) => (current === width ? current : width)); }, []); return ( - - - - {MEASURE_TEXT} - - {rows.map((row) => ( - - ))} - {!state.cursor.hidden && } - + + ); } @@ -432,38 +125,7 @@ const styles = StyleSheet.create({ root: { overflow: "hidden", }, - grid: { - position: "relative", - }, - row: { - flexDirection: "row", - }, - run: { - overflow: "hidden", - }, - customGlyphRun: { + surface: { flex: 1, - position: "relative", - }, - customGlyphSvg: { - left: 0, - position: "absolute", - top: 0, - }, - rowText: { - includeFontPadding: false, - margin: 0, - padding: 0, - }, - measureText: { - includeFontPadding: false, - opacity: 0, - position: "absolute", - }, - cursor: { - left: 0, - opacity: 0.45, - position: "absolute", - top: 0, }, }); diff --git a/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts b/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts deleted file mode 100644 index b19922b7a9..0000000000 --- a/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { TerminalCell } from "@getpaseo/protocol/messages"; -import { describe, expect, test } from "vitest"; - -import { createTerminalCellStyleResolver, DEFAULT_TERMINAL_THEME } from "./colors"; -import { buildRows } from "./terminal-row-model"; - -function cell( - char: string, - overrides: Partial & { width?: number } = {}, -): TerminalCell & { width?: number } { - return { char, ...overrides }; -} - -describe("terminal row model", () => { - test("preserves terminal cell width for styled runs and trailing spaces", () => { - const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); - - const rows = buildRows({ - grid: [ - [cell("n", { bg: 2, bgMode: 1 }), cell("v", { bg: 2, bgMode: 1 }), cell(" "), cell(" ")], - ], - resolver, - }); - - expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([ - { text: "nv", cellCount: 2 }, - { text: " ", cellCount: 2 }, - ]); - }); - - test("uses xterm's authoritative width instead of guessing from the code point", () => { - const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); - - const rows = buildRows({ - grid: [[cell("A", { width: 2 }), cell(" ", { width: 0 }), cell("界", { width: 1 })]], - resolver, - }); - - expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([ - { text: "A界", cellCount: 3 }, - ]); - }); - - test("redraws selected cells with the terminal selection colors", () => { - const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); - const grid = [[cell("a", { fg: 1, fgMode: 1 }), cell("b", { dim: true }), cell("c")]]; - - const rows = buildRows({ - grid, - resolver, - selection: { - range: { - start: { row: 7, col: 1 }, - end: { row: 7, col: 2 }, - coordinateEpoch: 1, - }, - firstRow: 7, - backgroundColor: "#ffff00", - foregroundColor: "#000000", - }, - }); - - expect( - rows[0].runs.map((run) => ({ - text: run.text, - color: run.style.color, - backgroundColor: run.style.backgroundColor, - opacity: run.style.opacity, - })), - ).toEqual([ - { - text: "a", - color: DEFAULT_TERMINAL_THEME.red, - backgroundColor: undefined, - opacity: undefined, - }, - { text: "b", color: "#000000", backgroundColor: "#ffff00", opacity: 1 }, - { text: "c", color: "#000000", backgroundColor: "#ffff00", opacity: 1 }, - ]); - expect(rows[0].hash).not.toBe(buildRows({ grid, resolver })[0].hash); - }); - - test("splits Claude box drawing from font text at exact terminal columns", () => { - const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); - const frame = "╭─ Claude Code ───╮"; - - const [row] = buildRows({ - grid: [frame.split("").map((char) => cell(char, { fg: 1, fgMode: 1 }))], - resolver, - }); - - expect( - row.runs.map((run) => ({ - text: run.text, - cellCount: run.cellCount, - renderKind: run.renderKind, - })), - ).toEqual([ - { text: "╭─", cellCount: 2, renderKind: "custom-glyph" }, - { text: " Claude Code ", cellCount: 13, renderKind: "text" }, - { text: "───╮", cellCount: 4, renderKind: "custom-glyph" }, - ]); - }); - - test("keeps Claude mascot glyphs custom while preserving copied row text", () => { - const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); - const mascot = "▐▛███▜▌ Claude Code"; - - const [row] = buildRows({ - grid: [mascot.split("").map((char) => cell(char, { fg: 1, fgMode: 1 }))], - resolver, - }); - - expect(row.runs.map((run) => [run.text, run.renderKind])).toEqual([ - ["▐▛███▜▌", "custom-glyph"], - [" Claude Code", "text"], - ]); - expect(row.runs.map((run) => run.text).join("")).toBe(mascot); - }); -}); diff --git a/packages/app/src/terminal/native-renderer/terminal-row-model.ts b/packages/app/src/terminal/native-renderer/terminal-row-model.ts deleted file mode 100644 index a65f39fd44..0000000000 --- a/packages/app/src/terminal/native-renderer/terminal-row-model.ts +++ /dev/null @@ -1,253 +0,0 @@ -import type { TextStyle } from "react-native"; -import type { TerminalCell } from "@getpaseo/protocol/messages"; - -import type { TerminalCellStyleResolver } from "./colors"; -import { resolveTerminalCustomGlyph, type TerminalCustomGlyph } from "./terminal-custom-glyph"; -import type { TerminalSelectionRange } from "./terminal-selection"; - -const WIDE_CHAR_RANGES: ReadonlyArray = [ - [0x1100, 0x115f], - [0x2329, 0x232a], - [0x2e80, 0xa4cf], - [0xac00, 0xd7a3], - [0xf900, 0xfaff], - [0xfe10, 0xfe19], - [0xfe30, 0xfe6f], - [0xff00, 0xff60], - [0xffe0, 0xffe6], - [0x1f300, 0x1faff], -]; - -interface TerminalRunBase { - key: string; - text: string; - cellCount: number; - styleKey: string; - style: TextStyle; - foregroundColor: string; -} - -export interface TerminalTextRun extends TerminalRunBase { - renderKind: "text"; -} - -export interface TerminalCustomGlyphRun extends TerminalRunBase { - renderKind: "custom-glyph"; - glyphs: TerminalCustomGlyphCell[]; -} - -export type TerminalRun = TerminalCustomGlyphRun | TerminalTextRun; - -export interface TerminalCustomGlyphCell { - key: string; - offset: number; - glyph: TerminalCustomGlyph; -} - -export interface TerminalRowModel { - index: number; - hash: string; - runs: TerminalRun[]; -} - -type TerminalRenderableCell = TerminalCell & { width?: number }; - -interface TerminalRowSelectionStyle { - range: TerminalSelectionRange; - firstRow: number; - backgroundColor: string; - foregroundColor: string; -} - -function hashStringPart(hash: number, value: string): number { - let nextHash = hash; - for (let index = 0; index < value.length; index += 1) { - nextHash = Math.imul(nextHash ^ value.charCodeAt(index), 16777619); - } - return nextHash; -} - -function finishHash(hash: number): string { - return (hash >>> 0).toString(36); -} - -function terminalCharWidth(char: string): number { - const codePoint = char.codePointAt(0); - if (codePoint === undefined) return 1; - const isWide = WIDE_CHAR_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end); - return isWide ? 2 : 1; -} - -function shouldSkipSpacerCell(cells: TerminalRenderableCell[], col: number, char: string): boolean { - if (terminalCharWidth(char) < 2) { - return false; - } - return cells[col + 1]?.char === " "; -} - -function terminalCellCount(cells: TerminalRenderableCell[], col: number, char: string): number { - const authoritativeWidth = cells[col]?.width; - if (authoritativeWidth !== undefined) { - return Math.max(1, authoritativeWidth); - } - if (shouldSkipSpacerCell(cells, col, char)) { - return 2; - } - return 1; -} - -function appendRun(input: { - runs: TerminalRun[]; - text: string; - cellCount: number; - styleKey: string; - style: TextStyle; - foregroundColor: string; - customGlyph: TerminalCustomGlyph | null; - col: number; -}): void { - const renderKind = input.customGlyph ? "custom-glyph" : "text"; - const previousRun = input.runs[input.runs.length - 1]; - if ( - previousRun && - previousRun.styleKey === input.styleKey && - previousRun.renderKind === renderKind - ) { - const offset = previousRun.cellCount; - previousRun.text += input.text; - previousRun.cellCount += input.cellCount; - if (previousRun.renderKind === "custom-glyph" && input.customGlyph) { - previousRun.glyphs.push({ - key: `${input.col}:${input.text}`, - offset, - glyph: input.customGlyph, - }); - } - return; - } - - const baseRun: TerminalRunBase = { - key: `${input.col}:${input.styleKey}`, - text: input.text, - cellCount: input.cellCount, - styleKey: input.styleKey, - style: input.style, - foregroundColor: input.foregroundColor, - }; - if (input.customGlyph) { - input.runs.push({ - ...baseRun, - renderKind: "custom-glyph", - glyphs: [{ key: `${input.col}:${input.text}`, offset: 0, glyph: input.customGlyph }], - }); - return; - } - input.runs.push({ ...baseRun, renderKind: "text" }); -} - -function cellIntersectsSelection(input: { - selection: TerminalRowSelectionStyle; - row: number; - col: number; - cellCount: number; -}): boolean { - const absoluteRow = input.selection.firstRow + input.row; - const { start, end } = input.selection.range; - if (absoluteRow < start.row || absoluteRow > end.row) { - return false; - } - - const selectedStartCol = absoluteRow === start.row ? start.col : 0; - const selectedEndCol = absoluteRow === end.row ? end.col : Number.POSITIVE_INFINITY; - const cellEndCol = input.col + input.cellCount - 1; - return cellEndCol >= selectedStartCol && input.col <= selectedEndCol; -} - -function selectedCellStyle(input: { - style: TextStyle; - foregroundColor: string; - backgroundColor: string; -}): TextStyle { - return { - ...input.style, - backgroundColor: input.backgroundColor, - color: input.foregroundColor, - opacity: 1, - ...(input.style.textDecorationLine - ? { textDecorationColor: input.foregroundColor } - : undefined), - }; -} - -function buildRowModel(input: { - cells: TerminalRenderableCell[]; - index: number; - resolver: TerminalCellStyleResolver; - selection?: TerminalRowSelectionStyle; -}): TerminalRowModel { - const runs: TerminalRun[] = []; - let hash = 2166136261; - - for (let col = 0; col < input.cells.length; col += 1) { - const cell = input.cells[col]; - const text = cell.char || " "; - const resolvedStyle = input.resolver.resolve(cell); - const cellCount = terminalCellCount(input.cells, col, text); - const customGlyph = resolveTerminalCustomGlyph(text); - const selection = input.selection; - let styleKey = resolvedStyle.key; - let style = resolvedStyle.style; - let foregroundColor = resolvedStyle.foregroundColor; - if ( - selection && - cellIntersectsSelection({ - selection, - row: input.index, - col, - cellCount, - }) - ) { - styleKey = `${resolvedStyle.key}|selection:${selection.foregroundColor}:${selection.backgroundColor}`; - style = selectedCellStyle({ - style: resolvedStyle.style, - foregroundColor: selection.foregroundColor, - backgroundColor: selection.backgroundColor, - }); - foregroundColor = selection.foregroundColor; - } - appendRun({ - runs, - text, - cellCount, - styleKey, - style, - foregroundColor, - customGlyph, - col, - }); - hash = hashStringPart(hash, text); - hash = hashStringPart(hash, String(cellCount)); - hash = hashStringPart(hash, styleKey); - hash = hashStringPart(hash, customGlyph ? "custom-glyph" : "text"); - - if (cellCount > 1) { - col += 1; - } - } - - return { - index: input.index, - hash: finishHash(hash), - runs, - }; -} - -export function buildRows(input: { - grid: TerminalRenderableCell[][]; - resolver: TerminalCellStyleResolver; - selection?: TerminalRowSelectionStyle; -}): TerminalRowModel[] { - return input.grid.map((cells, index) => - buildRowModel({ cells, index, resolver: input.resolver, selection: input.selection }), - ); -} diff --git a/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts b/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts index 3464bcd973..6bacdc0472 100644 --- a/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-scrollback.test.ts @@ -54,16 +54,19 @@ describe("native terminal scrollback retention", () => { scrollbackLines: SCROLLBACK, lineCount: SCROLLBACK + ROWS + 10, }); - const state = terminal.getState(); const bounds = terminal.getBufferBounds(); - expect(state.scrollback.length).toBeGreaterThan(0); - expect(state.scrollback.length).toBeLessThanOrEqual(SCROLLBACK); + const historyRows = bounds.bottomViewport.firstRow - bounds.oldestRow; + expect(historyRows).toBeGreaterThan(0); + expect(historyRows).toBeLessThanOrEqual(SCROLLBACK); expect(bounds.newestRow).toBeGreaterThan(ROWS); expect(bounds.oldestRow).toBeGreaterThan(0); - expect(bounds.newestRow - bounds.oldestRow + 1).toBe( - state.scrollback.length + state.grid.length, - ); + const retained = terminal.getBufferWindow({ + startRow: bounds.oldestRow, + rowCount: SCROLLBACK + ROWS + 1, + }); + expect(retained.rows).toHaveLength(SCROLLBACK + ROWS); + expect(rowText(retained.rows[0]!)).toBe(expectedBenchmarkLine(bounds.oldestRow)); }); test("observable scrollback count is bounded by configured lines", async () => { @@ -73,9 +76,9 @@ describe("native terminal scrollback retention", () => { scrollbackLines: SCROLLBACK, lineCount: SCROLLBACK * 3, }); - const state = terminal.getState(); - expect(state.scrollback.length).toBe(SCROLLBACK); + const bounds = terminal.getBufferBounds(); + expect(bounds.bottomViewport.firstRow - bounds.oldestRow).toBe(SCROLLBACK); }); test("scroll up moves viewport into retained history", async () => { diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-controller.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection-controller.test.ts new file mode 100644 index 0000000000..b71c264434 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-selection-controller.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from "vitest"; +import { createNativeHeadlessTerminal } from "./headless-terminal-state"; +import { + createTerminalSelectionModel, + extractTerminalSelectedText, + resolveTerminalSelectionRects, +} from "./terminal-selection"; +import { + createTerminalSelectionController, + terminalSelectionHandles, +} from "./terminal-selection-controller"; + +describe("selection handle controller", () => { + it("preserves the grab offset while moving a handle", () => { + const range = { start: { row: 1, col: 2 }, end: { row: 1, col: 5 }, coordinateEpoch: 0 }; + const moves: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => {}, + onMoveHandle: (coordinate) => moves.push(coordinate), + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 23, y: 15 }); + controller.move({ x: 63, y: 15 }); + controller.release(); + expect(moves).toEqual([{ row: 1, col: 5 }]); + }); +}); + +it("selects on hold, preserves a handle tap, and cancels an active drag", () => { + const actions: string[] = []; + let timer: (() => void) | null = null; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: (endpoint) => actions.push(endpoint), + onMoveHandle: () => actions.push("move"), + onLongPress: () => actions.push("long"), + onTap: () => actions.push("tap"), + onScroll: () => actions.push("scroll"), + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 90, y: 80 }); + (timer as (() => void) | null)?.(); + controller.release(); + controller.press({ x: 20, y: 25 }); + controller.release(); + controller.press({ x: 20, y: 25 }); + controller.cancel(); + controller.move({ x: 50, y: 25 }); + expect(actions).toEqual(["long", "start", "start"]); +}); + +it("scrolls selection into history while holding a handle at the edge", () => { + let firstRow = 10; + let timer: (() => void) | null = null; + const coordinates: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: { start: { row: 12, col: 2 }, end: { row: 13, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => {}, + onMoveHandle: (point) => coordinates.push(point), + onLongPress: () => {}, + onTap: () => {}, + onScroll: (direction) => { + if (direction === "up") firstRow--; + }, + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 20, y: 25 }); + controller.move({ x: 20, y: 1 }); + (timer as (() => void) | null)?.(); + (timer as (() => void) | null)?.(); + controller.release(); + expect(coordinates).toEqual([ + { row: 10, col: 2 }, + { row: 9, col: 2 }, + { row: 8, col: 2 }, + ]); + expect(timer).toBeNull(); +}); + +it("scrolls outside handles without clearing selection and clears on an outside tap", () => { + const actions: string[] = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => actions.push("handle"), + onMoveHandle: () => actions.push("move"), + onLongPress: () => actions.push("long"), + onTap: () => actions.push("tap"), + onScroll: (direction, rows) => actions.push(`${direction}:${rows}`), + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 95, y: 70 }); + controller.move({ x: 95, y: 90 }); + controller.release(); + controller.press({ x: 95, y: 70 }); + controller.release(); + expect(actions).toEqual(["up:2", "tap"]); +}); + +it("extends the selection after a long press and drag", () => { + let timer: (() => void) | null = null; + const moves: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: null, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: () => {}, + onMoveHandle: (coordinate) => moves.push(coordinate), + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 25, y: 25 }); + (timer as (() => void) | null)?.(); + controller.move({ x: 65, y: 35 }); + controller.release(); + expect(moves).toEqual([{ row: 3, col: 6 }]); +}); + +it.each([ + { + endpoint: "start" as const, + startX: 20, + crossedX: 80, + crossedText: "gh", + crossedStart: 6, + crossedEnd: 7, + backX: 10, + backText: "bcdef", + backStart: 1, + backEnd: 5, + }, + { + endpoint: "end" as const, + startX: 60, + crossedX: 10, + crossedText: "b", + crossedStart: 1, + crossedEnd: 1, + backX: 80, + backText: "cdefgh", + backStart: 2, + backEnd: 7, + }, +])("tracks the $endpoint handle through crossing and back", async (scenario) => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 }); + try { + await terminal.write("abcdefghij"); + const bounds = terminal.getBufferBounds(); + const model = createTerminalSelectionModel(); + model.begin({ coordinate: { row: 0, col: 2 }, bounds }); + model.update({ coordinate: { row: 0, col: 5 }, bounds }); + const viewport = { firstRow: 0, rows: 4, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics }); + const controller = createTerminalSelectionController({ + getGeometry: geometry, + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + const check = (start: number, end: number, text: string) => { + const range = model.getSnapshot().range; + expect(range).toEqual({ + start: { row: 0, col: start }, + end: { row: 0, col: end }, + coordinateEpoch: bounds.coordinateEpoch, + }); + expect(terminalSelectionHandles(geometry())).toMatchObject([ + { endpoint: "start", x: start * 10, y: 5 }, + { endpoint: "end", x: (end + 1) * 10, y: 5 }, + ]); + expect(resolveTerminalSelectionRects({ selection: range, viewport, metrics })).toEqual([ + { + key: `0:${start}:${end}`, + x: start * 10, + y: 0, + width: (end - start + 1) * 10, + height: 10, + }, + ]); + expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(text); + }; + controller.press({ x: scenario.startX, y: 5 }); + controller.move({ x: scenario.crossedX, y: 5 }); + check(scenario.crossedStart, scenario.crossedEnd, scenario.crossedText); + controller.move({ x: scenario.backX, y: 5 }); + check(scenario.backStart, scenario.backEnd, scenario.backText); + controller.release(); + } finally { + terminal.dispose(); + } +}); + +it.each([ + { + endpoint: "end" as const, + initialRow: 0, + targetRow: 1, + targetX: 0, + expectedRow: 0, + expectedCol: 9, + text: "cdefghij", + }, + { + endpoint: "start" as const, + initialRow: 1, + targetRow: 0, + targetX: 100, + expectedRow: 1, + expectedCol: 0, + text: "KLMNOP", + }, +])("preserves a $endpoint boundary at a row edge", async (scenario) => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 }); + try { + await terminal.write("abcdefghij\r\nKLMNOPQRST"); + const bounds = terminal.getBufferBounds(); + const model = createTerminalSelectionModel(); + model.begin({ coordinate: { row: scenario.initialRow, col: 2 }, bounds }); + model.update({ coordinate: { row: scenario.initialRow, col: 5 }, bounds }); + const viewport = { firstRow: 0, rows: 4, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics }); + const controller = createTerminalSelectionController({ + getGeometry: geometry, + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: scenario.endpoint === "end" ? 60 : 20, y: scenario.initialRow * 10 + 5 }); + controller.move({ x: scenario.targetX, y: scenario.targetRow * 10 + 5 }); + const range = model.getSnapshot().range; + expect(range?.[scenario.endpoint]).toEqual({ + row: scenario.expectedRow, + col: scenario.expectedCol, + }); + expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(scenario.text); + expect( + terminalSelectionHandles(geometry()).find((handle) => handle.endpoint === scenario.endpoint), + ).toMatchObject({ + x: scenario.endpoint === "end" ? 100 : 0, + y: scenario.expectedRow * 10 + 5, + }); + controller.release(); + } finally { + terminal.dispose(); + } +}); + +it("cancels a drag when buffer coordinates are invalidated", () => { + const model = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 }; + model.begin({ coordinate: { row: 2, col: 2 }, bounds }); + model.update({ coordinate: { row: 2, col: 5 }, bounds }); + const viewport = { firstRow: 0, rows: 10, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ range: model.getSnapshot().range, viewport, metrics }), + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 20, y: 25 }); + const previous = model.getSnapshot().range; + const snapshot = model.sync({ bounds: { ...bounds, coordinateEpoch: 1 } }); + if (previous && !snapshot.range) controller.cancel(); + controller.move({ x: 80, y: 25 }); + controller.release(); + expect(model.getSnapshot().range).toBeNull(); +}); + +it("preserves a newly selected word through small movement at the viewport edge", () => { + const model = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 }; + let timer: (() => void) | null = null; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ + range: model.getSnapshot().range, + viewport: { firstRow: 0, rows: 10, cols: 20 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }), + onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols), + onMoveHandle: (coordinate) => { + model.update({ coordinate, bounds }); + }, + onLongPress: () => { + model.begin({ coordinate: { row: 0, col: 2 }, bounds }); + model.update({ coordinate: { row: 0, col: 6 }, bounds }); + }, + onTap: () => {}, + onScroll: () => {}, + schedule: (callback) => { + timer = callback; + return 1; + }, + cancel: () => { + timer = null; + }, + }); + controller.press({ x: 45, y: 5 }); + const fire = timer as (() => void) | null; + timer = null; + fire?.(); + controller.move({ x: 44, y: 4 }); + controller.release(); + expect(model.getSnapshot().range).toEqual({ + start: { row: 0, col: 2 }, + end: { row: 0, col: 6 }, + coordinateEpoch: 0, + }); + expect(timer).toBeNull(); +}); + +it("places mirrored teardrop grips below the selected row", () => { + const handles = terminalSelectionHandles({ + range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 }, + viewport: { firstRow: 0, rows: 10, cols: 10 }, + metrics: { cellWidth: 10, cellHeight: 10 }, + }); + expect(handles).toMatchObject([ + { endpoint: "start", x: 20, y: 25, gripX: 11, gripY: 39, gripDirection: "left" }, + { endpoint: "end", x: 60, y: 25, gripX: 69, gripY: 39, gripDirection: "right" }, + ]); +}); + +it("keeps edge grips reachable and preserves the offset from the cell when dragging a grip", () => { + const range = { start: { row: 0, col: 0 }, end: { row: 9, col: 9 }, coordinateEpoch: 0 }; + const viewport = { firstRow: 0, rows: 10, cols: 10 }; + const metrics = { cellWidth: 10, cellHeight: 10 }; + const handles = terminalSelectionHandles({ range, viewport, metrics }); + expect(handles.map(({ gripX, gripY }) => ({ gripX, gripY }))).toEqual([ + { gripX: 9, gripY: 19 }, + { gripX: 91, gripY: 81 }, + ]); + const moves: Array<{ row: number; col: number }> = []; + const controller = createTerminalSelectionController({ + getGeometry: () => ({ range, viewport, metrics }), + onBeginHandle: () => {}, + onMoveHandle: (coordinate) => moves.push(coordinate), + onLongPress: () => {}, + onTap: () => {}, + onScroll: () => {}, + schedule: () => 1, + cancel: () => {}, + }); + controller.press({ x: 9, y: 19 }); + controller.move({ x: 19, y: 29 }); + controller.release(); + expect(moves).toEqual([{ row: 1, col: 1 }]); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-controller.ts b/packages/app/src/terminal/native-renderer/terminal-selection-controller.ts new file mode 100644 index 0000000000..721fd501d7 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-selection-controller.ts @@ -0,0 +1,208 @@ +import type { TerminalGridCellMetrics } from "./terminal-grid-metrics"; +import type { + TerminalBufferCoordinate, + TerminalSelectionPoint, + TerminalSelectionRange, + TerminalSelectionViewport, +} from "./terminal-selection"; +export const TERMINAL_SELECTION_GRIP_SIZE = 18; +const TERMINAL_GESTURE_LONG_PRESS_MS = 450; +const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8; + +type Endpoint = "start" | "end"; +interface Geometry { + range: TerminalSelectionRange | null; + viewport: TerminalSelectionViewport; + metrics: TerminalGridCellMetrics; +} +interface Ports { + getGeometry(): Geometry | null; + onBeginHandle(endpoint: Endpoint, cols: number): void; + onMoveHandle(coordinate: TerminalBufferCoordinate): void; + onLongPress(point: TerminalSelectionPoint): void; + onTap(): void; + onScroll(direction: "up" | "down", rows: number): void; + schedule(callback: () => void, delay: number): number; + cancel(timer: number): void; +} + +export function terminalSelectionHandles(geometry: Geometry): Array<{ + endpoint: Endpoint; + x: number; + y: number; + gripX: number; + gripY: number; + gripDirection: "left" | "right"; + gripAbove: boolean; + stemTop: number; + stemHeight: number; +}> { + const { range, viewport, metrics } = geometry; + if (!range) return []; + return (["start", "end"] as const).flatMap((endpoint) => { + const cell = range[endpoint]; + if (cell.row < viewport.firstRow || cell.row >= viewport.firstRow + viewport.rows) return []; + const x = (cell.col + (endpoint === "end" ? 1 : 0)) * metrics.cellWidth; + const stemTop = (cell.row - viewport.firstRow) * metrics.cellHeight; + const size = TERMINAL_SELECTION_GRIP_SIZE; + const width = viewport.cols * metrics.cellWidth; + const height = viewport.rows * metrics.cellHeight; + const wantsLeft = endpoint === "start"; + const gripDirection = + (wantsLeft && x >= size) || (!wantsLeft && x > width - size) ? "left" : "right"; + const gripAbove = stemTop + metrics.cellHeight + size > height; + const gripTop = gripAbove ? Math.max(0, stemTop - size) : stemTop + metrics.cellHeight; + return [ + { + endpoint, + x, + y: stemTop + metrics.cellHeight / 2, + gripX: x + (gripDirection === "left" ? -size / 2 : size / 2), + gripY: gripTop + size / 2, + gripDirection, + gripAbove, + stemTop, + stemHeight: metrics.cellHeight, + }, + ]; + }); +} + +export function createTerminalSelectionController(ports: Ports) { + let press: { + start: TerminalSelectionPoint; + point: TerminalSelectionPoint; + endpoint: Endpoint | null; + offset: TerminalSelectionPoint; + fixed: TerminalBufferCoordinate | null; + moved: boolean; + long: boolean; + scrolling: boolean; + remainder: number; + } | null = null; + let timer: number | null = null; + function stopTimer() { + if (timer !== null) ports.cancel(timer); + timer = null; + } + function updateHandle() { + const geometry = ports.getGeometry(); + if (!press || (!press.endpoint && !press.long) || !geometry) return; + const { viewport, metrics } = geometry; + const x = press.point.x - press.offset.x; + const y = press.point.y - press.offset.y; + let row = + viewport.firstRow + + Math.max(0, Math.min(viewport.rows - 1, Math.floor(y / metrics.cellHeight))); + let col = Math.floor(x / metrics.cellWidth); + if (press.endpoint && press.fixed) { + const boundary = Math.round(x / metrics.cellWidth); + const isEnd = + row > press.fixed.row || (row === press.fixed.row && boundary > press.fixed.col); + col = boundary - (isEnd ? 1 : 0); + if (col < 0 && isEnd) { + row -= 1; + col = viewport.cols - 1; + } else if (col >= viewport.cols && !isEnd) { + row += 1; + col = 0; + } + } + col = Math.max(0, Math.min(viewport.cols - 1, col)); + ports.onMoveHandle({ row, col }); + } + function edgeTick() { + timer = null; + const geometry = ports.getGeometry(); + if (!press || (!press.endpoint && !press.long) || !geometry) return; + const y = press.point.y - press.offset.y; + const height = geometry.viewport.rows * geometry.metrics.cellHeight; + let direction: "up" | "down" | null = null; + if (y < geometry.metrics.cellHeight) direction = "up"; + else if (y > height - geometry.metrics.cellHeight) direction = "down"; + if (!direction) return; + ports.onScroll(direction, 1); + if (!press) return; + updateHandle(); + if (press) timer = ports.schedule(edgeTick, 100); + } + return { + press(point: TerminalSelectionPoint) { + stopTimer(); + const geometry = ports.getGeometry(); + const handles = geometry ? terminalSelectionHandles(geometry) : []; + const handle = handles + .map((item) => ({ + endpoint: item.endpoint, + x: item.x, + y: item.y, + distance: Math.hypot(point.x - item.gripX, point.y - item.gripY), + })) + .filter((item) => item.distance <= 28) + .sort((a, b) => a.distance - b.distance)[0]; + press = { + start: point, + point, + endpoint: handle?.endpoint ?? null, + offset: handle ? { x: point.x - handle.x, y: point.y - handle.y } : { x: 0, y: 0 }, + fixed: + handle && geometry?.range + ? geometry.range[handle.endpoint === "start" ? "end" : "start"] + : null, + moved: false, + long: false, + scrolling: false, + remainder: 0, + }; + if (handle && geometry) { + ports.onBeginHandle(handle.endpoint, geometry.viewport.cols); + return; + } + timer = ports.schedule(() => { + timer = null; + if (press && !press.moved) { + press.long = true; + ports.onLongPress(press.start); + } + }, TERMINAL_GESTURE_LONG_PRESS_MS); + }, + move(point: TerminalSelectionPoint) { + if (!press) return; + const previous = press.point; + press.point = point; + const dx = point.x - press.start.x, + dy = point.y - press.start.y; + if (Math.hypot(dx, dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { + press.moved = true; + if (!press.endpoint) stopTimer(); + } + if (press.endpoint || press.long) { + if (press.endpoint || press.moved) { + updateHandle(); + stopTimer(); + if (press) timer = ports.schedule(edgeTick, 100); + } + } else if (!press.long) { + if (!press.scrolling && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx)) { + press.scrolling = true; + press.remainder = dy; + } else if (press.scrolling) press.remainder += point.y - previous.y; + const height = ports.getGeometry()?.metrics.cellHeight ?? 0; + if (press.scrolling && height > 0) { + const rows = Math.trunc(press.remainder / height); + press.remainder -= rows * height; + if (rows) ports.onScroll(rows > 0 ? "up" : "down", Math.abs(rows)); + } + } + }, + release() { + stopTimer(); + if (press && !press.endpoint && !press.long && !press.moved) ports.onTap(); + press = null; + }, + cancel() { + stopTimer(); + press = null; + }, + }; +} diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts deleted file mode 100644 index aed6857d5c..0000000000 --- a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - TERMINAL_GESTURE_LONG_PRESS_MS, - TERMINAL_GESTURE_TAP_TOLERANCE_PX, - TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX, - advanceTerminalScrollGesture, - classifyTerminalGestureIntent, - resolveTerminalGestureReleaseAction, -} from "./terminal-selection-gesture"; - -describe("native terminal gesture policy", () => { - describe("advanceTerminalScrollGesture", () => { - it("keeps consuming a slow drag after scroll intent arms below one row", () => { - let state = { lastDy: 0, rowRemainder: 0, didScroll: false }; - const rowDeltas: number[] = []; - - for (const currentDy of [13, 15, 18, 22, 28]) { - const update = advanceTerminalScrollGesture({ - state, - currentDy, - cellHeight: 14, - }); - state = update.state; - rowDeltas.push(update.rowDelta); - } - - expect({ rowDeltas, state }).toEqual({ - rowDeltas: [0, 1, 0, 0, 1], - state: { lastDy: 28, rowRemainder: 0, didScroll: true }, - }); - }); - }); - - describe("classifyTerminalGestureIntent", () => { - it("treats a motionless press as a tap", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: 0, - dy: 0, - vx: 0, - vy: 0, - }), - ).toEqual("tap"); - }); - - it("treats a tiny jitter within tap tolerance as a tap", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4, - dy: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4, - vx: 0, - vy: 0, - }), - ).toEqual("tap"); - }); - - it("leaves horizontal movement available to terminal selection", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 20, - dy: 4, - vx: 0, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not infer selection from slow horizontal drag dwell", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, - dy: 3, - vx: 0, - vy: 0, - elapsedMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, - }), - ).toEqual("pending"); - }); - - it("keeps tiny horizontal jitter inside tap tolerance as a tap", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1, - dy: 1, - vx: 2, - vy: 0, - elapsedMs: 20, - }), - ).toEqual("tap"); - }); - - it("keeps horizontal movement below navigation distance pending", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 10, - dy: 2, - vx: 0, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("treats a clearly vertical drag as scroll", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: 4, - dy: TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX + 5, - vx: 0, - vy: 0.2, - }), - ).toEqual("scroll"); - }); - - it("does not navigate on a fast rightward swipe", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, - dy: 4, - vx: 2, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not navigate on a slow rightward swipe", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40, - dy: 4, - vx: 0, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not navigate on a fast leftward swipe", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: -(TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40), - dy: -4, - vx: -2, - vy: 0, - }), - ).toEqual("pending"); - }); - - it("does not navigate while movement stays inside tap tolerance", () => { - expect( - classifyTerminalGestureIntent({ - status: "pressing", - dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1, - dy: 2, - vx: 2, - vy: 0, - }), - ).toEqual("tap"); - }); - - it("keeps selection intent once selection is active", () => { - expect( - classifyTerminalGestureIntent({ - status: "selecting", - dx: 100, - dy: 50, - vx: 2, - vy: 1, - }), - ).toEqual("select"); - }); - }); - - describe("resolveTerminalGestureReleaseAction", () => { - it("focuses the terminal after a normal tap", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("focus"); - }); - - it("focuses the terminal after a normal tap while scrolled", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "scrolled", - }), - ).toEqual("focus"); - }); - - it("starts selection after a long press", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("select"); - }); - - it("starts selection after a 500ms hold", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 500, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("select"); - }); - - it("keeps a 400ms press as a tap", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 400, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("focus"); - }); - - it("does nothing after a scroll gesture", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: true, - didNavigate: false, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "scrolled", - }), - ).toEqual("none"); - }); - - it("does nothing after a navigation swipe", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: true, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("does nothing while actively selecting", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "selecting", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("clears an existing selection after a short tap", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "selecting", - startedWithSelection: true, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("clear"); - }); - - it("preserves a word selection created by the current long press", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "selecting", - startedWithSelection: false, - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("does not focus after an undecided fast drag", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "pressing", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: true, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - - it("does nothing from idle", () => { - expect( - resolveTerminalGestureReleaseAction({ - status: "idle", - didScroll: false, - didNavigate: false, - movedBeyondTapTolerance: false, - pressDurationMs: 80, - longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS, - scrollMode: "following", - }), - ).toEqual("none"); - }); - }); -}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts b/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts deleted file mode 100644 index 07b5267e87..0000000000 --- a/packages/app/src/terminal/native-renderer/terminal-selection-gesture.ts +++ /dev/null @@ -1,108 +0,0 @@ -export type TerminalGestureStatus = "idle" | "pressing" | "selecting"; -export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll"; -export type TerminalGestureScrollMode = "following" | "scrolled"; - -export interface TerminalGestureMoveInput { - status: TerminalGestureStatus; - dx: number; - dy: number; - vx: number; - vy: number; - elapsedMs?: number; -} - -export interface TerminalGestureReleaseInput { - status: TerminalGestureStatus; - startedWithSelection?: boolean; - didScroll: boolean; - didNavigate: boolean; - movedBeyondTapTolerance: boolean; - pressDurationMs: number; - longPressMs: number; - scrollMode: TerminalGestureScrollMode; -} - -export interface TerminalScrollGestureState { - lastDy: number; - rowRemainder: number; - didScroll: boolean; -} - -export interface TerminalScrollGestureUpdate { - state: TerminalScrollGestureState; - rowDelta: number; -} - -export type TerminalGestureAction = "none" | "focus" | "select" | "clear"; - -export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8; -export const TERMINAL_GESTURE_LONG_PRESS_MS = 450; -export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12; - -export function classifyTerminalGestureIntent( - input: TerminalGestureMoveInput, -): TerminalGestureIntent { - if (input.status === "selecting") { - return "select"; - } - - const absDx = Math.abs(input.dx); - const absDy = Math.abs(input.dy); - - if (absDy > TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX && absDy > absDx) { - return "scroll"; - } - - if (Math.hypot(input.dx, input.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) { - return "pending"; - } - - return "tap"; -} - -export function advanceTerminalScrollGesture(input: { - state: TerminalScrollGestureState; - currentDy: number; - cellHeight: number; -}): TerminalScrollGestureUpdate { - const deltaY = input.currentDy - input.state.lastDy; - const accumulatedRows = input.state.rowRemainder + deltaY; - const rowDelta = Math.trunc(accumulatedRows / input.cellHeight); - return { - state: { - lastDy: input.currentDy, - rowRemainder: accumulatedRows - rowDelta * input.cellHeight, - didScroll: input.state.didScroll || rowDelta !== 0, - }, - rowDelta, - }; -} - -export function resolveTerminalGestureReleaseAction( - input: TerminalGestureReleaseInput, -): TerminalGestureAction { - if (input.didScroll || input.didNavigate) { - return "none"; - } - - if (input.status === "selecting") { - if (!input.startedWithSelection) { - return "none"; - } - return input.movedBeyondTapTolerance ? "none" : "clear"; - } - - if (input.status !== "pressing") { - return "none"; - } - - if (input.pressDurationMs >= input.longPressMs) { - return "select"; - } - - if (input.movedBeyondTapTolerance) { - return "none"; - } - - return "focus"; -} diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts index c188671ffe..6141bf1efd 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts @@ -483,3 +483,51 @@ describe("native terminal selection", () => { expect(copied).toEqual([]); }); }); + +describe("endpoint dragging", () => { + it.each([ + { + endpoint: "start" as const, + fixed: { row: 2, col: 9 }, + target: { row: 3, col: 2 }, + expected: { row: 3, col: 0 }, + }, + { + endpoint: "end" as const, + fixed: { row: 2, col: 0 }, + target: { row: 1, col: 7 }, + expected: { row: 1, col: 9 }, + }, + ])( + "preserves the fixed boundary across a row edge for $endpoint", + ({ endpoint, fixed, target, expected }) => { + const selection = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 }; + selection.begin({ coordinate: fixed, bounds }); + selection.update({ coordinate: fixed, bounds }); + selection.beginEndpointDrag(endpoint, 10); + selection.update({ coordinate: target, bounds }); + expect(selection.getSnapshot().range?.[endpoint === "start" ? "start" : "end"]).toEqual( + expected, + ); + }, + ); + + it.each(["start", "end"] as const)( + "keeps the opposite endpoint fixed as %s crosses it", + (endpoint) => { + const selection = createTerminalSelectionModel(); + const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 }; + selection.begin({ coordinate: { row: 2, col: 2 }, bounds }); + selection.update({ coordinate: { row: 3, col: 4 }, bounds }); + selection.beginEndpointDrag(endpoint, 10); + const coordinate = endpoint === "start" ? { row: 4, col: 5 } : { row: 1, col: 1 }; + selection.update({ coordinate, bounds }); + expect(selection.getSnapshot().range).toEqual( + endpoint === "start" + ? { start: { row: 3, col: 5 }, end: coordinate, coordinateEpoch: 1 } + : { start: coordinate, end: { row: 2, col: 1 }, coordinateEpoch: 1 }, + ); + }, + ); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.ts b/packages/app/src/terminal/native-renderer/terminal-selection.ts index 450a3813ac..0f7391e669 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.ts @@ -25,6 +25,7 @@ export interface TerminalSelectionBounds { export interface TerminalSelectionModel { begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot; update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot; + beginEndpointDrag(endpoint: "start" | "end", cols: number): void; clear(): TerminalSelectionSnapshot; sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot; getSnapshot(): TerminalSelectionSnapshot; @@ -252,6 +253,7 @@ export function resolveTerminalSelectionRects( export function createTerminalSelectionModel(): TerminalSelectionModel { let state: TerminalSelectionState = { anchor: null, range: null }; + let drag: { endpoint: "start" | "end"; cols: number } | null = null; function snapshot(): TerminalSelectionSnapshot { return { range: state.range }; @@ -259,18 +261,31 @@ export function createTerminalSelectionModel(): TerminalSelectionModel { function setSelection(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { const coordinate = clampCoordinate(input.coordinate, input.bounds); - const anchor = state.anchor ?? coordinate; + const fixed = state.anchor ?? coordinate; + let anchor = fixed; + if (drag?.endpoint === "start" && compareCoordinates(coordinate, fixed) > 0) { + anchor = + fixed.col + 1 < drag.cols + ? { row: fixed.row, col: fixed.col + 1 } + : { row: fixed.row + 1, col: 0 }; + } else if (drag?.endpoint === "end" && compareCoordinates(coordinate, fixed) < 0) { + anchor = + fixed.col > 0 + ? { row: fixed.row, col: fixed.col - 1 } + : { row: fixed.row - 1, col: drag.cols - 1 }; + } const range = normalizeTerminalSelection({ anchor, focus: coordinate, coordinateEpoch: input.bounds.coordinateEpoch, }); - state = { anchor, range }; + state = { anchor: fixed, range }; return snapshot(); } return { begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { + drag = null; const coordinate = clampCoordinate(input.coordinate, input.bounds); state = { anchor: coordinate, @@ -285,7 +300,14 @@ export function createTerminalSelectionModel(): TerminalSelectionModel { update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { return setSelection(input); }, + beginEndpointDrag(endpoint, cols) { + if (state.range) { + drag = { endpoint, cols }; + state = { ...state, anchor: state.range[endpoint === "start" ? "end" : "start"] }; + } + }, clear(): TerminalSelectionSnapshot { + drag = null; state = { anchor: null, range: null }; return snapshot(); }, @@ -293,6 +315,7 @@ export function createTerminalSelectionModel(): TerminalSelectionModel { if (!state.range || rangeIsSafe(state.range, input.bounds)) { return snapshot(); } + drag = null; state = { anchor: null, range: null }; return snapshot(); }, diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 5dcefb8ebf..bf044237b0 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -166,7 +166,6 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { runtime.mount({ root, host, - initialSnapshot: null, scrollback: input.scrollback ?? 10_000, theme: { background: "#0b0b0b", @@ -718,11 +717,13 @@ describe("terminal emulator runtime in a real browser", () => { { name: "DA1-zero", bytes: "\x1b[0c" }, { name: "DA2", bytes: "\x1b[>c" }, { name: "DA3", bytes: "\x1b[=c" }, + { name: "XTVERSION", bytes: "\x1b[>q" }, { name: "DSR-5", bytes: "\x1b[5n" }, { name: "DSR-6", bytes: "\x1b[6n" }, { name: "DSR-?6", bytes: "\x1b[?6n" }, { name: "DECRQM", bytes: "\x1b[1$p" }, { name: "DECRQM-?", bytes: "\x1b[?1$p" }, + { name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" }, { name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" }, { name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" }, { name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" }, @@ -750,30 +751,11 @@ describe("terminal emulator runtime in a real browser", () => { const reset = vi.fn(originalReset); terminal.reset = reset; - mounted.runtime.renderSnapshot({ - state: { - rows: terminal.rows, - cols: terminal.cols, - scrollback: [], - grid: [ - [ - { char: "p" }, - { char: "r" }, - { char: "o" }, - { char: "m" }, - { char: "p" }, - { char: "t" }, - ], - ], - cursor: { - row: 0, - col: 6, - }, - }, - }); - await nextFrame(); - + mounted.runtime.replay({ type: "begin", rows: terminal.rows, cols: terminal.cols }); + mounted.runtime.replay({ type: "data", data: terminalOutput("prompt") }); + mounted.runtime.replay({ type: "end" }); expect(reset).not.toHaveBeenCalled(); + await nextFrame(); }); it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => { @@ -795,3 +777,156 @@ describe("terminal emulator runtime in a real browser", () => { expect(confirm).not.toHaveBeenCalled(); }); }); + +describe("incremental replay lifecycle", () => { + it.each(["clear", "remount"] as const)( + "recovers live input after interrupted replay and %s", + async (cleanup) => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 }); + mounted.root.style.width = "360px"; + mounted.runtime.resize({ shouldClaim: false }); + mounted.runtime.replay({ type: "data", data: terminalOutput("interrupted") }); + if (cleanup === "clear") + await new Promise((resolve) => mounted.runtime.clear({ onCommitted: resolve })); + else { + mounted.runtime.unmount(); + mounted.runtime.mount({ + root: mounted.root, + host: mounted.host, + scrollback: 1000, + theme: {}, + }); + } + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }), + ); + getBrowserTerminal().input("user-input", true); + expect(mounted.inputs).toContain("user-input"); + expect(getBrowserTerminal().cols).toBeLessThan(60); + }, + ); + + it("suppresses generated replies until queued replay chunks commit after end", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + mounted.runtime.replay({ type: "begin" }); + mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") }); + mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07") }); + mounted.runtime.replay({ type: "end" }); + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("committed"), onCommitted: resolve }), + ); + expect(mounted.inputs).toEqual([]); + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("\x1b]4;1;?\x07"), onCommitted: resolve }), + ); + expect(mounted.inputs).toHaveLength(1); + expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true); + }); + + it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + const text = "A😀B❤️C👋🏽D👨‍👩‍👧E"; + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput(text), onCommitted: resolve }), + ); + + const terminal = window.__paseoTerminal; + if (!terminal) { + throw new Error("Expected the browser terminal to be mounted"); + } + const line = terminal.buffer.active.getLine(terminal.buffer.active.cursorY); + const cells = Array.from({ length: 13 }, (_, col) => { + const cell = line?.getCell(col); + return { chars: cell?.getChars(), width: cell?.getWidth() }; + }); + + expect(cells).toEqual([ + { chars: "A", width: 1 }, + { chars: "😀", width: 2 }, + { chars: "", width: 0 }, + { chars: "B", width: 1 }, + { chars: "❤️", width: 2 }, + { chars: "", width: 0 }, + { chars: "C", width: 1 }, + { chars: "👋🏽", width: 2 }, + { chars: "", width: 0 }, + { chars: "D", width: 1 }, + { chars: "👨‍👩‍👧", width: 2 }, + { chars: "", width: 0 }, + { chars: "E", width: 1 }, + ]); + expect(terminal.buffer.active.cursorX).toBe(13); + + terminal.select(0, terminal.buffer.active.cursorY, 13); + expect(terminal.getSelection()).toBe(text); + }); + + // A host without grapheme widths answers cursor reports and replays with its + // own table. The web client keeps the Unicode 11 table it used before. + it("measures with the legacy table for hosts without grapheme widths", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + const terminal = window.__paseoTerminal; + if (!terminal) { + throw new Error("Expected the browser terminal to be mounted"); + } + const buffer = terminal.buffer; + async function cursorAfter(text: string): Promise { + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput(`\r\n${text}`), onCommitted: resolve }), + ); + return buffer.active.cursorX; + } + + mounted.runtime.setCharacterWidths({ widths: "legacy" }); + expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 3]); + + mounted.runtime.setCharacterWidths({ widths: "graphemes" }); + expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 4]); + }); +}); + +it("restores fitted web geometry after replay before live output", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + await settleMountRefits(); + const local = latestSize(mounted.sizes); + mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 }); + mounted.runtime.replay({ type: "data", data: terminalOutput("x".repeat(100)) }); + mounted.runtime.replay({ type: "end" }); + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }), + ); + expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols); + expect(getBrowserTerminal().cols).toBeLessThan(120); + expect(getBrowserTerminal().rows).toBe(local.rows); + mounted.runtime.resize({ forceClaim: true, shouldClaim: true }); + expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols); +}); + +it("defers layout fit until all replay chunks commit", async () => { + const mounted = createTerminalHost({ width: 720, height: 360 }); + await settleMountRefits(); + mounted.runtime.replay({ type: "begin", rows: 30, cols: 120 }); + await new Promise((resolve) => + mounted.runtime.write({ + data: terminalOutput("first"), + suppressInput: true, + onCommitted: resolve, + }), + ); + expect(getBrowserTerminal().cols).toBe(120); + mounted.root.style.width = "360px"; + mounted.runtime.resize({ shouldClaim: true, forceClaim: true, forceRefresh: true }); + await nextFrame(); + expect(getBrowserTerminal().cols).toBe(120); + mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b[2;110Hsecond") }); + mounted.runtime.replay({ type: "end" }); + await new Promise((resolve) => + mounted.runtime.write({ data: terminalOutput("LIVE"), onCommitted: resolve }), + ); + expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols); + expect(getBrowserTerminal().cols).toBeLessThan(60); + expect(latestSize(mounted.sizes)).toMatchObject({ shouldClaim: true, forceClaim: true }); + mounted.runtime.resize({ shouldClaim: true, forceClaim: true }); + expect(getBrowserTerminal().cols).toBe(latestSize(mounted.sizes).cols); +}); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index a11cedbfe2..7b1568b60e 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -36,6 +36,12 @@ vi.mock("@xterm/addon-unicode11", () => ({ }, })); +vi.mock("@xterm/addon-unicode-graphemes", () => ({ + UnicodeGraphemesAddon: class UnicodeGraphemesAddon { + dispose(): void {} + }, +})); + vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class WebLinksAddon { dispose(): void {} @@ -82,6 +88,7 @@ import { createTerminalResizeEvent, encodeTerminalOutput, TerminalEmulatorRuntime, + type TerminalClipboard, } from "./terminal-emulator-runtime"; interface StubTerminal { @@ -302,25 +309,14 @@ describe("terminal-emulator-runtime", () => { expect(committed).toEqual(["first", "second", "clear", "third"]); }); - it("falls back to timeout commit for a barrier op when the gate sentinel never fires", () => { + it("unblocks queued replay writes if a begin barrier never commits", () => { vi.useFakeTimers(); - const { runtime, terminal } = createRuntimeWithTerminal(); - const onCommitted = vi.fn(); - - // restoreOutput is a barrier; if xterm never commits the gate sentinel, the barrier still - // applies (and commits) after the 5s safety timeout instead of stalling forever. - runtime.restoreOutput({ - data: terminalOutput("stuck"), - onCommitted, - }); - - expect(onCommitted).not.toHaveBeenCalled(); - expect(terminal.resetCalls).toBe(0); - vi.advanceTimersByTime(5_000); - // The barrier now runs (write submitted) but its own commit still waits on xterm; advance - // again to fire the barrier's own write timeout. - vi.advanceTimersByTime(5_000); - expect(onCommitted).toHaveBeenCalledTimes(1); + const { runtime, writeTexts } = createRuntimeWithTerminal(); + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("stuck") }); + expect(writeTexts).toEqual(["\x1bc"]); + vi.advanceTimersByTime(5000); + expect(writeTexts).toEqual(["\x1bc", "stuck"]); }); it("reports input mode changes from terminal output and resets them on snapshots", () => { @@ -343,18 +339,7 @@ describe("terminal-emulator-runtime", () => { }); runtime.write({ data: terminalOutput("\x1b[>7u") }); - runtime.renderSnapshot({ - state: { - rows: 2, - cols: 8, - scrollback: [], - grid: [[{ char: "$" }, { char: " " }]], - cursor: { - row: 0, - col: 2, - }, - }, - }); + runtime.replay({ type: "begin", rows: 2, cols: 8 }); // The plain write reports kitty flags synchronously during drain; the snapshot resets // them only after its barrier gate (the sentinel write callback) resolves. expect(inputModeChanges).toEqual([ @@ -410,40 +395,18 @@ describe("terminal-emulator-runtime", () => { expect(committed).toEqual(["second", "first"]); }); - it("applies a snapshot after the writes ahead of it, never interleaving the reset", () => { + it("keeps replay reset and data after preceding writes, without interleaving", () => { const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal(); - const committed: string[] = []; - - runtime.write({ - data: terminalOutput("before-a"), - onCommitted: () => { - committed.push("before-a"); - }, - }); - runtime.write({ - data: terminalOutput("before-b"), - onCommitted: () => { - committed.push("before-b"); - }, - }); - runtime.restoreOutput({ - data: terminalOutput("snap"), - onCommitted: () => { - committed.push("snap"); - }, - }); - - // Plain writes are submitted up front; the snapshot's reset+write has not run yet. + runtime.write({ data: terminalOutput("before-a") }); + runtime.write({ data: terminalOutput("before-b") }); + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("snap") }); + runtime.replay({ type: "end" }); expect(writeTexts).toEqual(["before-a", "before-b"]); - - // writeCallbacks: [0]=before-a, [1]=before-b, [2]=barrier gate sentinel. - // The snapshot is gated behind the prior writes; resolving the gate runs it after them. writeCallbacks[2]?.(); - expect(writeTexts).toEqual(["before-a", "before-b", "csnap"]); - - // Snapshot commit fires only after its own write callback, strictly after the writes. + expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc"]); writeCallbacks[3]?.(); - expect(committed).toEqual(["snap"]); + expect(writeTexts).toEqual(["before-a", "before-b", "\x1bc", "snap"]); expect(terminal.resetCalls).toBe(0); }); @@ -456,7 +419,7 @@ describe("terminal-emulator-runtime", () => { // No plain writes precede this barrier (mount), so there is nothing to gate: it starts // at once with no sentinel, flipping suppressInput synchronously. - runtime.restoreOutput({ data: terminalOutput("snapshot") }); + runtime.replay({ type: "begin" }); expect(readSuppressInput()).toBe(true); // writeCallbacks[0] is the barrier's own snapshot write; committing it restores input. @@ -471,7 +434,7 @@ describe("terminal-emulator-runtime", () => { // A plain write is now ungated, so the following barrier must wait on the sentinel. runtime.write({ data: terminalOutput("output") }); - runtime.restoreOutput({ data: terminalOutput("snapshot") }); + runtime.replay({ type: "begin" }); // The plain write commits first. Only the following sentinel opens the barrier. expect(readSuppressInput()).toBe(false); @@ -505,57 +468,40 @@ describe("terminal-emulator-runtime", () => { expect(onCommittedB).toHaveBeenCalledTimes(1); }); - it("clears ungated writes on unmount so remount barriers apply immediately", () => { + it("clears ungated writes on unmount so remount replay begins immediately", () => { const { runtime } = createRuntimeWithTerminal(); - runtime.write({ data: terminalOutput("before unmount") }); runtime.unmount(); - const remounted = attachStubTerminal(runtime); - runtime.restoreOutput({ data: terminalOutput("restored screen") }); - - expect(remounted.writeTexts).toEqual(["\u001bcrestored screen"]); - expect(remounted.writeCallbacks).toHaveLength(1); + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("restored screen") }); + expect(remounted.writeTexts).toEqual(["\x1bc"]); + remounted.writeCallbacks[0]?.(); + expect(remounted.writeTexts).toEqual(["\x1bc", "restored screen"]); }); - it("replays snapshots through a single write without first painting a reset terminal", () => { + it("parses replay chunks serially without synchronously resetting the terminal", () => { const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal(); - - runtime.renderSnapshot({ - state: { - rows: 2, - cols: 8, - scrollback: [], - grid: [ - [{ char: "h" }, { char: "i" }], - [{ char: "$" }, { char: " " }], - ], - cursor: { - row: 1, - col: 2, - }, - }, - }); - - // The snapshot is a barrier; its write applies after the gate sentinel (writeCallbacks[0]). - writeCallbacks[0]?.(); - + runtime.replay({ type: "begin", rows: 2, cols: 8 }); + runtime.replay({ type: "data", data: terminalOutput("hi") }); + runtime.replay({ type: "data", data: terminalOutput("\r\n$") }); + runtime.replay({ type: "end" }); expect(terminal.resetCalls).toBe(0); - expect(writeTexts).toHaveLength(1); - expect(writeTexts[0]?.startsWith("\u001bc")).toBe(true); - expect(writeTexts[0]).toContain("hi"); + expect(writeTexts).toEqual(["\x1bc"]); + writeCallbacks[0]?.(); + expect(writeTexts).toEqual(["\x1bc", "hi"]); + writeCallbacks[1]?.(); + expect(writeTexts).toEqual(["\x1bc", "hi", "\r\n$"]); }); - it("restores server-rendered ANSI snapshots through the snapshot write path", () => { + it("restores worker-rendered ANSI through the replay lifecycle", () => { const { runtime, terminal, writeTexts, writeCallbacks } = createRuntimeWithTerminal(); - - runtime.restoreOutput({ data: terminalOutput("restored screen") }); - - // restoreOutput is a barrier (suppressInput); it applies after the gate sentinel. + runtime.replay({ type: "begin" }); + runtime.replay({ type: "data", data: terminalOutput("restored screen") }); + runtime.replay({ type: "end" }); writeCallbacks[0]?.(); - expect(terminal.resetCalls).toBe(0); - expect(writeTexts).toEqual(["\u001bcrestored screen"]); + expect(writeTexts).toEqual(["\x1bc", "restored screen"]); }); it("forces a refit when resize is requested", () => { @@ -699,3 +645,97 @@ describe("terminal-emulator-runtime", () => { expect(fitAndEmitResize).not.toHaveBeenCalled(); }); }); + +describe("terminal-emulator-runtime clipboard shortcuts", () => { + type KeyHandler = (event: KeyboardEvent) => boolean; + + function createClipboard(available: boolean) { + return { + isAvailable: () => available, + readText: vi.fn(async () => "clipboard text"), + read: vi.fn(async () => []), + writeText: vi.fn(async (_text: string) => undefined), + } satisfies TerminalClipboard; + } + + function attachKeyHandler(input: { clipboard: TerminalClipboard; selection: string }) { + const runtime = new TerminalEmulatorRuntime({ isMac: false }, input.clipboard); + let handler: KeyHandler | null = null; + const paste = vi.fn(); + runtime.attachKeyEventHandler({ + attachCustomKeyEventHandler: (next: KeyHandler) => { + handler = next; + }, + hasSelection: () => input.selection.length > 0, + getSelection: () => input.selection, + paste, + input: vi.fn(), + scrollToBottom: vi.fn(), + }); + if (!handler) throw new Error("key handler was not attached"); + return { handler: handler as KeyHandler, paste }; + } + + function ctrlKey(key: string) { + const preventDefault = vi.fn(); + const event = { + type: "keydown", + key, + isComposing: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + preventDefault, + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + return { event, preventDefault }; + } + + it("leaves Ctrl+C with a selection to the native copy event without the Clipboard API", () => { + const clipboard = createClipboard(false); + const { handler } = attachKeyHandler({ clipboard, selection: "selected text" }); + const { event, preventDefault } = ctrlKey("c"); + + expect(handler(event)).toBe(false); + expect(clipboard.writeText).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("leaves Ctrl+V to the native paste event without the Clipboard API", () => { + const clipboard = createClipboard(false); + const { handler, paste } = attachKeyHandler({ clipboard, selection: "" }); + const { event, preventDefault } = ctrlKey("v"); + + expect(handler(event)).toBe(false); + expect(clipboard.readText).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); + }); + + it("lets xterm send ^C for Ctrl+C without a selection when the Clipboard API is missing", () => { + const clipboard = createClipboard(false); + const { handler } = attachKeyHandler({ clipboard, selection: "" }); + const { event, preventDefault } = ctrlKey("c"); + + expect(handler(event)).toBe(true); + expect(clipboard.writeText).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("copies and pastes through the Clipboard API when it is available", async () => { + const clipboard = createClipboard(true); + const { handler, paste } = attachKeyHandler({ clipboard, selection: "selected text" }); + + const copy = ctrlKey("c"); + expect(handler(copy.event)).toBe(false); + expect(clipboard.writeText).toHaveBeenCalledWith("selected text"); + expect(copy.preventDefault).not.toHaveBeenCalled(); + + const pasteKey = ctrlKey("v"); + expect(handler(pasteKey.event)).toBe(false); + expect(pasteKey.preventDefault).toHaveBeenCalledTimes(1); + expect(clipboard.readText).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("clipboard text")); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 0a55cd7126..b1cab84d0e 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -3,11 +3,12 @@ import { FitAddon } from "@xterm/addon-fit"; import { ImageAddon } from "@xterm/addon-image"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs"; import { Terminal, type ITheme, type IMarker } from "@xterm/xterm"; -import type { TerminalState } from "@getpaseo/protocol/messages"; +import type { TerminalCharacterWidths } from "../character-widths"; import { type TerminalInputModeState, TerminalInputModeTracker, @@ -24,7 +25,6 @@ import { resolveMacTerminalEditingShortcut, shouldInterceptDomTerminalKey, } from "@/utils/terminal-keys"; -import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; import { createTerminalLocalFileLinkProvider, type TerminalLocalFileLinkSource, @@ -55,7 +55,6 @@ const FIND_HIGHLIGHT_LIMIT = 20_000; export interface TerminalEmulatorRuntimeMountInput { root: HTMLDivElement; host: HTMLDivElement; - initialSnapshot: TerminalState | null; scrollback: number; theme: ITheme; fontFamily?: string; @@ -134,7 +133,7 @@ interface TerminalEmulatorRuntimeDisposables { } interface TerminalOutputOperation { - type: "write" | "clear" | "snapshot"; + type: "write" | "clear" | "snapshot" | "fit"; data: TerminalOutputData; rows?: number; cols?: number; @@ -185,15 +184,26 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme { } export interface TerminalClipboard { + /** Whether the async Clipboard API is usable. Browsers omit it outside secure contexts. */ + isAvailable?(): boolean; readText(): Promise; read(): Promise[]>; writeText(text: string): Promise; } +function getBrowserClipboard(): Clipboard | undefined { + return typeof navigator !== "undefined" ? navigator.clipboard : undefined; +} + +function rejectUnavailableClipboard(): Promise { + return Promise.reject(new Error("Clipboard API is unavailable")); +} + const browserClipboard: TerminalClipboard = { - readText: () => navigator.clipboard.readText(), - read: () => navigator.clipboard.read(), - writeText: (text) => navigator.clipboard.writeText(text), + isAvailable: () => !!getBrowserClipboard(), + readText: () => getBrowserClipboard()?.readText() ?? rejectUnavailableClipboard(), + read: () => getBrowserClipboard()?.read() ?? rejectUnavailableClipboard(), + writeText: (text) => getBrowserClipboard()?.writeText(text) ?? rejectUnavailableClipboard(), }; async function readClipboardImage(clipboard: TerminalClipboard): Promise { @@ -224,6 +234,12 @@ function findFileOnlyPaste(transfer: DataTransfer | null): File | null { return null; } +// Grapheme widths match the daemon and native buffers. Legacy hosts keep the +// Unicode 11 table web used before. See docs/terminal-rendering.md. +function applyCharacterWidths(terminal: Terminal, widths: TerminalCharacterWidths): void { + terminal.unicode.activeVersion = widths === "graphemes" ? "15-graphemes" : "11"; +} + export class TerminalEmulatorRuntime { constructor( private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }, @@ -236,6 +252,7 @@ export class TerminalEmulatorRuntime { shift: false, alt: false, }; + private characterWidths: TerminalCharacterWidths = "graphemes"; private terminal: Terminal | null = null; private fitAddon: FitAddon | null = null; private searchAddon: SearchAddon | null = null; @@ -280,6 +297,7 @@ export class TerminalEmulatorRuntime { }, }; + private replayResize: TerminalResizeRequest | null = null; private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null; private lastSize: { rows: number; cols: number } | null = null; private cleanup: (() => void) | null = null; @@ -322,6 +340,16 @@ export class TerminalEmulatorRuntime { this.pendingModifiers = input.pendingModifiers; } + setCharacterWidths(input: { widths: TerminalCharacterWidths }): void { + this.characterWidths = input.widths; + const terminal = this.terminal; + if (!terminal) { + return; + } + applyCharacterWidths(terminal, input.widths); + this.refreshVisibleRows(); + } + private loadSearchAddon(terminal: Terminal): void { const searchAddon = new SearchAddon({ highlightLimit: FIND_HIGHLIGHT_LIMIT }); terminal.loadAddon(searchAddon); @@ -370,22 +398,7 @@ export class TerminalEmulatorRuntime { !event.altKey && !event.metaKey ) { - const key = event.key.toLowerCase(); - - // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT - if (key === "c" && terminal.hasSelection()) { - void this.clipboard.writeText(terminal.getSelection()); - return false; - } - - // Ctrl+V: paste clipboard text, or the clipboard image when there is no text - if (key === "v") { - event.preventDefault(); - this.pasteClipboard(terminal); - return false; - } - - return true; + return !this.handleCtrlClipboardShortcut(terminal, event); } if (this.maybeSendMacEditingShortcut(terminal, event)) { @@ -434,6 +447,36 @@ export class TerminalEmulatorRuntime { }); } + /** Returns true when a non-Mac Ctrl+C / Ctrl+V clipboard shortcut was handled. */ + private handleCtrlClipboardShortcut( + terminal: Pick, + event: KeyboardEvent, + ): boolean { + const key = event.key.toLowerCase(); + // Without the Clipboard API (e.g. plain http on a LAN address), the browser's native + // copy/paste events still fire and xterm handles them, so leave the default action. + const clipboardAvailable = this.clipboard.isAvailable?.() ?? true; + + // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT + if (key === "c" && terminal.hasSelection()) { + if (clipboardAvailable) { + void this.clipboard.writeText(terminal.getSelection()); + } + return true; + } + + // Ctrl+V: paste clipboard text, or the clipboard image when there is no text + if (key === "v") { + if (clipboardAvailable) { + event.preventDefault(); + this.pasteClipboard(terminal); + } + return true; + } + + return false; + } + private handleFindShortcut(event: KeyboardEvent): boolean { if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false; event.preventDefault(); @@ -531,11 +574,12 @@ export class TerminalEmulatorRuntime { theme: withOverviewRulerBorderHidden(input.theme), }); const fitAddon = new FitAddon(); - const unicode11Addon = new Unicode11Addon(); let webglAddon: WebglAddon | null = null; let imageAddon: ImageAddon | null = null; terminal.loadAddon(fitAddon); - terminal.loadAddon(unicode11Addon); + terminal.loadAddon(new Unicode11Addon()); + terminal.loadAddon(new UnicodeGraphemesAddon()); + applyCharacterWidths(terminal, this.characterWidths); terminal.loadAddon(new WebLinksAddon(openExternalLink)); const localFileLinkProvider = terminal.registerLinkProvider( createTerminalLocalFileLinkProvider(terminal, { @@ -570,11 +614,6 @@ export class TerminalEmulatorRuntime { terminal.open(input.host); this.themeBackgroundElements = this.collectThemeBackgroundElements(input); this.applyThemeBackground(input.theme); - try { - terminal.unicode.activeVersion = "11"; - } catch { - // Ignore if unicode API isn't available in this build/runtime. - } const disposeImageAddon = (): void => { try { @@ -608,6 +647,7 @@ export class TerminalEmulatorRuntime { terminal.parser.registerCsiHandler({ final: "c" }, () => true); terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true); terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true); + terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true); terminal.parser.registerCsiHandler({ final: "n" }, () => true); terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true); terminal.parser.registerCsiHandler({ final: "R" }, () => true); @@ -616,6 +656,7 @@ export class TerminalEmulatorRuntime { { prefix: "?", intermediates: "$", final: "p" }, () => true, ); + terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true); for (const code of [10, 11, 12]) { terminal.parser.registerOscHandler(code, (data) => data.trim() === "?"); } @@ -652,6 +693,7 @@ export class TerminalEmulatorRuntime { window.__paseoTerminal = terminal; const fitAndEmitResize = (resizeInput?: TerminalResizeRequest): void => { + if (this.deferReplayResize(resizeInput)) return; const forceRefresh = resizeInput?.forceRefresh ?? false; const shouldClaim = resizeInput?.shouldClaim ?? true; const forceClaim = resizeInput?.forceClaim ?? false; @@ -777,10 +819,6 @@ export class TerminalEmulatorRuntime { fitAndEmitResize({ forceRefresh: true, shouldClaim: false }); }, 0); - if (input.initialSnapshot) { - this.renderSnapshot({ state: input.initialSnapshot }); - } - this.processOutputQueue(); const disposables: TerminalEmulatorRuntimeDisposables = { @@ -898,20 +936,19 @@ export class TerminalEmulatorRuntime { onFilePaste?.(file); } - renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void { - if (!input.state) { - this.clear(input); - return; + replay(event: import("@getpaseo/client").TerminalReplayEvent): void { + if (event.type === "begin") { + this.replayResize ??= { shouldClaim: false }; + this.restoreOutput({ data: EMPTY_TERMINAL_OUTPUT, rows: event.rows, cols: event.cols }); + } else if (event.type === "data") { + this.write({ data: event.data, suppressInput: true }); + } else { + this.outputOperations.push({ type: "fit", data: EMPTY_TERMINAL_OUTPUT }); + this.processOutputQueue(); } - this.restoreOutput({ - data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)), - rows: input.state.rows, - cols: input.state.cols, - ...(input.onCommitted ? { onCommitted: input.onCommitted } : {}), - }); } - restoreOutput(input: { + private restoreOutput(input: { data: TerminalOutputData; rows?: number; cols?: number; @@ -928,6 +965,16 @@ export class TerminalEmulatorRuntime { this.processOutputQueue(); } + private deferReplayResize(input?: TerminalResizeRequest): boolean { + if (!this.replayResize) return false; + this.replayResize = { + forceRefresh: this.replayResize.forceRefresh || input?.forceRefresh, + shouldClaim: this.replayResize.shouldClaim || (input?.shouldClaim ?? true), + forceClaim: this.replayResize.forceClaim || input?.forceClaim, + }; + return true; + } + resize(input?: TerminalResizeRequest): void { this.fitAndEmitResize?.(input); } @@ -1047,6 +1094,7 @@ export class TerminalEmulatorRuntime { commit(); } this.hasUngatedWrites = false; + this.replayResize = null; this.cleanup?.(); this.cleanup = null; @@ -1176,16 +1224,40 @@ export class TerminalEmulatorRuntime { this.processOutputQueue(); }; + if (operation.type === "fit") { + // xterm resize flushes its write buffer synchronously. Leave the parser's + // commit callback before fitting, or that flush consumes the next write. + queueMicrotask(() => { + if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return; + const resize = this.replayResize ?? { shouldClaim: false }; + this.replayResize = null; + this.fitAndEmitResize?.(resize); + finalizeOperation(operation); + }); + return; + } + if (operation.type === "clear") { this.inputModeDecoder.decode(); this.inputModeTracker.reset(); this.emitInputModeChange(); terminal.reset(); - finalizeOperation(operation); + if (!this.replayResize) { + finalizeOperation(operation); + return; + } + queueMicrotask(() => { + if (this.inFlightOutputOperation !== operation || this.terminal !== terminal) return; + const resize = this.replayResize ?? { shouldClaim: false }; + this.replayResize = null; + this.fitAndEmitResize?.(resize); + finalizeOperation(operation); + }); return; } if (operation.type === "snapshot") { + this.replayResize ??= { shouldClaim: false }; this.inputModeDecoder.decode(); this.inputModeTracker.reset(); this.emitInputModeChange(); diff --git a/packages/app/src/terminal/runtime/terminal-find.browser.test.ts b/packages/app/src/terminal/runtime/terminal-find.browser.test.ts index 8336925338..84b941a340 100644 --- a/packages/app/src/terminal/runtime/terminal-find.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-find.browser.test.ts @@ -31,7 +31,6 @@ function mount(options?: { isMac: boolean }) { runtime.mount({ root, host, - initialSnapshot: null, scrollback: 1000, theme: { background: "#ffffff", foreground: "#111111" }, }); diff --git a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts new file mode 100644 index 0000000000..62a65c6878 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts @@ -0,0 +1,202 @@ +import "@xterm/xterm/css/xterm.css"; +import { page } from "vitest/browser"; +import { afterEach, expect, it } from "vitest"; +import type { Terminal } from "@xterm/xterm"; +import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; + +interface TerminalView { + root: HTMLDivElement; + host: HTMLDivElement; + runtime: TerminalEmulatorRuntime; + terminal: Terminal; +} + +interface RowImage { + width: number; + height: number; + pixels: Uint8ClampedArray; +} + +const views: TerminalView[] = []; +const header = "HEADER_0123456789 한글 日本語"; + +function mountTerminal(left: number): TerminalView { + const root = document.createElement("div"); + Object.assign(root.style, { + position: "fixed", + left: `${left}px`, + top: "0", + width: "440px", + height: "360px", + overflow: "hidden", + }); + const host = document.createElement("div"); + Object.assign(host.style, { width: "100%", height: "100%" }); + root.appendChild(host); + document.body.appendChild(root); + const runtime = new TerminalEmulatorRuntime(); + runtime.mount({ + root, + host, + scrollback: 1000, + fontFamily: "monospace", + fontSize: 16, + theme: { background: "#101010", foreground: "#eeeeee", cursor: "#eeeeee" }, + }); + const terminal = window.__paseoTerminal; + if (!terminal) throw new Error("Terminal did not mount"); + const view = { root, host, runtime, terminal }; + views.push(view); + return view; +} + +function write(view: TerminalView, text: string): Promise { + return new Promise((resolve) => { + view.runtime.write({ data: encodeTerminalOutput(text), onCommitted: resolve }); + }); +} + +function webglCanvas(view: TerminalView): HTMLCanvasElement { + for (const canvas of view.host.querySelectorAll(".xterm-screen canvas")) { + if (canvas instanceof HTMLCanvasElement && canvas.getContext("webgl2")) return canvas; + } + throw new Error("Real WebGL is required; DOM fallback cannot test glyph corruption"); +} + +async function mountPair() { + await page.viewport(1000, 600); + const active = mountTerminal(0); + const sibling = mountTerminal(460); + await document.fonts.ready; + await expect + .poll(() => active.host.querySelectorAll(".xterm-screen canvas").length) + .toBeGreaterThan(0); + await expect + .poll(() => sibling.host.querySelectorAll(".xterm-screen canvas").length) + .toBeGreaterThan(0); + webglCanvas(active); + webglCanvas(sibling); + await write(active, "\x1b[?25l"); + await write(sibling, "\x1b[?25l"); + return { active, sibling }; +} + +function captureHeader(input: { view: TerminalView; output?: string }): Promise { + const { view, output } = input; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + rendered.dispose(); + reject(new Error("Terminal did not render within 2 seconds")); + }, 2000); + const rendered = view.terminal.onRender(() => { + rendered.dispose(); + clearTimeout(timeout); + try { + // Copy during onRender, before WebGL discards its default drawing buffer. + const source = webglCanvas(view); + const canvas = document.createElement("canvas"); + canvas.width = source.width; + canvas.height = Math.round(source.height / view.terminal.rows); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas 2D pixel capture is unavailable"); + context.drawImage(source, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + resolve({ width: canvas.width, height: canvas.height, pixels }); + } catch (error) { + reject(error); + } + }); + if (output === undefined) view.terminal.refresh(0, view.terminal.rows - 1); + else view.runtime.write({ data: encodeTerminalOutput(output) }); + }); +} + +function expectSamePixels(input: { expected: RowImage; actual: RowImage; label: string }): void { + const { expected, actual, label } = input; + expect(actual.width, label).toBe(expected.width); + expect(actual.height, label).toBe(expected.height); + let changed = 0; + for (let index = 0; index < expected.pixels.length; index += 1) { + if (expected.pixels[index] !== actual.pixels[index]) changed += 1; + } + expect(changed, label).toBe(0); +} + +function coloredOutput(input: { cells: number; offset: number }): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let output = ""; + for (let index = 0; index < input.cells; index += 1) { + const value = index + input.offset; + const foreground = `${value % 256};${(value >> 8) % 256};${(value * 13) % 256}`; + const background = (value * 7) % 256; + output += `\x1b[38;2;${foreground}m\x1b[48;5;${background}m${alphabet[value % alphabet.length]}`; + } + return `${output}\x1b[0m`; +} + +afterEach(() => { + for (const view of views.splice(0)) { + view.runtime.unmount(); + view.root.remove(); + } +}); + +it("preserves another terminal's glyph pixels when a shared atlas is cleared", async () => { + const { active, sibling } = await mountPair(); + await write(sibling, header); + const before = await captureHeader({ view: sibling }); + const visibleChannels = before.pixels.filter( + (channel, index) => index % 4 !== 3 && channel > 100, + ); + expect(visibleChannels.length).toBeGreaterThan(100); + + active.terminal.clearTextureAtlas(); + await captureHeader({ view: active, output: coloredOutput({ cells: 2000, offset: 0 }) }); + const after = await captureHeader({ + view: sibling, + output: `\x1b[${sibling.terminal.rows};1Hx`, + }); + expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header); + expectSamePixels({ expected: before, actual: after, label: "sibling after atlas clear" }); +}); + +it("preserves both headers while ordinary output grows the shared glyph atlas", async () => { + const { active, sibling } = await mountPair(); + const cells = active.terminal.cols * (active.terminal.rows - 2); + // Startup glyphs can stay on an unmerged page and hide this bug. Allocate the + // reference after some output, as a real long-running TUI does. + for (let batch = 0; batch < 16; batch += 1) { + await captureHeader({ + view: active, + output: `\x1b[H${coloredOutput({ cells, offset: batch * cells })}`, + }); + } + const lateHeader = `\x1b[H\x1b[0m\x1b[2K\x1b[38;2;253;251;247m${header}\x1b[0m`; + await write(active, lateHeader); + await write(sibling, lateHeader); + const activeBefore = await captureHeader({ view: active }); + const siblingBefore = await captureHeader({ view: sibling }); + + for (let batch = 0; batch < 256; batch += 1) { + const activeFrame = await captureHeader({ + view: active, + output: `\x1b[2;1H${coloredOutput({ cells, offset: (batch + 16) * cells })}`, + }); + const siblingFrame = await captureHeader({ + view: sibling, + output: `\x1b[${sibling.terminal.rows};1H${batch % 10}`, + }); + expect(active.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header); + expect(sibling.terminal.buffer.active.getLine(0)?.translateToString(true)).toBe(header); + expectSamePixels({ + expected: activeBefore, + actual: activeFrame, + label: `active frame ${batch}`, + }); + expectSamePixels({ + expected: siblingBefore, + actual: siblingFrame, + label: `sibling frame ${batch}`, + }); + } +}, 30000); diff --git a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts index 35b3492b71..d72a935f49 100644 --- a/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-resize-repro.browser.test.ts @@ -1,15 +1,14 @@ +import { Terminal as ReplaySource } from "@xterm/xterm"; +import { SerializeAddon } from "@xterm/addon-serialize"; import { page } from "@vitest/browser/context"; import { afterEach, describe, expect, it } from "vitest"; -import type { TerminalState } from "@getpaseo/protocol/messages"; import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; // Regression: "streaming pino log, resized the Paseo terminal, old logs stayed // narrow and new logs drew on top of the old ones." // -// A heavy stream overflows MAX_TERMINAL_OUTPUT_FRAME_BYTES, so the server sends a -// SNAPSHOT mid-stream. The client restores it via renderTerminalSnapshotToAnsi. -// When the snapshot carries per-row soft-wrap flags, restored long lines must -// reflow on resize just like live output — not freeze at the snapshot width. +// Backpressure triggers an ANSI replay serialized directly from xterm. Replayed +// soft-wrapped lines must reflow on resize like live output. interface Mounted { host: HTMLDivElement; @@ -57,7 +56,6 @@ function mount(width: number, height: number): Mounted { runtime.mount({ root, host, - initialSnapshot: null, scrollback: 10_000, theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" }, }); @@ -90,9 +88,18 @@ function someRowContains(term: InspectableTerminal, text: string): boolean { return dumpRows(term).some((r) => r.text.includes(text)); } -function renderSnapshotCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) { +function replayCommitted( + runtime: TerminalEmulatorRuntime, + state: { rows: number; cols: number; data: Uint8Array }, +) { return new Promise((resolve) => { - runtime.renderSnapshot({ state, onCommitted: resolve }); + runtime.replay({ type: "begin", rows: state.rows, cols: state.cols }); + runtime.replay({ + type: "data", + data: state.data, + }); + runtime.replay({ type: "end" }); + runtime.write({ data: new Uint8Array([0]), onCommitted: resolve }); }); } @@ -102,31 +109,18 @@ function writeCommitted(runtime: TerminalEmulatorRuntime, data: Uint8Array) { }); } -// Build a server-style snapshot of one long pino line that the SERVER soft-wrapped -// at `cols`, represented as multiple grid rows (exactly what the daemon stores in -// its headless xterm and ships in a snapshot). -function buildWrappedSnapshot(cols: number): TerminalState { - const longLine = `[server] [15:30:00.123] TRACE: provider.claude.raw_event {"module":"agent","seq":1,"sessionId":"ed8972f4-d3be-45d0-992c-631f8f1ed04e","turnId":"foreground-turn-1","payload":"${"A".repeat(40)}"}`; - const grid: TerminalState["grid"] = []; - for (let i = 0; i < longLine.length; i += cols) { - const chunk = longLine.slice(i, i + cols); - grid.push([...chunk].map((char) => ({ char }))); +// Serialize one long pino line from a real server-width xterm buffer. +// The replay must retain its soft-wrap boundaries for later client reflow. +async function buildWrappedSnapshot(cols: number) { + const source = new ReplaySource({ rows: 24, cols, allowProposedApi: true }); + const serializer = new SerializeAddon(); + source.loadAddon(serializer); + try { + await new Promise((resolve) => source.write(pinoLine(0), resolve)); + return { rows: 24, cols, data: new TextEncoder().encode(serializer.serialize()) }; + } finally { + source.dispose(); } - // The fixed daemon ships per-row soft-wrap flags: every row of the logical line - // continues onto the next except the last. - const gridWrapped = grid.map((_, index) => index < grid.length - 1); - // Cursor sits just below the restored content, exactly where the daemon's - // headless terminal leaves it after a line break, so streamed output appends - // cleanly below instead of overwriting the restored rows. - return { - rows: 24, - cols, - scrollback: [], - scrollbackWrapped: [], - grid, - gridWrapped, - cursor: { row: grid.length, col: 0 }, - }; } function pinoLine(seq: number): string { @@ -141,15 +135,14 @@ afterEach(() => { }); describe("terminal resize reflow repro (Paseo terminal)", () => { - it("snapshot-restored rows stay frozen at the snapshot width after the terminal grows", async () => { + it("replayed soft-wrapped rows reflow after the terminal grows", async () => { await page.viewport(1600, 700); const m = mount(560, 360); // ~70 cols await waitFor(() => m.terminal.cols > 40); const narrowCols = m.terminal.cols; - // 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the - // long line wrapped at the server width as separate grid rows. - await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols)); + // 1) A mid-stream ANSI replay arrives, preserving wraps at the captured width. + await replayCommitted(m.runtime, await buildWrappedSnapshot(narrowCols)); await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]")); // 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped). diff --git a/packages/app/src/terminal/runtime/terminal-restore-options.test.ts b/packages/app/src/terminal/runtime/terminal-restore-options.test.ts index 14f2ce4d8f..7e5294b347 100644 --- a/packages/app/src/terminal/runtime/terminal-restore-options.test.ts +++ b/packages/app/src/terminal/runtime/terminal-restore-options.test.ts @@ -4,25 +4,27 @@ import { resolveTerminalRestoreOptions } from "./terminal-restore-options"; describe("terminal restore options", () => { it("omits restore options for daemons without terminal restore modes", () => { - expect( + expect(() => resolveTerminalRestoreOptions({ + scrollbackLines: 10000, supportsTerminalRestoreModes: false, canClaimSize: true, size: { rows: 24, cols: 80 }, }), - ).toBeUndefined(); + ).toThrow("Update the host to view terminals."); }); - it("requests visible snapshot restore with bounded scrollback for capable daemons", () => { + it("requests visible snapshot restore with configured scrollback for capable daemons", () => { expect( resolveTerminalRestoreOptions({ + scrollbackLines: 10000, supportsTerminalRestoreModes: true, canClaimSize: true, size: { rows: 24, cols: 80 }, }), ).toEqual({ mode: "visible-snapshot", - scrollbackLines: 200, + scrollbackLines: 10000, size: { rows: 24, cols: 80 }, }); }); @@ -30,26 +32,39 @@ describe("terminal restore options", () => { it("omits size until the terminal has been measured", () => { expect( resolveTerminalRestoreOptions({ + scrollbackLines: 10000, supportsTerminalRestoreModes: true, canClaimSize: true, size: null, }), ).toEqual({ mode: "visible-snapshot", - scrollbackLines: 200, + scrollbackLines: 10000, }); }); it("does not let a background attach resize the PTY", () => { expect( resolveTerminalRestoreOptions({ + scrollbackLines: 10000, supportsTerminalRestoreModes: true, canClaimSize: false, size: { rows: 24, cols: 80 }, }), ).toEqual({ mode: "visible-snapshot", - scrollbackLines: 200, + scrollbackLines: 10000, }); }); }); + +it.each([0, 5, 400, 10000])("requests exactly %s configured history rows", (scrollbackLines) => { + expect( + resolveTerminalRestoreOptions({ + supportsTerminalRestoreModes: true, + scrollbackLines, + canClaimSize: false, + size: null, + }), + ).toEqual({ mode: "visible-snapshot", scrollbackLines }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-restore-options.ts b/packages/app/src/terminal/runtime/terminal-restore-options.ts index d1e304cec7..3fcd894eac 100644 --- a/packages/app/src/terminal/runtime/terminal-restore-options.ts +++ b/packages/app/src/terminal/runtime/terminal-restore-options.ts @@ -1,23 +1,23 @@ +import { i18n } from "@/i18n/i18next"; import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages"; -export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200; - export interface ResolveTerminalRestoreOptionsInput { supportsTerminalRestoreModes: boolean; + scrollbackLines: number; canClaimSize: boolean; size: { rows: number; cols: number } | null; } export function resolveTerminalRestoreOptions( input: ResolveTerminalRestoreOptionsInput, -): SubscribeTerminalRequest["restore"] | undefined { +): NonNullable { if (!input.supportsTerminalRestoreModes) { - return undefined; + throw new Error(i18n.t("workspace.terminal.restoreRequiresUpdate")); } return { mode: "visible-snapshot", - scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES, + scrollbackLines: input.scrollbackLines, ...(input.canClaimSize && input.size ? { size: input.size } : {}), }; } diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts index c5fdbbd9fd..6232d9a8c0 100644 --- a/packages/app/src/terminal/runtime/terminal-snapshot.test.ts +++ b/packages/app/src/terminal/runtime/terminal-snapshot.test.ts @@ -2,7 +2,7 @@ import { Terminal as HeadlessTerminal } from "@xterm/headless"; import { Terminal as ClientTerminal } from "@xterm/xterm"; import { describe, expect, it } from "vitest"; -import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; +import { SerializeAddon } from "@xterm/addon-serialize"; interface SnapshotCell { char: string; @@ -122,7 +122,7 @@ function extractRow(terminal: ClientTerminal | HeadlessTerminal, row: number): S } describe("terminal-snapshot", () => { - it("replays extracted terminal state into a client xterm with matching grid, scrollback, and cursor presentation", async () => { + it("replays upstream serialized ANSI into a client xterm with matching grid, scrollback, and cursor presentation", async () => { const source = new HeadlessTerminal({ rows: 4, cols: 12, @@ -138,6 +138,8 @@ describe("terminal-snapshot", () => { await writeToTerminal(source, "\u001b[2 q"); await writeToTerminal(source, "\u001b[?25l"); + const serializer = new SerializeAddon(); + source.loadAddon(serializer); const snapshot = extractState(source); const client = new ClientTerminal({ @@ -147,7 +149,7 @@ describe("terminal-snapshot", () => { scrollback: 100, }); - await writeToTerminal(client, renderTerminalSnapshotToAnsi(snapshot)); + await writeToTerminal(client, serializer.serialize() + "\x1b[2 q\x1b[?25l"); expect(extractState(client)).toEqual(snapshot); }); diff --git a/packages/app/src/terminal/runtime/terminal-snapshot.ts b/packages/app/src/terminal/runtime/terminal-snapshot.ts deleted file mode 100644 index 4b7a8e0b9b..0000000000 --- a/packages/app/src/terminal/runtime/terminal-snapshot.ts +++ /dev/null @@ -1 +0,0 @@ -export { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; 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 388484d79d..ecd49c6fd5 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 { resolveTerminalRestoreOptions } from "./terminal-restore-options"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, it } from "vitest"; @@ -7,34 +8,16 @@ import { type TerminalStreamControllerStatus, } from "./terminal-stream-controller"; -interface TerminalSnapshot { - rows: number; - cols: number; - grid: Array>; - scrollback: Array>; - cursor: { row: number; col: number }; -} - -function terminalCellText(cell: { char: string }): string { - return cell.char; -} - -function terminalRowText(row: Array<{ char: string }>): string { - return row.map(terminalCellText).join(""); -} - -function terminalSnapshotText(state: TerminalSnapshot): string { - return state.grid.map(terminalRowText).join("\n"); -} - function terminalOutput(text: string): Uint8Array { return new TextEncoder().encode(text); } -type TerminalStreamEvent = - | { terminalId: string; type: "output"; data: Uint8Array } - | { terminalId: string; type: "snapshot"; state: TerminalSnapshot } - | { terminalId: string; type: "restore"; data: Uint8Array }; +type StreamEvent = import("@getpaseo/client").TerminalStreamEvent; +type TerminalStreamEvent = StreamEvent extends infer Event + ? Event extends StreamEvent + ? Omit + : never + : never; class FakeTerminalStreamClient implements TerminalStreamControllerClient { private readonly updates = new Set<(message: SessionOutboundMessage) => void>(); @@ -115,32 +98,23 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { function createHarness(input?: { client?: FakeTerminalStreamClient }) { const client = input?.client ?? new FakeTerminalStreamClient(); const outputs: Array<{ terminalId: string; data: Uint8Array }> = []; - const restores: Array<{ terminalId: string; data: Uint8Array }> = []; - const snapshots: Array<{ terminalId: string; text: string }> = []; + const replays: import("@getpaseo/client").TerminalReplayEvent[] = []; const statuses: TerminalStreamControllerStatus[] = []; const exits: string[] = []; const controller = new TerminalStreamController({ client, + onReplay: ({ replay }) => replays.push(replay), onExit: (terminalId) => exits.push(terminalId), getPreferredSize: () => ({ rows: 24, cols: 80 }), onOutput: (output) => { outputs.push(output); }, - onRestore: (restore) => { - restores.push(restore); - }, - onSnapshot: ({ terminalId, state }) => { - snapshots.push({ - terminalId, - text: terminalSnapshotText(state), - }); - }, onStatusChange: (status) => { statuses.push(status); }, }); - return { client, controller, outputs, restores, snapshots, statuses, exits }; + return { client, controller, outputs, replays, statuses, exits }; } async function flushAsyncWork(): Promise { @@ -149,24 +123,20 @@ async function flushAsyncWork(): Promise { } describe("terminal-stream-controller", () => { - it("subscribes, resizes, and forwards snapshot/output events", async () => { + it("subscribes, resizes, and forwards replay/output events", async () => { const harness = createHarness(); harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null }); harness.controller.setTerminal({ terminalId: "term-1" }); await flushAsyncWork(); - harness.client.emit({ - terminalId: "term-1", - type: "snapshot", - state: { - rows: 1, - cols: 5, - grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], - scrollback: [], - cursor: { row: 0, col: 5 }, - }, - }); + const replay: import("@getpaseo/client").TerminalReplayEvent[] = [ + { type: "begin", rows: 24, cols: 80, retainedHistoryLines: 400 }, + { type: "data", data: terminalOutput("hello") }, + { type: "end" }, + ]; + for (const event of replay) + harness.client.emit({ terminalId: "term-1", type: "replay", replay: event }); const outputData = terminalOutput(" world"); harness.client.emit({ terminalId: "term-1", @@ -178,7 +148,7 @@ describe("terminal-stream-controller", () => { expect(harness.client.resizeCalls).toEqual([ { terminalId: "term-1", rows: 24, cols: 80, intent: "claim" }, ]); - expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]); + expect(harness.replays).toEqual(replay); expect(harness.outputs[0]?.data).toBe(outputData); expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]); expect(harness.statuses.at(-1)).toEqual({ @@ -236,23 +206,19 @@ describe("terminal-stream-controller", () => { size: { rows: 24, cols: 80 }, }), onOutput: (output) => harness.outputs.push(output), - onRestore: (restore) => harness.restores.push(restore), - onSnapshot: ({ terminalId, state }) => { - harness.snapshots.push({ - terminalId, - text: terminalSnapshotText(state), - }); - }, + onReplay: ({ replay }) => harness.replays.push(replay), }); controller.setTerminal({ terminalId: "term-1" }); await flushAsyncWork(); const restoreData = terminalOutput("restored"); - client.emit({ - terminalId: "term-1", - type: "restore", - data: restoreData, - }); + const replay: import("@getpaseo/client").TerminalReplayEvent[] = [ + { type: "begin" }, + { type: "data", data: restoreData }, + { type: "end" }, + ]; + for (const event of replay) + client.emit({ terminalId: "term-1", type: "replay", replay: event }); controller.dispose(); expect(client.subscribeCalls).toEqual([ @@ -270,8 +236,7 @@ describe("terminal-stream-controller", () => { 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.replays).toEqual(replay); expect(harness.outputs).toEqual([]); }); @@ -319,3 +284,28 @@ it("reports observation failure without declaring the PTY exited, then allows re expect(h.statuses.at(-1)?.error).toBe(null); h.controller.dispose(); }); + +it("does not resize or attach on a JSON-era host and reports the required update", () => { + const client = new FakeTerminalStreamClient(); + const statuses: TerminalStreamControllerStatus[] = []; + const controller = new TerminalStreamController({ + client, + getPreferredSize: () => ({ rows: 24, cols: 40 }), + onOutput: () => {}, + getRestoreOptions: () => + resolveTerminalRestoreOptions({ + supportsTerminalRestoreModes: false, + scrollbackLines: 10000, + canClaimSize: true, + size: { rows: 24, cols: 40 }, + }), + onStatusChange: (status) => statuses.push(status), + }); + controller.setTerminal({ terminalId: "old-host-terminal" }); + expect(client.subscribeCalls).toEqual([]); + expect(client.resizeCalls).toEqual([]); + expect(statuses.at(-1)).toMatchObject({ + isAttaching: false, + error: "Update the host to view terminals.", + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index 90d54be4d4..b4b166cadf 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -1,5 +1,5 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages"; +import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages"; import type { TerminalOutputData } from "./terminal-emulator-runtime"; import { i18n } from "@/i18n/i18next"; @@ -23,9 +23,11 @@ export interface TerminalStreamControllerOptions { client: TerminalStreamControllerClient; 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; + onReplay?: (input: { + terminalId: string; + replay: import("@getpaseo/client").TerminalReplayEvent; + }) => void; getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined; onStatusChange?: (status: TerminalStreamControllerStatus) => void; } @@ -43,17 +45,11 @@ export class TerminalStreamController { if (this.disposed || event.terminalId !== this.terminalId) { return; } - if (event.type === "snapshot") { - this.options.onSnapshot({ terminalId: event.terminalId, state: event.state }); + if (event.type === "replay") { + this.options.onReplay?.({ terminalId: event.terminalId, replay: event.replay }); return; } - if (event.type === "restore") { - if (event.data.length > 0) { - this.options.onRestore?.({ terminalId: event.terminalId, data: event.data }); - } - return; - } - if (event.data.length > 0) { + if (event.type === "output" && event.data.length > 0) { this.options.onOutput({ terminalId: event.terminalId, data: event.data }); } }; @@ -70,10 +66,10 @@ export class TerminalStreamController { this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null }); return; } - const restore = this.options.getRestoreOptions?.(); this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null }); let subscription: ReturnType; try { + const restore = this.options.getRestoreOptions?.(); const preferredSize = restore?.size ?? this.options.getPreferredSize(); if (preferredSize) this.options.client.sendTerminalInput(nextTerminalId, { diff --git a/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts b/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts deleted file mode 100644 index 97063539d6..0000000000 --- a/packages/app/src/terminal/runtime/workspace-terminal-session.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - getWorkspaceTerminalSession, - releaseWorkspaceTerminalSession, - retainWorkspaceTerminalSession, -} from "./workspace-terminal-session"; - -describe("workspace-terminal-session", () => { - it("returns the same workspace session instance for the same scope", () => { - const first = getWorkspaceTerminalSession({ - scopeKey: "workspace-a", - }); - const second = getWorkspaceTerminalSession({ - scopeKey: "workspace-a", - }); - - expect(second).toBe(first); - }); - - it("preserves snapshots across repeated lookups", () => { - const first = getWorkspaceTerminalSession({ - scopeKey: "workspace-snapshots", - }); - first.snapshots.set({ - terminalId: "term-1", - state: { - rows: 1, - cols: 1, - grid: [[{ char: "A" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }, - }); - - const second = getWorkspaceTerminalSession({ - scopeKey: "workspace-snapshots", - }); - - expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({ - rows: 1, - cols: 1, - grid: [[{ char: "A" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }); - }); - - it("evicts workspace terminal session state when the retain count returns to zero", () => { - const scopeKey = "workspace-release"; - const first = getWorkspaceTerminalSession({ - scopeKey, - }); - first.snapshots.set({ - terminalId: "term-1", - state: { - rows: 1, - cols: 1, - grid: [[{ char: "A" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }, - }); - - retainWorkspaceTerminalSession({ scopeKey }); - releaseWorkspaceTerminalSession({ scopeKey }); - - const second = getWorkspaceTerminalSession({ - scopeKey, - }); - - expect(second).not.toBe(first); - expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull(); - }); -}); diff --git a/packages/app/src/terminal/runtime/workspace-terminal-session.ts b/packages/app/src/terminal/runtime/workspace-terminal-session.ts deleted file mode 100644 index 277f956b97..0000000000 --- a/packages/app/src/terminal/runtime/workspace-terminal-session.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { TerminalState } from "@getpaseo/protocol/messages"; - -export interface WorkspaceTerminalSnapshots { - get: (input: { terminalId: string }) => TerminalState | null; - set: (input: { terminalId: string; state: TerminalState }) => void; - clear: (input: { terminalId: string }) => void; - prune: (input: { terminalIds: string[] }) => void; -} - -export interface WorkspaceTerminalSession { - scopeKey: string; - snapshots: WorkspaceTerminalSnapshots; -} - -interface WorkspaceTerminalSessionRecord { - snapshotByTerminalId: Map; - session: WorkspaceTerminalSession; -} - -const sessionsByScopeKey = new Map(); -const refCountByScopeKey = new Map(); - -function createSnapshots(input: { - snapshotByTerminalId: Map; -}): WorkspaceTerminalSnapshots { - return { - get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null, - set: ({ terminalId, state }) => { - input.snapshotByTerminalId.set(terminalId, state); - }, - clear: ({ terminalId }) => { - input.snapshotByTerminalId.delete(terminalId); - }, - prune: ({ terminalIds }) => { - const terminalIdSet = new Set(terminalIds); - for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) { - if (!terminalIdSet.has(terminalId)) { - input.snapshotByTerminalId.delete(terminalId); - } - } - }, - }; -} - -export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession { - const existing = sessionsByScopeKey.get(input.scopeKey); - if (existing) { - return existing.session; - } - - const snapshotByTerminalId = new Map(); - const session: WorkspaceTerminalSession = { - scopeKey: input.scopeKey, - snapshots: createSnapshots({ - snapshotByTerminalId, - }), - }; - - sessionsByScopeKey.set(input.scopeKey, { - snapshotByTerminalId, - session, - }); - return session; -} - -export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void { - const current = refCountByScopeKey.get(input.scopeKey) ?? 0; - refCountByScopeKey.set(input.scopeKey, current + 1); -} - -export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void { - const current = refCountByScopeKey.get(input.scopeKey) ?? 0; - if (current > 1) { - refCountByScopeKey.set(input.scopeKey, current - 1); - return; - } - refCountByScopeKey.delete(input.scopeKey); - sessionsByScopeKey.delete(input.scopeKey); -} diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index 47bd36596a..859ae8e6b7 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -57,7 +57,6 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { fetchPage: async () => ({ hasNewer: false, endCursor: null }), fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), reportError: () => undefined, - schedule: () => () => undefined, }, }); } @@ -371,6 +370,22 @@ describe("viewed timeline persistence", () => { ]); }); + it("preparing an already painted cache again keeps history out of the live head", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { readTimeline: async () => cachedTimeline(), commitTimeline: () => undefined }, + prepareAgent: async () => undefined, + }); + await replica.prepare(AGENT_ID); + const painted = useSessionStore.getState().sessions[SERVER_ID]!.agentStreamTail.get(AGENT_ID); + expect(painted).toEqual(cachedTimeline().items); + await replica.prepare(AGENT_ID); + const session = useSessionStore.getState().sessions[SERVER_ID]!; + expect(session.agentStreamHead.get(AGENT_ID) ?? []).toEqual([]); + expect(session.agentStreamTail.get(AGENT_ID)).toBe(painted); + }); + it("reconciles a live head that overlaps the cached canonical timeline", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; @@ -601,7 +616,6 @@ describe("create handoff lifetime", () => { }, fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), reportError: () => undefined, - schedule: () => () => undefined, }, }); diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 9e57142846..0e401ad7a0 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -19,6 +19,66 @@ import { type TimelineCursor, } from "./session-stream-reducers"; +it("keeps streamed reasoning together while the initial history is loading", () => { + let head: StreamItem[] = []; + const chunks = ["Checking a long oper", "ation before", " the re", "sult arrives."]; + for (const [index, text] of chunks.entries()) { + const result = processAgentStreamEvent({ + event: { + type: "timeline", + provider: "codex", + turnId: "turn-18", + item: { type: "reasoning", text }, + }, + seq: index + 1, + epoch: "epoch-1", + currentTail: [], + currentHead: head, + currentCursor: undefined, + timestamp: new Date(1000 + index), + hasAuthoritativeBaseline: false, + }); + head = result.head; + expect(head).toHaveLength(1); + expect(head[0]).toMatchObject({ + kind: "thought", + text: chunks.slice(0, index + 1).join(""), + turnId: "turn-18", + }); + } +}); + +it.each([false, true])( + "keeps adjacent reasoning from different turns separate (history loaded: %s)", + (hasAuthoritativeBaseline) => { + let head: StreamItem[] = []; + let tail: StreamItem[] = []; + for (const [index, turnId] of ["turn-1", "turn-2"].entries()) { + const result = processAgentStreamEvent({ + event: { + type: "timeline", + provider: "codex", + turnId, + item: { type: "reasoning", text: turnId }, + }, + seq: index + 1, + epoch: "epoch-1", + currentTail: tail, + currentHead: head, + currentCursor: undefined, + timestamp: new Date(1000), + hasAuthoritativeBaseline, + }); + head = result.head; + tail = result.tail; + } + expect([...tail, ...head]).toMatchObject([ + { kind: "thought", text: "turn-1", turnId: "turn-1" }, + { kind: "thought", text: "turn-2", turnId: "turn-2" }, + ]); + }, +); + // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 34a461d893..a46ec8188f 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1615,19 +1615,17 @@ export function processAgentStreamEvent( unmatchedUserMessageInsert: "head", }); } else { - const overlay = applyStreamEvent({ - tail: currentHead, - head: [], - event, - timestamp, + // Before history arrives, the overlay is one ordered stream. Reducing it + // directly keeps consecutive reasoning chunks in the same row. + const overlay = reduceStreamUpdate(currentHead, event, timestamp, { source: "live", timelineCursor, }); streamResult = { tail: currentTail, - head: [...overlay.tail, ...overlay.head], + head: overlay, changedTail: false, - changedHead: overlay.changedTail || overlay.changedHead, + changedHead: overlay !== currentHead, }; } } else { diff --git a/packages/app/src/timeline/timeline-replacement.test.ts b/packages/app/src/timeline/timeline-replacement.test.ts index f457ea7daa..cea2dc909d 100644 --- a/packages/app/src/timeline/timeline-replacement.test.ts +++ b/packages/app/src/timeline/timeline-replacement.test.ts @@ -15,7 +15,7 @@ describe("requestTimelineReplacement", () => { await expect(requestTimelineReplacement(client, "agent-1")).rejects.toThrow("fetch complete"); expect(requests).toEqual([ - ["agent-1", { direction: "tail", projection: "projected", limit: 40 }], + ["agent-1", { direction: "tail", projection: "projected", wholeTurns: true, limit: 40 }], ]); }); }); diff --git a/packages/app/src/timeline/timeline-sync-plan.test.ts b/packages/app/src/timeline/timeline-sync-plan.test.ts index 9135b5fb85..093941fb77 100644 --- a/packages/app/src/timeline/timeline-sync-plan.test.ts +++ b/packages/app/src/timeline/timeline-sync-plan.test.ts @@ -17,6 +17,7 @@ describe("timeline sync planning", () => { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -26,6 +27,7 @@ describe("timeline sync planning", () => { cursor: { epoch: "epoch-1", seq: 49 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -37,6 +39,7 @@ describe("timeline sync planning", () => { cursor: { epoch: "epoch-1", seq: 25 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, }); }); @@ -49,6 +52,7 @@ describe("timeline sync planning", () => { }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, mergeWindow: true, }); }); diff --git a/packages/app/src/timeline/timeline-sync-plan.ts b/packages/app/src/timeline/timeline-sync-plan.ts index e5e8b2fb95..b66de16941 100644 --- a/packages/app/src/timeline/timeline-sync-plan.ts +++ b/packages/app/src/timeline/timeline-sync-plan.ts @@ -9,6 +9,7 @@ export interface ProjectedTimelineTailFetchPlan { direction: "tail"; limit: number; projection: "projected"; + wholeTurns: true; } export interface ProjectedTimelineAfterFetchPlan { @@ -16,6 +17,7 @@ export interface ProjectedTimelineAfterFetchPlan { cursor: TimelineSyncCursor; limit: number; projection: "projected"; + wholeTurns: true; } export interface ProjectedTimelineBeforeFetchPlan { @@ -23,6 +25,7 @@ export interface ProjectedTimelineBeforeFetchPlan { cursor: TimelineSyncCursor; limit: number; projection: "projected"; + wholeTurns: true; } export type ProjectedTimelineFetchPlan = @@ -40,6 +43,7 @@ export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) { cursor, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -48,6 +52,7 @@ export function planTimelineTailFetch() { direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -65,6 +70,7 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) { cursor, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, } as const; } @@ -75,6 +81,7 @@ export function planTimelinePromptJump(target: TimelineSyncCursor) { cursor: { epoch: target.epoch, seq: target.seq + newerRows + 1 }, limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", + wholeTurns: true, mergeWindow: true, } as const; } diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index 3ce6942301..f3e97b2e49 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -51,7 +51,7 @@ describe("deriveStreamTurnTiming", () => { }); assert.equal(timing.runningStartedAt, secondUserAt); - assert.equal(timing.byAssistantId.has("a2"), false); + assert.equal(timing.byItemId.has("a2"), false); }); it("derives completed turn timing from user and assistant item timestamps", () => { @@ -69,7 +69,7 @@ describe("deriveStreamTurnTiming", () => { head: [], }); - assert.deepEqual(timing.byAssistantId.get("a1"), { + assert.deepEqual(timing.byItemId.get("a1"), { completedAt: assistantAt, durationMs: 7000, }); @@ -95,8 +95,8 @@ describe("deriveStreamTurnTiming", () => { completedAt: lastAssistantAt, durationMs: 7000, }; - assert.deepEqual(timing.byAssistantId.get("a1"), expected); - assert.deepEqual(timing.byAssistantId.get("a2"), expected); + assert.deepEqual(timing.byItemId.get("a1"), expected); + assert.deepEqual(timing.byItemId.get("a2"), expected); }); it("preserves the completion timestamp when a canonical turn has no visible prompt", () => { @@ -120,7 +120,7 @@ describe("deriveStreamTurnTiming", () => { head: [], }); - assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), { + assert.deepEqual(timing.byItemId.get("hidden-prompt-a2"), { completedAt: hiddenPromptTurnAt, durationMs: null, }); @@ -143,12 +143,12 @@ describe("deriveStreamTurnTiming", () => { const first = derive("one", "2026-05-15T00:01:04.000Z"); const second = derive("one two", "2026-05-15T00:01:05.000Z"); - assert.equal(second.byAssistantId, first.byAssistantId); - assert.deepEqual(first.byAssistantId.get("a1"), { + assert.equal(second.byItemId, first.byItemId); + assert.deepEqual(first.byItemId.get("a1"), { completedAt: tail[1]?.timestamp, durationMs: 7000, }); - assert.equal(first.byAssistantId.has("a2"), false); + assert.equal(first.byItemId.has("a2"), false); }); it("keeps the timing map identity when the head closed the tail's open turn", () => { @@ -168,8 +168,8 @@ describe("deriveStreamTurnTiming", () => { const first = derive("one", "2026-05-15T00:01:04.000Z"); const second = derive("one two", "2026-05-15T00:01:05.000Z"); - assert.equal(second.byAssistantId, first.byAssistantId); - assert.deepEqual(first.byAssistantId.get("a1"), { + assert.equal(second.byItemId, first.byItemId); + assert.deepEqual(first.byItemId.get("a1"), { completedAt: lastTailAt, durationMs: 7000, }); @@ -193,9 +193,9 @@ describe("deriveStreamTurnTiming", () => { head, }); - assert.notEqual(completed.byAssistantId, active.byAssistantId); - assert.equal(active.byAssistantId.has("a1"), false); - assert.deepEqual(completed.byAssistantId.get("a1"), { completedAt, durationMs: 9000 }); + assert.notEqual(completed.byItemId, active.byItemId); + assert.equal(active.byItemId.has("a1"), false); + assert.deepEqual(completed.byItemId.get("a1"), { completedAt, durationMs: 9000 }); }); it("derives fresh timing when the tail itself changes", () => { @@ -211,11 +211,39 @@ describe("deriveStreamTurnTiming", () => { const first = derive(firstTail); const second = derive(secondTail); - assert.notEqual(second.byAssistantId, first.byAssistantId); - assert.equal(first.byAssistantId.has("a2"), false); - assert.deepEqual(second.byAssistantId.get("a2"), { + assert.notEqual(second.byItemId, first.byItemId); + assert.equal(first.byItemId.has("a2"), false); + assert.deepEqual(second.byItemId.get("a2"), { completedAt: secondTail[3]?.timestamp, durationMs: 3000, }); }); }); + +it("times a heartbeat that ends with tools and no assistant text", () => { + const trigger: StreamItem = { + kind: "tool_call", + id: "trigger", + timestamp: new Date("2026-05-15T00:00:00Z"), + turnId: "heartbeat", + payload: { + source: "agent", + data: { + provider: "claude", + callId: "trigger", + name: "heartbeat", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Check progress" }, + }, + }, + }; + const tool: StreamItem = { ...trigger, id: "last", timestamp: new Date("2026-05-15T00:00:05Z") }; + const timing = deriveStreamTurnTiming({ + tail: [trigger, tool], + head: [], + isTurnActive: false, + activeTurnStartedAt: null, + }); + assert.equal(timing.byItemId.get("last")?.durationMs, 5000); +}); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index c47a6983cd..8d1aa00648 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -1,5 +1,5 @@ import type { StreamItem } from "@/types/stream"; -import { startsNewTurn } from "@/agent-stream/turn-membership"; +import { startsNewTurn, isTurnTrigger } from "@/agent-stream/turn-membership"; export interface TurnTiming { completedAt: Date; @@ -7,7 +7,7 @@ export interface TurnTiming { } export interface StreamTurnTiming { - byAssistantId: Map; + byItemId: Map; runningStartedAt: Date | null; } @@ -20,7 +20,7 @@ interface OpenTurn { interface HeadTurnTiming { additions: Map; - byAssistantId: Map; + byItemId: Map; } interface TailTurnTiming { @@ -37,14 +37,18 @@ const EMPTY_OPEN_TURN: OpenTurn = { }; function closeTurn(turn: OpenTurn, into: Map): void { - if (!turn.lastItemAt || turn.assistantIds.length === 0) { + if (!turn.lastItemAt) { return; } const timing: TurnTiming = { completedAt: turn.lastItemAt, durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null, }; - for (const id of turn.assistantIds) { + let footerIds = turn.assistantIds; + if (footerIds.length === 0 && turn.previousItem?.kind === "tool_call") { + footerIds = [turn.previousItem.id]; + } + for (const id of footerIds) { into.set(id, timing); } } @@ -61,7 +65,7 @@ function walkTurns( for (const item of items) { if (startsNewTurn(item, previousItem)) { closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into); - userAt = item.kind === "user_message" ? item.timestamp : null; + userAt = isTurnTrigger(item) ? item.timestamp : null; lastItemAt = null; assistantIds = []; } @@ -108,7 +112,7 @@ function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming { return timing; } -function resolveByAssistantId( +function resolveByItemId( tail: TailTurnTiming, additions: Map, ): Map { @@ -116,14 +120,14 @@ function resolveByAssistantId( return tail.completed; } if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) { - return tail.lastHead.byAssistantId; + return tail.lastHead.byItemId; } - const byAssistantId = new Map(tail.completed); + const byItemId = new Map(tail.completed); for (const [id, timing] of additions) { - byAssistantId.set(id, timing); + byItemId.set(id, timing); } - tail.lastHead = { additions, byAssistantId }; - return byAssistantId; + tail.lastHead = { additions, byItemId }; + return byItemId; } export function deriveStreamTurnTiming(params: { @@ -139,7 +143,7 @@ export function deriveStreamTurnTiming(params: { closeTurn(openTurn, additions); } return { - byAssistantId: resolveByAssistantId(tail, additions), + byItemId: resolveByItemId(tail, additions), runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null, }; } diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 9bf6897bd9..e556da612f 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -90,7 +90,7 @@ test("visible catch-up settles before hidden open chats start fetching", async ( world.sync.dispose(); }); -test("a failed visible catch-up releases hidden chats without bypassing its retry delay", async () => { +test("a failed visible catch-up releases hidden chats without bypassing its error", async () => { const world = new TimelineWorld(); world.openedEarlier(["a-hidden"]); world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); @@ -103,7 +103,7 @@ test("a failed visible catch-up releases hidden chats without bypassing its retr hidden.respond({ hasNewer: false }); await world.nextError(); world.expectNoPendingFetch(); - (await world.nextRetry())(); + world.sync.retryVisibleAgentTimeline("z-visible"); (await world.nextFetch("z-visible")).respond({ hasNewer: false }); world.sync.dispose(); }); @@ -189,6 +189,7 @@ class TimelineWorld { direction: "tail", limit: 40, projection: "projected", + wholeTurns: true, }); } finally { this.forcedTimelineTailReplacements.delete(agentId); @@ -199,20 +200,6 @@ class TimelineWorld { const waiter = this.errorWaiters.shift(); if (waiter) waiter(this.errors.at(-1) ?? ""); }, - schedule: (task, delayMs) => { - const scheduled = { task, delayMs }; - this.scheduled.push(scheduled); - const waiterIndex = this.retryWaiters.findIndex((waiter) => waiter.delayMs === delayMs); - if (waiterIndex >= 0) { - const [waiter] = this.retryWaiters.splice(waiterIndex, 1); - this.scheduled.splice(this.scheduled.indexOf(scheduled), 1); - waiter.resolve(task); - } - return () => { - const index = this.scheduled.indexOf(scheduled); - if (index >= 0) this.scheduled.splice(index, 1); - }; - }, }); show(sourceId: string, agentIds: string[]): void { @@ -234,12 +221,6 @@ class TimelineWorld { }> = []; private readonly errorWaiters: Array<(message: string) => void> = []; private readonly cacheRequestWaiters: Array<(agentId: string) => void> = []; - private readonly scheduled: Array<{ task: () => void; delayMs: number }> = []; - private readonly retryWaiters: Array<{ - delayMs: number; - resolve(retry: () => void): void; - }> = []; - get pendingFetchCount(): number { return this.fetches.length; } @@ -299,22 +280,6 @@ class TimelineWorld { return new Promise((resolve) => this.errorWaiters.push(resolve)); } - nextRetry(delayMs = 1_000): Promise<() => void> { - const index = this.scheduled.findIndex((entry) => entry.delayMs === delayMs); - if (index >= 0) return Promise.resolve(this.scheduled.splice(index, 1)[0].task); - return new Promise((resolve) => this.retryWaiters.push({ delayMs, resolve })); - } - - elapse(elapsedMs: number): void { - const due = this.scheduled.filter((entry) => entry.delayMs <= elapsedMs); - this.scheduled.splice( - 0, - this.scheduled.length, - ...this.scheduled.filter((entry) => entry.delayMs > elapsedMs), - ); - for (const entry of due) entry.task(); - } - private releaseMembershipWaiter(): void { const waiter = this.membershipWaiters.shift(); if (!waiter) return; @@ -341,7 +306,12 @@ test("uses a tail fetch when an agent becomes visible", async () => { membership.succeed(); const fetch = await world.nextFetch("agent-a"); - expect(fetch.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); + expect(fetch.request).toEqual({ + direction: "tail", + limit: 40, + projection: "projected", + wholeTurns: true, + }); fetch.respond({ hasNewer: false }); }); @@ -366,6 +336,7 @@ test("loads the cache before choosing the authoritative network request", async cursor: { epoch: "cached-epoch", seq: 17 }, limit: 40, projection: "projected", + wholeTurns: true, }); fetch.respond({ hasNewer: false }); }); @@ -384,6 +355,7 @@ test("catches up after the restored cursor when an agent becomes visible", async cursor: { epoch: "epoch-agent-a", seq: 42 }, limit: 40, projection: "projected", + wholeTurns: true, }); fetch.respond({ hasNewer: false }); }); @@ -401,7 +373,12 @@ test("falls back to the latest tail when a restored cursor has more than one cat probe.respond({ hasNewer: true, seq: 82 }); const fallback = await world.nextFetch("agent-a"); - expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" }); + expect(fallback.request).toEqual({ + direction: "tail", + limit: 40, + projection: "projected", + wholeTurns: true, + }); expect( world.applyTimelineResponse({ requestId: "fallback-tail", @@ -444,6 +421,7 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a cursor: { epoch: "epoch-agent-a", seq: 9 }, limit: 40, projection: "projected", + wholeTurns: true, }); recovery.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); @@ -469,6 +447,28 @@ test("reports a chat current only once its parked follow-up page completes", asy world.sync.dispose(); }); +test("a gap during manual retry is recovered before reporting ready", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + (await world.nextFetch("agent-a")).fail("unavailable"); + await world.nextError(); + world.sync.retryVisibleAgentTimeline("agent-a"); + const retry = await world.nextFetch("agent-a"); + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }); + retry.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.pendingFetchCount).toBe(1)); + const recovery = await world.nextFetch("agent-a"); + expect(recovery.request).toMatchObject({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 9 }, + }); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying"); + recovery.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); +}); + test("reports a chat current once the latest-tail fallback replaces an overflowing catch-up", async () => { const world = new TimelineWorld(); world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); @@ -527,6 +527,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async ( cursor: { epoch: "epoch-agent-a", seq: 5 }, limit: 40, projection: "projected", + wholeTurns: true, }); world.expectNoPendingMembership(); }); @@ -765,6 +766,21 @@ test("overlapping sources deduplicate membership and retain open hidden chats", world.expectNoPendingFetch(); }); +test("failed timeline fetch does not schedule a retry", async () => { + vi.useFakeTimers(); + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + (await world.nextFetch("agent-a")).fail("unavailable"); + await world.nextError(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(120_000); + world.expectNoPendingFetch(); + world.expectNoPendingMembership(); + vi.useRealTimers(); +}); + test("a failed catch-up reports once and retries through the explicit retry policy", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); @@ -773,12 +789,12 @@ test("a failed catch-up reports once and retries through the explicit retry poli membership.succeed(); const failed = await world.nextFetch("agent-a"); failed.fail("timeline unavailable"); - const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]); + const error = await world.nextError(); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); - retryCatchUp(); + world.sync.retryVisibleAgentTimeline("agent-a"); const retry = await world.nextFetch("agent-a"); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying"); retry.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); @@ -789,35 +805,6 @@ test("a failed catch-up reports once and retries through the explicit retry poli world.expectNoPendingMembership(); }); -test("a failed catch-up retries with exponential backoff", async () => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - const membership = await world.nextMembership(); - membership.succeed(); - - const first = await world.nextFetch("agent-a"); - first.fail("timeline unavailable"); - const [firstError, retryAfterFirstFailure] = await Promise.all([ - world.nextError(), - world.nextRetry(), - ]); - expect(firstError).toBe("timeline unavailable"); - retryAfterFirstFailure(); - - const second = await world.nextFetch("agent-a"); - second.fail("timeline unavailable"); - await world.nextError(); - - const retryAfterSecondFailure = await world.nextRetry(2_000); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); - - retryAfterSecondFailure(); - const third = await world.nextFetch("agent-a"); - third.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); -}); - test("manual retries can immediately re-attempt a failed catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); @@ -827,7 +814,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () => const failed = await world.nextFetch("agent-a"); failed.fail("timeline unavailable"); - await world.nextRetry(); + await world.nextError(); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); world.sync.retryVisibleAgentTimeline("agent-a"); @@ -838,7 +825,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () => await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); }); -test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => { +test("redeclaring unchanged visibility does not bypass catch-up error", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); @@ -847,9 +834,11 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async const failed = await world.nextFetch("agent-a"); failed.fail("timeline unavailable"); - await world.nextRetry(); + await world.nextError(); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.setActive(false); + world.sync.setActive(true); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -865,7 +854,7 @@ test("a manual retry that fails returns to the error state", async () => { const failed = await world.nextFetch("agent-a"); failed.fail("timeline unavailable"); - await world.nextRetry(); + await world.nextError(); world.sync.retryVisibleAgentTimeline("agent-a"); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying"); @@ -897,12 +886,14 @@ test("gap recovery supersedes completed catch-up and pages through the current t cursor: { epoch: "epoch-agent-a", seq: 10 }, limit: 40, projection: "projected", + wholeTurns: true, }, { direction: "after", cursor: { epoch: "epoch-agent-a", seq: 15 }, limit: 40, projection: "projected", + wholeTurns: true, }, ]); }); @@ -926,20 +917,34 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as gapPage.respond({ hasNewer: false }); }); -test("membership failure autonomously retries without another visibility declaration", async () => { +test("failed membership does not schedule a retry", async () => { + vi.useFakeTimers(); + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).fail("unavailable"); + await world.nextError(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(120_000); + world.expectNoPendingFetch(); + world.expectNoPendingMembership(); + vi.useRealTimers(); +}); + +test("membership failure waits for manual retry", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); - const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]); + const error = await world.nextError(); expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); - retryMembership(); + world.sync.retryVisibleAgentTimeline("agent-a"); const retry = await world.nextMembership(); retry.succeed(); const catchUp = await world.nextFetch("agent-a"); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying"); catchUp.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); @@ -950,44 +955,68 @@ test("membership failure autonomously retries without another visibility declara }); }); -test("membership failures retry with exponential backoff", async () => { +test("retrying membership restores a previously loaded chat", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - - const first = await world.nextMembership(); - first.fail("subscription unavailable"); - const [firstError, retryAfterFirstFailure] = await Promise.all([ - world.nextError(), - world.nextRetry(), - ]); - expect(firstError).toBe("subscription unavailable"); - - retryAfterFirstFailure(); - const second = await world.nextMembership(); - second.fail("subscription unavailable again"); - - const retryAfterSecondFailure = await world.nextRetry(2_000); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); - - retryAfterSecondFailure(); - const retry = await world.nextMembership(); - retry.succeed(); - const catchUp = await world.nextFetch("agent-a"); - catchUp.respond({ hasNewer: false }); + 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: 10 }); + world.show("workspace", ["agent-b"]); + (await world.nextMembership()).fail("subscription unavailable"); + await world.nextError(); + world.sync.retryVisibleAgentTimeline("agent-a"); + (await world.nextMembership()).succeed(); + (await world.nextFetch("agent-b")).respond({ hasNewer: false }); + const resumed = await world.nextFetch("agent-a"); + expect(resumed.request).toMatchObject({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 10 }, + }); + resumed.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); }); -test("redeclaring unchanged visibility does not bypass membership backoff", async () => { +test("a fetch that finishes after membership failure cannot clear the error", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + const inFlight = await world.nextFetch("agent-a"); + world.show("workspace", ["agent-b"]); + (await world.nextMembership()).fail("subscription unavailable"); + await world.nextError(); + inFlight.respond({ hasNewer: false }); + await Promise.resolve(); + await Promise.resolve(); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); +}); + +test("a failed membership retry returns to error", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).fail("unavailable"); + await world.nextError(); + world.sync.retryVisibleAgentTimeline("agent-a"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying"); + (await world.nextMembership()).fail("still unavailable"); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error")); +}); + +test("redeclaring unchanged visibility does not bypass membership error", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); - await world.nextRetry(); + await world.nextError(); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.setActive(false); + world.sync.setActive(true); world.expectNoPendingMembership(); world.expectNoPendingFetch(); @@ -1033,7 +1062,6 @@ test("backgrounding preserves subscriptions and returning verifies the visible c world.sync.setActive(false); world.expectNoPendingMembership(); - world.elapse(60_000); world.sync.setActive(true); world.expectNoPendingMembership(); @@ -1050,11 +1078,10 @@ test("stale membership retry cannot overwrite a newer effective set", async () = world.show("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); - const staleRetry = await world.nextRetry(); + await world.nextError(); world.show("workspace", ["agent-b"]); const current = await world.nextMembership(); - staleRetry(); current.succeed(); const agentB = await world.nextFetch("agent-b"); agentB.respond({ hasNewer: false }); @@ -1071,10 +1098,9 @@ test("membership retry cannot run while disconnected", async () => { world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); - const disconnectedRetry = await world.nextRetry(); + await world.nextError(); world.sync.setConnected(false); - disconnectedRetry(); world.expectNoPendingMembership(); world.sync.setConnected(true); const restored = await world.nextMembership(); @@ -1101,7 +1127,6 @@ test("returning to an open hidden agent stays live after inactivity", async () = agentBCatchUp.respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); - world.elapse(60_000); world.show("workspace", ["agent-a"]); world.expectNoPendingMembership(); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 5939feabce..67defcec04 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -109,6 +109,15 @@ class TimelineReplicaOwner implements TimelineReplica { async prepare(agentId: string): Promise { const existing = this.preparations.get(agentId); if (existing) return existing; + const timeline = selectAgentTimelineState( + useSessionStore.getState().sessions[this.serverId], + agentId, + ); + // A route and the visible-chat owner can both prepare before catch-up finishes. + // Repainting that same cache would treat its history as a live head. + if (timeline.status !== "cold" && this.cachedRanges.has(agentId)) { + return; + } const preparation = this.load(agentId).finally(() => { if (this.preparations.get(agentId) === preparation) { this.preparations.delete(agentId); @@ -320,7 +329,6 @@ export interface ViewedTimelineSyncPorts { */ onCatchUpEnded(agentId: string): void; reportError(error: unknown): void; - schedule(task: () => void, delayMs: number): () => void; } export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying"; @@ -414,26 +422,14 @@ export function createViewedTimelineOwner(input: { }; } -const RETRY_DELAY_MS = 1_000; -const MAX_RETRY_DELAY_MS = 30_000; - type CatchUpStatus = "running" | "complete" | "error"; interface CatchUpState { generation: number; status: CatchUpStatus; request?: ProjectedTimelineForwardFetchPlan; - cancelRetry?: () => void; - retryDelayMs?: number; } -const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => { - if (previousDelayMs == null) { - return RETRY_DELAY_MS; - } - return Math.min(previousDelayMs * 2, MAX_RETRY_DELAY_MS); -}; - function isSameCatchUpRequest( left: ProjectedTimelineForwardFetchPlan | undefined, right: ProjectedTimelineForwardFetchPlan | undefined, @@ -463,9 +459,7 @@ function decideCatchUp(input: { } return "replace"; } - return input.current.status === "running" || input.current.status === "complete" - ? "keep" - : "replace"; + return "keep"; } function normalizeAgentIds(agentIds: string[]): string[] { @@ -485,8 +479,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const pendingCatchUps = new Map(); const visibilityCatchUpPending = new Set(); const visibilityCatchUpErrors = new Map(); - // User-initiated retries only. Background retries stay silent; a retry the user asked for - // owes them a pending state until it settles. + // A user-initiated retry owes a pending state until it settles. const manualRetries = new Set(); const loadedCache = new Set(); const cacheLoads = new Map>(); @@ -502,8 +495,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed let acknowledged: string[] = []; let membershipGeneration = 0; let membershipNeedsRetry = false; - let membershipRetryDelayMs: number | undefined; - let cancelMembershipRetry: (() => void) | null = null; // Chats this session has opened. Visibility is the only way in, so an agent is never // subscribed — and never resumed on the daemon — until the user actually opens it. // Membership then survives hiding, workspace switches, backgrounding and reconnect; @@ -554,7 +545,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const cancelCatchUp = (agentId: string) => { catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1); - catchUps.get(agentId)?.cancelRetry?.(); catchUps.delete(agentId); pendingCatchUps.delete(agentId); }; @@ -598,19 +588,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed setVisibilityCatchUpReady(agentId); } catch (error) { if (catchUps.get(agentId)?.generation === generation) { - const nextRetryDelayMs = getNextRetryDelayMs(catchUps.get(agentId)?.retryDelayMs); - const cancelRetry = ports.schedule(() => { - const current = catchUps.get(agentId); - if (current?.generation !== generation || current.status !== "error") return; - startCatchUp(agentId); - }, nextRetryDelayMs); - catchUps.set(agentId, { - generation, - status: "error", - request, - cancelRetry, - retryDelayMs: nextRetryDelayMs, - }); + catchUps.set(agentId, { generation, status: "error", request }); setVisibilityCatchUpError([agentId], error); ports.reportError(error); } @@ -677,17 +655,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (decision === "keep") { return; } - current?.cancelRetry?.(); const generation = (catchUpGenerations.get(agentId) ?? 0) + 1; catchUpGenerations.set(agentId, generation); - const retryDelayMs = - supersede || current?.status !== "error" ? undefined : current.retryDelayMs; - catchUps.set(agentId, { - generation, - status: "running", - request: nextRequest, - retryDelayMs, - }); + catchUps.set(agentId, { generation, status: "running", request: nextRequest }); pendingCatchUps.delete(agentId); void fetchUntilCurrent( agentId, @@ -706,7 +676,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed 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 + // Completion of a different chat must not bypass a failed chat's error or // supersede an in-flight tail that owns its parked gap recovery. if (current && !(current.status === "complete" && pendingCatchUp)) continue; startCatchUp(agentId, { @@ -732,42 +702,31 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } catch (error) { if (disposed || !connected || generation !== membershipGeneration) return; membershipNeedsRetry = true; + acknowledged = []; + for (const agentId of requested) cancelCatchUp(agentId); setVisibilityCatchUpError(requested, error); - cancelMembershipRetry?.(); - const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs); - cancelMembershipRetry = ports.schedule(() => { - cancelMembershipRetry = null; - 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; acknowledged = requested; startAcknowledgedCatchUps(); }; const retryVisibleAgentTimeline = (agentId: string) => { - if (!isDesired(agentId) || manualRetries.has(agentId)) return; + if (!connected || !isDesired(agentId) || manualRetries.has(agentId)) return; const catchUp = catchUps.get(agentId); const membershipRetryable = membershipNeedsRetry && connected; if (catchUp?.status !== "error" && !membershipRetryable) return; manualRetries.add(agentId); notifyListeners(); - if (catchUp?.status === "error") { - catchUp.cancelRetry?.(); - startCatchUp(agentId, { request: catchUp.request, supersede: true }); + if (membershipRetryable) { + void reconcileMembership(); return; } - cancelMembershipRetry?.(); - cancelMembershipRetry = null; - membershipRetryDelayMs = undefined; - void reconcileMembership(); + if (catchUp?.status === "error") { + startCatchUp(agentId, { request: catchUp.request, supersede: true }); + } }; const commitDesiredMembership = ( @@ -808,8 +767,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed ensureCacheLoaded(agentId); } } - cancelMembershipRetry?.(); - cancelMembershipRetry = null; desired = nextDesired; ports.replaceDemandedAgentIds(desired); membershipGeneration += 1; @@ -860,6 +817,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed return; } for (const agentId of visibleAgentIds()) { + if (visibilityCatchUpErrors.has(agentId)) continue; visibilityCatchUpPending.add(agentId); visibilityCatchUpErrors.delete(agentId); startCatchUp(agentId, { supersede: true }); @@ -871,9 +829,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed connected = nextConnected; if (!connected) { commitDesiredMembership(desired, { resetCatchUpStatus: true }); - cancelMembershipRetry?.(); - cancelMembershipRetry = null; - membershipRetryDelayMs = undefined; void observation?.handle.release().catch(ports.reportError); observation = null; acknowledged = []; @@ -885,7 +840,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed void reconcileMembership(); }, recoverGap(agentId, cursor) { - if (!isDesired(agentId)) return; + if (!isDesired(agentId) || catchUps.get(agentId)?.status === "error") return; startCatchUp(agentId, { request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }), supersede: true, @@ -895,10 +850,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed disposed = true; void observation?.handle.release().catch(ports.reportError); observation = null; - cancelMembershipRetry?.(); - cancelMembershipRetry = null; membershipNeedsRetry = false; - membershipRetryDelayMs = undefined; sources.clear(); membershipGeneration += 1; for (const agentId of desired) cancelCatchUp(agentId); diff --git a/packages/app/src/tool-calls/detail-level/grouping.ts b/packages/app/src/tool-calls/detail-level/grouping.ts index 079158b2d9..aeb0d2ed32 100644 --- a/packages/app/src/tool-calls/detail-level/grouping.ts +++ b/packages/app/src/tool-calls/detail-level/grouping.ts @@ -1,5 +1,6 @@ +import { isTurnTrigger, isTurnBoundary } from "@/agent-stream/turn-membership"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; -import type { StreamItem, ToolCallItem } from "@/types/stream"; +import type { StreamItem, ToolCallItem, ThoughtItem } from "@/types/stream"; export interface ToolCallDescriptor { detail: ToolCallDetail; @@ -9,17 +10,19 @@ export interface ToolCallDescriptor { metadata?: Record; } +export type GroupableItem = ToolCallItem | ThoughtItem; + export interface ToolCallRun { id: string; - calls: readonly ToolCallItem[]; - latest: ToolCallItem; + calls: readonly GroupableItem[]; + latest: GroupableItem; isSealed: boolean; } export interface GroupedHistory { tail: StreamItem[]; groupsByHostId: Map; - pendingCalls: readonly ToolCallItem[]; + pendingCalls: readonly GroupableItem[]; } export interface GroupedToolCalls { @@ -62,15 +65,19 @@ export function describeToolCall(item: ToolCallItem): ToolCallDescriptor { }; } -export function isGroupableToolCall(item: StreamItem): item is ToolCallItem { - if (item.kind !== "tool_call") { +export function isGroupableToolCall( + item: StreamItem, + autoExpandReasoning = false, +): item is GroupableItem { + if (item.kind === "thought") return !autoExpandReasoning; + if (item.kind !== "tool_call" || isTurnTrigger(item)) { return false; } const descriptor = describeToolCall(item); return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak"; } -function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun { +function createRun(calls: readonly GroupableItem[], isSealed: boolean): ToolCallRun { const first = calls[0]; const latest = calls.at(-1); if (!first || !latest) { @@ -79,20 +86,21 @@ function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallR return { id: first.id, calls, latest, isSealed }; } -function createHost(run: ToolCallRun): ToolCallItem { +function createHost(run: ToolCallRun): GroupableItem { if (run.calls.length === 1) { return run.latest; } return { ...run.latest, id: run.id }; } -function isRunning(call: ToolCallItem): boolean { +function isRunning(call: GroupableItem): boolean { + if (call.kind === "thought") return call.status !== "ready"; const status = describeToolCall(call).status; return status === "running" || status === "executing"; } function appendRun(input: { - calls: readonly ToolCallItem[]; + calls: readonly GroupableItem[]; isSealed: boolean; output: StreamItem[]; groups: Map; @@ -104,19 +112,23 @@ function appendRun(input: { const run = createRun(input.calls, input.isSealed); const host = createHost(run); input.output.push(host); - input.groups.set(host.id, input.buildGroup(run)); + if (run.calls.length > 1) input.groups.set(host.id, input.buildGroup(run)); } export function prepareGroupedHistory(input: { tail: StreamItem[]; buildGroup: (run: ToolCallRun) => TGroup; + autoExpandReasoning?: boolean; }): GroupedHistory { const output: StreamItem[] = []; const groups = new Map(); - let pending: ToolCallItem[] = []; + let pending: GroupableItem[] = []; for (const item of input.tail) { - if (isGroupableToolCall(item)) { + if ( + isGroupableToolCall(item, input.autoExpandReasoning) && + !isTurnBoundary(pending.at(-1) ?? null, item) + ) { pending.push(item); continue; } @@ -128,7 +140,8 @@ export function prepareGroupedHistory(input: { buildGroup: input.buildGroup, }); pending = []; - output.push(item); + if (isGroupableToolCall(item, input.autoExpandReasoning)) pending.push(item); + else output.push(item); } appendRun({ @@ -151,6 +164,7 @@ export function groupLiveToolCalls(input: { head: StreamItem[]; isTurnActive: boolean; buildGroup: (run: ToolCallRun) => TGroup; + autoExpandReasoning?: boolean; }): GroupedToolCalls { const head: StreamItem[] = []; const liveGroups = new Map(); @@ -166,7 +180,8 @@ export function groupLiveToolCalls(input: { if (hostPlacement === "head") { head.push(createHost(run)); } - if (hostPlacement === "head" || pendingIncludesHead || !isSealed) { + const needsLiveGroup = hostPlacement === "head" || pendingIncludesHead || !isSealed; + if (run.calls.length > 1 && needsLiveGroup) { liveGroups.set(run.id, input.buildGroup(run)); } pending = []; @@ -175,7 +190,8 @@ export function groupLiveToolCalls(input: { }; for (const item of input.head) { - if (isGroupableToolCall(item)) { + if (isTurnBoundary(pending.at(-1) ?? null, item)) flush(true); + if (isGroupableToolCall(item, input.autoExpandReasoning)) { if (pending.length === 0) { hostPlacement = "head"; } @@ -202,19 +218,13 @@ export function groupLiveToolCalls(input: { historyGroupUpdatesByHostId: EMPTY_GROUPS, }; } - if (input.history.groupsByHostId.size === 0) { - return { - tail: input.history.tail, - head, - groupsByHostId: liveGroups, - historyGroupUpdatesByHostId: EMPTY_GROUPS, - }; - } const groupsByHostId = new Map(input.history.groupsByHostId); let historyGroupUpdatesByHostId: Map | null = null; for (const [id, group] of liveGroups) { groupsByHostId.set(id, group); - if (input.history.groupsByHostId.has(id)) { + const isHistoryHost = + input.history.groupsByHostId.has(id) || input.history.pendingCalls[0]?.id === id; + if (isHistoryHost) { historyGroupUpdatesByHostId ??= new Map(); historyGroupUpdatesByHostId.set(id, group); } diff --git a/packages/app/src/tool-calls/detail-level/overview/label.ts b/packages/app/src/tool-calls/detail-level/overview/label.ts new file mode 100644 index 0000000000..605e9ce211 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/overview/label.ts @@ -0,0 +1,52 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { OverviewSummary } from "./model"; + +function joinSummaryParts(parts: string[], conjunction: string): string { + if (parts.length === 0) { + return ""; + } + let joined = parts[0] ?? ""; + if (parts.length === 2) { + joined = `${parts[0]} ${conjunction} ${parts[1]}`; + } else if (parts.length > 2) { + joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; + } + const firstCharacter = joined[0]; + return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; +} + +/** + * One sentence of what a stretch of agent work did, such as "Edited 2 files and ran 3 commands". + * Leading parts come first, for summaries that count more than tool calls. + */ +export function useOverviewSummaryLabel( + summary: OverviewSummary, + leadingParts: readonly string[] = [], +): string { + const { t } = useTranslation(); + return useMemo(() => { + const parts = [...leadingParts]; + const entries = [ + [summary.editedFileCount, "toolCallGroup.editedFiles"], + [summary.commandCount, "toolCallGroup.commands"], + [summary.readFileCount, "toolCallGroup.readFiles"], + [summary.searchCount, "toolCallGroup.searches"], + [summary.otherToolCount, "toolCallGroup.otherTools"], + ] as const; + for (const [count, key] of entries) { + if (count > 0) { + parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); + } + } + for (const [action, count] of Object.entries(summary.paseoActions)) { + parts.push( + count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`, + ); + } + // Reasoning stays in the details; it is not an action in a work summary. + // Keep a label for a group containing only reasoning. + if (parts.length === 0 && summary.thoughtCount > 0) return t("toolCallGroup.thought"); + return joinSummaryParts(parts, t("toolCallGroup.and")); + }, [leadingParts, summary, t]); +} diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts index f41790461c..e4717c43bf 100644 --- a/packages/app/src/tool-calls/detail-level/overview/model.ts +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -1,16 +1,18 @@ -import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; -import { describeToolCall, type ToolCallRun } from "../grouping"; +import { getPaseoToolActionLabel } from "@getpaseo/protocol/paseo-tool-presentation/index"; +import { isTurnTrigger } from "@/agent-stream/turn-membership"; + +import { describeToolCall, type GroupableItem, type ToolCallRun } from "../grouping"; -const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/; export interface OverviewSummary { + thoughtCount: number; editedFileCount: number; commandCount: number; readFileCount: number; searchCount: number; otherToolCount: number; - paseoCallCount: number; + paseoActions: Record; } export interface OverviewToolCallGroup { @@ -20,29 +22,31 @@ export interface OverviewToolCallGroup { isLoading: boolean; } -function isPaseoCall(name: string, normalizedName: string): boolean { - return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX); -} - function isSearchCall(name: string): boolean { return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); } -export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { +/** Counts tool calls by what they did, for any summary of a stretch of agent work. */ +export function summarizeToolCalls(calls: readonly GroupableItem[]): OverviewSummary { const editedFiles = new Set(); const readFiles = new Set(); - let isLoading = false; + let thoughtCount = 0; let commandCount = 0; let searchCount = 0; let otherToolCount = 0; - let paseoCallCount = 0; + const paseoActions: Record = {}; - for (const call of run.calls) { + for (const call of calls) { + if (call.kind === "thought") { + thoughtCount += 1; + continue; + } + if (isTurnTrigger(call)) continue; const descriptor = describeToolCall(call); const normalizedName = descriptor.name.trim().toLowerCase(); - isLoading ||= descriptor.status === "running" || descriptor.status === "executing"; - if (isPaseoCall(descriptor.name, normalizedName)) { - paseoCallCount += 1; + const action = getPaseoToolActionLabel(descriptor.name); + if (action) { + paseoActions[action] = (paseoActions[action] ?? 0) + 1; } else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { editedFiles.add(descriptor.detail.filePath); } else if (descriptor.detail.type === "shell") { @@ -56,18 +60,27 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { } } - const summary = { + return { + thoughtCount, editedFileCount: editedFiles.size, commandCount, readFileCount: readFiles.size, searchCount, otherToolCount, - paseoCallCount, + paseoActions, }; +} + +export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { + const isLoading = run.calls.some((call) => { + if (call.kind === "thought") return call.status !== "ready"; + const status = describeToolCall(call).status; + return status === "running" || status === "executing"; + }); return { mode: "overview", run, isLoading, - summary, + summary: summarizeToolCalls(run.calls), }; } diff --git a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx index b3b5bf39eb..2888f1f424 100644 --- a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx @@ -4,8 +4,12 @@ import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorho import { Wrench, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { useIsolatedBottomSheetVisibility } from "@/components/ui/isolated-bottom-sheet-modal"; +import { + type ContextBridge, + useIsolatedBottomSheetVisibility, +} from "@/components/ui/isolated-bottom-sheet-modal"; import { ToolCallSheetModal, useToolCallSheetContextBridge } from "@/components/tool-call-sheet"; +import { useToolCallExpansionContextBridge } from "@/tool-calls/expansion-context"; import type { Theme } from "@/styles/theme"; interface OverviewToolCallGroupSheetProps { @@ -29,7 +33,12 @@ export function OverviewToolCallGroupSheet({ }: OverviewToolCallGroupSheetProps) { const { t } = useTranslation(); const scrollRef = useRef(null); - const contextBridge = useToolCallSheetContextBridge(); + const sheetContextBridge = useToolCallSheetContextBridge(); + const expansionContextBridge = useToolCallExpansionContextBridge(); + const contextBridge = useCallback( + (content) => sheetContextBridge(expansionContextBridge(content)), + [expansionContextBridge, sheetContextBridge], + ); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible, onClose, diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx index 326cc172b2..b11e82e4f7 100644 --- a/packages/app/src/tool-calls/detail-level/overview/view.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -1,11 +1,12 @@ -import React, { memo, useCallback, useMemo, useRef, type ReactNode } from "react"; +import React, { memo, useCallback, useRef, type ReactNode } from "react"; import { ScrollView } from "react-native"; -import { useTranslation } from "react-i18next"; import { Wrench } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; +import { IndentRail } from "@/components/indent-rail"; import { ExpandableBadge } from "@/components/message"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { type OverviewSummary, type OverviewToolCallGroup } from "./model"; +import { useOverviewSummaryLabel } from "./label"; +import type { OverviewToolCallGroup } from "./model"; import { OverviewToolCallGroupSheet } from "./sheet"; interface OverviewGroupProps { @@ -18,41 +19,6 @@ interface OverviewGroupProps { const TOOL_CALL_GROUP_MAX_HEIGHT = 400; -function joinSummaryParts(parts: string[], conjunction: string): string { - if (parts.length === 0) { - return ""; - } - let joined = parts[0] ?? ""; - if (parts.length === 2) { - joined = `${parts[0]} ${conjunction} ${parts[1]}`; - } else if (parts.length > 2) { - joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; - } - const firstCharacter = joined[0]; - return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; -} - -function useOverviewSummary(summary: OverviewSummary): string { - const { t } = useTranslation(); - return useMemo(() => { - const parts: string[] = []; - const entries = [ - [summary.editedFileCount, "toolCallGroup.editedFiles"], - [summary.commandCount, "toolCallGroup.commands"], - [summary.readFileCount, "toolCallGroup.readFiles"], - [summary.searchCount, "toolCallGroup.searches"], - [summary.otherToolCount, "toolCallGroup.otherTools"], - [summary.paseoCallCount, "toolCallGroup.paseoCalls"], - ] as const; - for (const [count, key] of entries) { - if (count > 0) { - parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); - } - } - return joinSummaryParts(parts, t("toolCallGroup.and")); - }, [summary, t]); -} - export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({ group, expanded, @@ -62,7 +28,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView }: OverviewGroupProps) { const scrollRef = useRef(null); const isCompact = useIsCompactFormFactor(); - const aggregateSummary = useOverviewSummary(group.summary); + const aggregateSummary = useOverviewSummaryLabel(group.summary); const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); @@ -82,7 +48,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView showsVerticalScrollIndicator onContentSizeChange={scrollToLatest} > - {children} + {children} ), [children, scrollToLatest], 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 dabf87b73d..5c4b4a900b 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -110,11 +110,7 @@ describe("tool call detail-level projection", () => { preparedHistory: prepared, }); expect(single.head).toEqual([firstCall]); - expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({ - calls: [firstCall], - latest: firstCall, - isSealed: false, - }); + expect(single.groupsByHostId.size).toBe(0); const grouped = project({ level: "overview", @@ -142,18 +138,11 @@ describe("tool call detail-level projection", () => { expect(result.groupsByHostId.get("1")?.isLoading).toBe(true); }); - it("builds a loading aggregate for a one-call run", () => { + it("leaves a running lone call visible without an aggregate", () => { const call = toolCall("1", { type: "shell", command: "one" }, { status: "running" }); const result = project({ level: "overview", head: [call], isTurnActive: true }); - const group = result.groupsByHostId.get(call.id); - if (!group) { - throw new Error("Expected an overview group"); - } - - expect(group).toMatchObject({ - isLoading: true, - summary: { commandCount: 1 }, - }); + expect(result.head).toEqual([call]); + expect(result.groupsByHostId.size).toBe(0); }); it("keeps an active overview group on its latest call until a visible boundary arrives", () => { @@ -255,12 +244,13 @@ describe("tool call detail-level projection", () => { run: expect.any(Object), isLoading: false, summary: { + thoughtCount: 0, editedFileCount: 1, commandCount: 1, readFileCount: 2, searchCount: 0, otherToolCount: 0, - paseoCallCount: 0, + paseoActions: {}, }, }); }); @@ -328,7 +318,7 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: calls }); expect(result.groupsByHostId.get("1")).toMatchObject({ - summary: { otherToolCount: 2, paseoCallCount: 2 }, + summary: { otherToolCount: 2, paseoActions: { "Listed agents": 1, "List worktrees": 1 } }, }); }); @@ -346,7 +336,11 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: calls }); expect(result.groupsByHostId.get("1")).toMatchObject({ - summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 }, + summary: { + searchCount: 3, + otherToolCount: 0, + paseoActions: { "Listed providers": 1, "List worktrees": 2 }, + }, }); }); @@ -476,7 +470,105 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: [singleCall, plan, speak] }); expect(result.head).toEqual([singleCall, plan, speak]); - expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]); - expect(result.groupsByHostId.size).toBe(1); + expect(result.groupsByHostId.size).toBe(0); }); }); + +it("keeps a heartbeat trigger visible outside grouped work", () => { + const trigger = toolCall( + "1", + { type: "plain_text", text: "Check progress" }, + { name: "heartbeat" }, + ); + const work = toolCall("2", { type: "shell", command: "pwd" }); + const result = project({ level: "overview", tail: [trigger, work] }); + expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]); + expect(result.groupsByHostId.has("1")).toBe(false); +}); + +it("does not merge tools from different canonical turns", () => { + const first = toolCall("1", { type: "shell", command: "pwd" }); + first.turnId = "first"; + const second = toolCall("2", { type: "shell", command: "ls" }); + second.turnId = "second"; + const result = project({ level: "overview", tail: [first, second] }); + expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]); +}); + +it.each([false, true])( + "handles streaming reasoning across retained history (Expand thinking %s)", + (autoExpandReasoning) => { + const call = { ...toolCall("1", { type: "shell", command: "echo ok" }), turnId: "run" }; + const thought: StreamItem = { + kind: "thought", + id: "reasoning", + text: "Checking", + status: "loading", + timestamp: new Date(), + turnId: "run", + }; + const tail = [call]; + const result = project({ + level: "overview", + tail, + head: [thought], + isTurnActive: true, + preparedHistory: prepareToolCallHistory("overview", tail, autoExpandReasoning), + }); + expect(result.head).toEqual(autoExpandReasoning ? [thought] : []); + expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual( + autoExpandReasoning ? undefined : [call, thought], + ); + expect(result.groupsByHostId.get(call.id)?.summary).toEqual( + autoExpandReasoning + ? undefined + : expect.objectContaining({ + commandCount: 1, + otherToolCount: 0, + thoughtCount: 1, + }), + ); + }, +); + +it("turns a retained lone call into a group without replacing its row key", () => { + const first = toolCall("1", { type: "shell", command: "one" }); + const second = toolCall("2", { type: "shell", command: "two" }); + const tail = [assistant("before"), first]; + const preparedHistory = prepareToolCallHistory("overview", tail); + const single = project({ level: "overview", tail, preparedHistory, isTurnActive: true }); + const grouped = project({ + level: "overview", + tail, + head: [second], + preparedHistory, + isTurnActive: true, + }); + expect(single.groupsByHostId.size).toBe(0); + expect(single.tail).toBe(tail); + expect(grouped.tail).toBe(single.tail); + expect(grouped.head).toEqual([]); + expect(grouped.groupsByHostId.get(first.id)?.run.calls).toEqual([first, second]); + expect(grouped.historyGroupUpdatesByHostId.get(first.id)).toBe( + grouped.groupsByHostId.get(first.id), + ); +}); + +it("shows one thought as itself until another groupable item arrives", () => { + const thought: StreamItem = { + kind: "thought", + id: "thought", + text: "Checking", + status: "ready", + timestamp: new Date(0), + }; + const single = project({ level: "overview", tail: [thought] }); + expect(single.tail).toEqual([thought]); + expect(single.groupsByHostId.size).toBe(0); + const grouped = project({ + level: "overview", + tail: [thought, toolCall("1", { type: "shell", command: "one" })], + }); + expect(grouped.tail.map((item) => item.id)).toEqual([thought.id]); + expect(grouped.groupsByHostId.get(thought.id)?.run.calls).toHaveLength(2); +}); diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts index 5dc1067914..3f10855ce5 100644 --- a/packages/app/src/tool-calls/detail-level/projection.ts +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -1,5 +1,4 @@ import type { StreamItem } from "@/types/stream"; -import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; import { groupLiveToolCalls, prepareGroupedHistory, @@ -8,11 +7,13 @@ import { } from "./grouping"; import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model"; -export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; +/** Whether consecutive tool calls show one by one or as one summary row. */ +export type ToolCallDetailLevel = "overview" | "detailed"; export type ToolCallDetailGroup = OverviewToolCallGroup; export interface PreparedToolCallHistory { mode: "overview"; + autoExpandReasoning: boolean; grouped: GroupedHistory; } @@ -41,15 +42,18 @@ function visibleToolCallItems(items: StreamItem[]): StreamItem[] { export function prepareToolCallHistory( level: ToolCallDetailLevel, tail: StreamItem[], + autoExpandReasoning = false, ): PreparedToolCallHistory | null { if (level === "detailed") { return null; } return { mode: "overview", + autoExpandReasoning, grouped: prepareGroupedHistory({ tail: visibleToolCallItems(tail), buildGroup: buildOverviewGroup, + autoExpandReasoning, }), }; } @@ -74,6 +78,7 @@ export function projectToolCallDetailLevel(input: { } return groupLiveToolCalls({ history: input.preparedHistory.grouped, + autoExpandReasoning: input.preparedHistory.autoExpandReasoning, head: visibleToolCallItems(input.head), isTurnActive: input.isTurnActive, buildGroup: buildOverviewGroup, diff --git a/packages/app/src/tool-calls/expansion-context.tsx b/packages/app/src/tool-calls/expansion-context.tsx new file mode 100644 index 0000000000..4ac2477247 --- /dev/null +++ b/packages/app/src/tool-calls/expansion-context.tsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useState, useSyncExternalStore } from "react"; +import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal"; +import { + createToolCallExpansion, + type ExpandedToolCallGroups, + type ToolCallExpansion, +} from "./expansion"; + +export const ToolCallExpansionContext = createContext(null); + +/** One store per viewed agent; switching agents starts with every tool call closed. */ +export function useAgentToolCallExpansion(agentId: string): ToolCallExpansion { + const [owner, setOwner] = useState(() => ({ agentId, expansion: createToolCallExpansion() })); + if (owner.agentId === agentId) return owner.expansion; + const next = { agentId, expansion: createToolCallExpansion() }; + setOwner(next); + return next.expansion; +} + +/** Subscribes the timeline to group toggles only; call toggles never reach it. */ +export function useExpandedToolCallGroups(expansion: ToolCallExpansion): ExpandedToolCallGroups { + return useSyncExternalStore( + expansion.subscribeGroups, + expansion.getExpandedGroups, + expansion.getExpandedGroups, + ); +} + +function useToolCallExpansion(): ToolCallExpansion { + const expansion = useContext(ToolCallExpansionContext); + if (!expansion) throw new Error("ToolCall requires a ToolCallExpansionContext"); + return expansion; +} + +/** Carries tool call open state into sheets that render their content in a portal. */ +export function useToolCallExpansionContextBridge(): ContextBridge { + const expansion = useToolCallExpansion(); + return useCallback( + (children) => ( + + {children} + + ), + [expansion], + ); +} + +/** Subscribes one tool call to its own open state. */ +export function useToolCallExpanded(input: { + callId: string; + defaultExpanded: boolean; + standalone: boolean; +}) { + const expansion = useToolCallExpansion(); + const { callId, defaultExpanded, standalone } = input; + const subscribe = useCallback( + (listener: () => void) => expansion.subscribeCall(callId, listener), + [expansion, callId], + ); + const getSnapshot = useCallback( + () => expansion.isCallExpanded(callId, defaultExpanded), + [expansion, callId, defaultExpanded], + ); + const expanded = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const toggle = useCallback(() => { + expansion.setCallExpanded({ callId, expanded: !expanded, standalone }); + }, [expansion, callId, expanded, standalone]); + return { expanded, toggle }; +} diff --git a/packages/app/src/tool-calls/expansion.test.ts b/packages/app/src/tool-calls/expansion.test.ts new file mode 100644 index 0000000000..875d3c6078 --- /dev/null +++ b/packages/app/src/tool-calls/expansion.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { createToolCallExpansion } from "./expansion"; + +function observe(expansion: ReturnType) { + const notified: string[] = []; + expansion.subscribeCall("first", () => notified.push("first")); + expansion.subscribeCall("second", () => notified.push("second")); + expansion.subscribeGroups(() => notified.push("timeline")); + return notified; +} + +describe("tool call expansion", () => { + it("opens a lone call and its future group without notifying the timeline", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + const groupsBefore = expansion.getExpandedGroups(); + + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true }); + + expect(notified).toEqual(["first"]); + expect(expansion.getExpandedGroups()).toBe(groupsBefore); + expect(expansion.isCallExpanded("first", false)).toBe(true); + expect(expansion.getExpandedGroups().has("first")).toBe(true); + }); + + it("closes the lone call and its future group together", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + const groupsBefore = expansion.getExpandedGroups(); + + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true }); + expansion.setCallExpanded({ callId: "first", expanded: false, standalone: true }); + + expect(notified).toEqual(["first", "first"]); + expect(expansion.getExpandedGroups()).toBe(groupsBefore); + expect(expansion.isCallExpanded("first", false)).toBe(false); + expect(expansion.getExpandedGroups().has("first")).toBe(false); + }); + + it("keeps a call open when its group closes and reopens", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true }); + const groupsWhenFormed = expansion.getExpandedGroups(); + + expansion.setGroupExpanded("first", false); + const closedGroups = expansion.getExpandedGroups(); + expect(closedGroups).not.toBe(groupsWhenFormed); + expect(closedGroups.has("first")).toBe(false); + expect(expansion.isCallExpanded("first", false)).toBe(true); + + expansion.setGroupExpanded("first", true); + expect(expansion.getExpandedGroups()).not.toBe(closedGroups); + expect(expansion.getExpandedGroups().has("first")).toBe(true); + expect(expansion.isCallExpanded("first", false)).toBe(true); + expect(notified).toEqual(["first", "timeline", "timeline"]); + }); + + it("toggles a call inside a group without touching the group", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + expansion.setGroupExpanded("first", true); + const groups = expansion.getExpandedGroups(); + + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: false }); + expansion.setCallExpanded({ callId: "first", expanded: false, standalone: false }); + + expect(notified).toEqual(["timeline", "first", "first"]); + expect(expansion.getExpandedGroups()).toBe(groups); + expect(groups.has("first")).toBe(true); + }); + + it("uses the call's default until the user toggles it", () => { + const expansion = createToolCallExpansion(); + expect(expansion.isCallExpanded("first", true)).toBe(true); + expansion.setCallExpanded({ callId: "first", expanded: false, standalone: true }); + expect(expansion.isCallExpanded("first", true)).toBe(false); + }); +}); diff --git a/packages/app/src/tool-calls/expansion.ts b/packages/app/src/tool-calls/expansion.ts new file mode 100644 index 0000000000..d11a824ae2 --- /dev/null +++ b/packages/app/src/tool-calls/expansion.ts @@ -0,0 +1,67 @@ +type Listener = () => void; + +/** Which tool call groups are open. A new value is published only when a group is toggled. */ +export interface ExpandedToolCallGroups { + has(groupId: string): boolean; +} + +/** + * Open state of one agent's tool calls and tool call groups. + * + * A group takes its first call's id. Opening a lone call (`standalone`) also opens the group it + * will host once a second call arrives, without publishing a group change: the timeline renders + * that row again when the group forms and reads the open state then. A call toggle notifies only + * that call's subscribers. + */ +export function createToolCallExpansion() { + const calls = new Map(); + const callListeners = new Map>(); + const groups = new Set(); + const groupListeners = new Set(); + let expandedGroups = createExpandedGroups(); + + function createExpandedGroups(): ExpandedToolCallGroups { + return { has: (groupId) => groups.has(groupId) }; + } + + function writeGroup(groupId: string, expanded: boolean) { + if (expanded) groups.add(groupId); + else groups.delete(groupId); + } + + return { + isCallExpanded: (callId: string, defaultExpanded: boolean) => + calls.get(callId) ?? defaultExpanded, + subscribeCall(callId: string, listener: Listener) { + let listeners = callListeners.get(callId); + if (!listeners) { + listeners = new Set(); + callListeners.set(callId, listeners); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) callListeners.delete(callId); + }; + }, + setCallExpanded(input: { callId: string; expanded: boolean; standalone: boolean }) { + calls.set(input.callId, input.expanded); + if (input.standalone) writeGroup(input.callId, input.expanded); + for (const listener of callListeners.get(input.callId) ?? []) listener(); + }, + getExpandedGroups: () => expandedGroups, + subscribeGroups(listener: Listener) { + groupListeners.add(listener); + return () => { + groupListeners.delete(listener); + }; + }, + setGroupExpanded(groupId: string, expanded: boolean) { + writeGroup(groupId, expanded); + expandedGroups = createExpandedGroups(); + for (const listener of groupListeners) listener(); + }, + }; +} + +export type ToolCallExpansion = ReturnType; diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 62e26698db..376d04ea67 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -661,6 +661,82 @@ describe("stream reducer canonical tool calls", () => { expect(new Set(tools.map((tool) => tool.id)).size).toBe(2); }); + it.each(["live", "canonical"] as const)( + "keeps current tool identity during %s updates when an older turn reports late", + (source) => { + let state: StreamItem[] = []; + const events = [ + { callId: "background", turnId: "codex-turn-17", status: "running" as const }, + { callId: "exec-current", turnId: "codex-turn-18", status: "running" as const }, + { callId: "background", turnId: "codex-turn-17", status: "completed" as const }, + { callId: "exec-current", turnId: "codex-turn-18", status: "completed" as const }, + ]; + for (const [index, item] of events.entries()) { + state = reduceStreamUpdate( + state, + canonicalToolTimeline({ + provider: "codex", + name: "exec_command", + ...item, + }), + new Date(index * 1000), + { source }, + ); + } + expect( + state.filter(isAgentToolCallItem).map((item) => ({ + id: item.id, + turnId: item.turnId, + status: item.payload.data.status, + })), + ).toEqual([ + { + id: "agent_tool_turn:codex-turn-17/background", + turnId: "codex-turn-17", + status: "completed", + }, + { + id: "agent_tool_turn:codex-turn-18/exec-current", + turnId: "codex-turn-18", + status: "completed", + }, + ]); + }, + ); + + it("does not move reasoning into an older turn when a late tool update finalizes it", () => { + const background = canonicalToolTimeline({ + provider: "codex", + name: "exec_command", + callId: "background", + turnId: "old", + status: "running", + }); + let state = reduceStreamUpdate([], background, new Date(0)); + state = reduceStreamUpdate( + state, + { + type: "timeline", + provider: "codex", + turnId: "current", + item: { type: "reasoning", text: "Working on the current request" }, + }, + new Date(1), + ); + state = reduceStreamUpdate( + state, + canonicalToolTimeline({ + provider: "codex", + name: "exec_command", + callId: "background", + turnId: "old", + status: "completed", + }), + new Date(2), + ); + expect(state.at(-1)).toMatchObject({ kind: "thought", turnId: "current", status: "ready" }); + }); + it("merges lifecycle updates for the same call occurrence", () => { const callId = "tool-updated-within-turn"; const turnId = "autonomous-turn-1"; diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 9425cabbed..0226993a8c 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -91,6 +91,8 @@ export type StreamItem = export type UserMessageImageAttachment = AttachmentMetadata; export interface UserMessageItem { + /** Attribution added by presentation for received agent messages. */ + sender?: AgentMessage["sender"]; kind: "user_message"; id: string; clientMessageId?: string; @@ -771,6 +773,12 @@ export type AgentToolCallItem = ToolCallItem & { payload: { source: "agent"; data: AgentToolCallData }; }; +/** Find and scrolling address a plugin row by the source it replaces. */ +export function getStreamItemMessageId(item: StreamItem): string { + if (item.kind === "plugin") return item.sourceId ?? item.id; + return item.id; +} + export function isAgentToolCallItem(item: StreamItem): item is AgentToolCallItem { return item.kind === "tool_call" && item.payload.source === "agent"; } @@ -980,6 +988,7 @@ function appendThought( text: string, timestamp: Date, timelineCursor?: TimelinePosition, + turnId?: string, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -987,7 +996,7 @@ function appendThought( } const last = state[state.length - 1]; - if (last && last.kind === "thought") { + if (last && last.kind === "thought" && last.turnId === turnId) { const updated: ThoughtItem = { ...last, ...(timelineCursor ? { timelineCursor } : {}), @@ -1505,36 +1514,30 @@ function reduceTimelineEvent( const item = event.item; switch (item.type) { case "user_message": - return finalizeActiveThoughts( - appendUserMessage( - state, - item.text, - timestamp, - source, - item.messageId, - item.clientMessageId, - timelineCursor, - event.turnId, - ), + return appendUserMessage( + state, + item.text, + timestamp, + source, + item.messageId, + item.clientMessageId, + timelineCursor, + event.turnId, ); case "assistant_message": - return finalizeActiveThoughts( - appendAssistantMessage( - state, - item.text, - timestamp, - source, - item.messageId, - reservedItemIds, - timelineCursor, - ), + return appendAssistantMessage( + state, + item.text, + timestamp, + source, + item.messageId, + reservedItemIds, + timelineCursor, ); case "reasoning": - return appendThought(state, item.text, timestamp, timelineCursor); + return appendThought(state, item.text, timestamp, timelineCursor, event.turnId); case "tool_call": - return finalizeActiveThoughts( - reduceTimelineToolCall(state, event, item, timestamp, timelineCursor), - ); + return reduceTimelineToolCall(state, event, item, timestamp, timelineCursor); case "todo": { const items: TodoEntry[] = (item.items ?? []).map((todo) => ({ text: todo.text, @@ -1543,9 +1546,7 @@ function reduceTimelineEvent( status: todo.status, activeForm: todo.activeForm, })); - return finalizeActiveThoughts( - appendTodoList(state, event.provider, items, timestamp, timelineCursor), - ); + return appendTodoList(state, event.provider, items, timestamp, timelineCursor); } case "error": { const notification: NotificationItem = { @@ -1557,7 +1558,7 @@ function reduceTimelineEvent( level: "error", message: item.message ?? "Unknown error", }; - return finalizeActiveThoughts(appendNotification(state, notification)); + return appendNotification(state, notification); } case "notification": { const notification: NotificationItem = { @@ -1569,16 +1570,12 @@ function reduceTimelineEvent( level: item.level, message: item.message, }; - return finalizeActiveThoughts(appendNotification(state, notification)); + return appendNotification(state, notification); } case "compaction": - return finalizeActiveThoughts( - reduceTimelineCompaction(state, item, timestamp, timelineCursor), - ); + return reduceTimelineCompaction(state, item, timestamp, timelineCursor); case "plugin": - return finalizeActiveThoughts( - appendPluginTimelineItem(state, item, timestamp, timelineCursor), - ); + return appendPluginTimelineItem(state, item, timestamp, timelineCursor); default: return state; } @@ -1595,8 +1592,9 @@ export function reduceStreamUpdate( ): StreamItem[] { const source = options?.source ?? "live"; switch (event.type) { - case "timeline": - return applyTimelineTurnId( + case "timeline": { + const updated = applyTimelineTurnId( + state, reduceTimelineEvent( state, event, @@ -1607,6 +1605,8 @@ export function reduceStreamUpdate( ), event, ); + return event.item.type === "reasoning" ? updated : finalizeActiveThoughts(updated); + } case "thread_started": case "turn_started": case "turn_completed": @@ -1622,6 +1622,7 @@ export function reduceStreamUpdate( } function applyTimelineTurnId( + previous: StreamItem[], items: StreamItem[], event: Extract, ): StreamItem[] { @@ -1631,15 +1632,14 @@ function applyTimelineTurnId( return reconcileCanonicalUserTurnMembership(items, clientMessageId, event.turnId); } - if (!event.turnId || items.length === 0) return items; - const index = items.length - 1; - const last = items[index]; - if (!last || last.turnId === event.turnId) return items; - return [ - ...items.slice(0, index), - { ...last, turnId: event.turnId } as StreamItem, - ...items.slice(index + 1), - ]; + if (!event.turnId || items === previous) return items; + // Assign membership only to rows written by this event, before finalizing + // unrelated reasoning rows. Late lifecycle updates can replace an earlier row. + return items.map((item, index) => + item === previous[index] || item.turnId === event.turnId + ? item + : ({ ...item, turnId: event.turnId } as StreamItem), + ); } function reconcileCanonicalUserTurnMembership( diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index def723c554..bcd33873cc 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -1,10 +1,16 @@ -import { useMemo } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { clampPct, formatAmount, formatProviderUsageLabel, formatResetLabel } from "./format"; -import type { UsageBalance, UsageTone } from "./types"; +import { + formatAmount, + formatProviderUsageLabel, + formatResetLabel, + formatResetTime, +} from "./format"; +import { UsageMeter } from "./meter"; +import { UsageResetLine } from "./reset-line"; +import type { UsageBalance } from "./types"; import { useRelativeTimeTick } from "./use-relative-time-tick"; interface ResolvedBalance { @@ -41,19 +47,6 @@ function resolveBalance({ return { amountText: "—", usedPct: null }; } -function fillToneStyle(tone: UsageTone) { - switch (tone) { - case "ok": - return styles.fillOk; - case "warning": - return styles.fillWarning; - case "danger": - return styles.fillDanger; - default: - return styles.fillDefault; - } -} - export function UsageBalanceBar({ balance, retained = false, @@ -66,11 +59,7 @@ export function UsageBalanceBar({ const { amountText, usedPct } = resolveBalance({ balance, t, locale: i18n.resolvedLanguage }); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt, retained, now, t); - - const fillStyle = useMemo>( - () => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }], - [usedPct, tone], - ); + const resetTime = formatResetTime(balance.resetsAt, now, t); return ( @@ -78,22 +67,21 @@ export function UsageBalanceBar({ {formatProviderUsageLabel(balance.id, balance.label, t)} - - - {amountText} - - {resetLabel ? ( - - {resetLabel} - - ) : null} - + + {amountText} + - {usedPct != null ? ( - - - - ) : null} + {usedPct != null ? : null} + ); } @@ -110,44 +98,13 @@ const styles = StyleSheet.create((theme) => ({ }, label: { flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - value: { - flexShrink: 1, - alignItems: "flex-end", - }, - amount: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, - reset: { - color: theme.colors.foregroundMuted, + amount: { + flexShrink: 1, + color: theme.colors.foreground, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - textAlign: "right", - }, - track: { - height: 4, - borderRadius: 2, - backgroundColor: theme.colors.surface3, - overflow: "hidden", - }, - fill: { - height: 4, - borderRadius: 2, - }, - fillDefault: { - backgroundColor: theme.colors.foregroundMuted, - }, - fillOk: { - backgroundColor: theme.colors.statusSuccess, - }, - fillWarning: { - backgroundColor: theme.colors.statusWarning, - }, - fillDanger: { - backgroundColor: theme.colors.statusDanger, }, })); diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index 20297c9df0..f0a8c73f5e 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -21,10 +21,12 @@ export interface UsageHostSelection { */ export function UsageControls({ view, + serverId, onRefresh, hostSelection, }: { view: UsageView; + serverId: string; onRefresh: () => void; hostSelection?: UsageHostSelection; }) { @@ -43,8 +45,8 @@ export function UsageControls({ ) : null} {view.kind === "unavailable" ? null : ( <> - - + {view.kind === "off" ? null : } + )} @@ -84,7 +86,12 @@ export function useHostUsageWithControls(hostSelection: UsageHostSelection): { const controls = useMemo( () => hasControls ? ( - + ) : null, [hasControls, hostSelection, refresh, view], ); diff --git a/packages/app/src/usage/display.ts b/packages/app/src/usage/display.ts index 5b4e534693..c62ed78fd7 100644 --- a/packages/app/src/usage/display.ts +++ b/packages/app/src/usage/display.ts @@ -1,7 +1,5 @@ import { useCallback, useMemo } from "react"; import { useAppSettings } from "@/hooks/use-settings"; -import { useUsageHostId } from "./hosts"; -import { useUsageHostReports } from "./queries"; import { isUsagePinned, setUsageDisplayAs, @@ -25,12 +23,6 @@ interface UsagePreferencesState { display: UsageDisplay; } -/** The device's usage preferences, with pins resolved against a host's reports. */ -export function useUsagePreferences(serverId?: string): UsagePreferencesState { - const defaultServerId = useUsageHostId(); - return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId)); -} - /** The device's usage preferences, with pins resolved against the given reports. */ export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState { const { settings, updateSettings } = useAppSettings(); diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index b7b81f0e5a..89fb9a7ed6 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -7,6 +7,7 @@ import { formatPct, formatProviderUsageLabel, formatResetLabel, + formatResetTime, formatRunsOutLabel, } from "./format"; @@ -36,8 +37,8 @@ describe("formatAmount", () => { const threeDaysAgo = new Date(NOW - 3 * 24 * 60 * 60 * 1000).toISOString(); expect(formatPct(7, "en")).toBe("7%"); - expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets 2h"); - expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out 2h"); + expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("resets in 2h"); + expect(formatRunsOutLabel(twoHoursFromNow, NOW)).toBe("runs out in 2h"); await i18n.changeLanguage("ja"); expect(formatResetLabel(twoHoursFromNow, false, NOW)).toBe("2時間後にリセット"); @@ -94,6 +95,70 @@ describe("formatAmount", () => { }); }); +describe("reset countdown", () => { + const MINUTE = 60_000; + const HOUR = 60 * MINUTE; + const DAY = 24 * HOUR; + const countdown = (ms: number) => formatResetLabel(new Date(NOW + ms).toISOString(), false, NOW); + + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + it("keeps counting down inside a day instead of sitting on the rounded-down day", () => { + expect(countdown(2 * DAY + 23 * HOUR + 10 * MINUTE)).toBe("resets in 2d 23h"); + expect(countdown(2 * DAY + 22 * HOUR + 10 * MINUTE)).toBe("resets in 2d 22h"); + }); + + it("says two units, dropping a unit that is zero", () => { + expect(countdown(DAY + 30 * MINUTE)).toBe("resets in 1d"); + expect(countdown(4 * HOUR + 59 * MINUTE)).toBe("resets in 4h 59m"); + expect(countdown(HOUR)).toBe("resets in 1h"); + expect(countdown(37 * MINUTE)).toBe("resets in 37m"); + expect(countdown(59 * 1000)).toBe("resets in 0m"); + }); + + it("joins the at-risk countdown the same way", () => { + const iso = new Date(NOW + 3 * HOUR + 5 * MINUTE).toISOString(); + expect(formatRunsOutLabel(iso, NOW)).toBe("runs out in 3h 5m"); + }); +}); + +describe("exact reset time", () => { + // Local-time dates, so the thresholds hold in any timezone the tests run in. + const now = new Date(2026, 9, 29, 12, 0).getTime(); + const at = (...args: [number, number, number, number, number]) => + formatResetTime(new Date(...args).toISOString(), now)?.replace(/\s/g, " "); + + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + it("shows only the time within 24 hours", () => { + expect(at(2026, 9, 29, 15, 0)).toMatch(/^(3:00 PM|15:00)$/); + expect(at(2026, 9, 30, 11, 59)).toMatch(/^(11:59 AM|11:59)$/); + }); + + it("adds the weekday within 7 days", () => { + expect(at(2026, 9, 30, 12, 0)).toMatch(/^Fri (12:00 PM|12:00)$/); + expect(at(2026, 10, 5, 11, 59)).toMatch(/^Thu (11:59 AM|11:59)$/); + }); + + it("adds the date after 7 days", () => { + expect(at(2026, 10, 5, 12, 0)).toMatch(/^Nov 5, (12:00 PM|12:00)$/); + }); + + it("names the weekday in the app language", async () => { + await i18n.changeLanguage("fr"); + expect(at(2026, 9, 31, 15, 0)).toMatch(/^sam\.? /); + }); + + it("says nothing for a time that has passed", () => { + expect(formatResetTime(new Date(now - 1).toISOString(), now)).toBeNull(); + expect(formatResetTime(null, now)).toBeNull(); + }); +}); + it("retained reset times that have passed do not claim the quota is resetting", () => { const past = "2000-01-01T00:00:00.000Z"; expect(formatResetLabel(past, true)).toBe("Reset time passed"); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index e7a6ba6f7b..867976a586 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,5 +1,6 @@ import type { TFunction } from "i18next"; import { i18n } from "@/i18n/i18next"; +import { formatTimeOfDay } from "@/utils/time"; import type { UsageDisplayAs } from "./preferences"; import type { UsageBalanceUnit } from "./types"; @@ -50,7 +51,17 @@ export function formatDisplayPct( : t("providerUsage.values.remaining", { amount: percentage }); } -type RelativeDuration = { unit: "now" } | { unit: "minutes" | "hours" | "days"; count: number }; +type DurationUnit = "minutes" | "hours" | "days"; +interface DurationPart { + unit: DurationUnit; + count: number; +} +/** Two units, floored, so a countdown keeps moving: "2d 23h", "4h 59m", "37m". */ +type RelativeDuration = { kind: "now" } | { kind: "remaining"; parts: DurationPart[] }; + +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; function formatCount(value: number): string { return new Intl.NumberFormat(i18n.resolvedLanguage).format(value); @@ -59,35 +70,34 @@ function formatCount(value: number): string { function relativeDuration(iso: string, now: number): RelativeDuration | null { const diffMs = new Date(iso).getTime() - now; if (!Number.isFinite(diffMs)) return null; - if (diffMs <= 0) return { unit: "now" }; - const diffMinutes = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMinutes / 60); - const diffDays = Math.floor(diffHours / 24); - if (diffDays > 0) return { unit: "days", count: diffDays }; - if (diffHours > 0) return { unit: "hours", count: diffHours }; - return { unit: "minutes", count: diffMinutes }; + if (diffMs <= 0) return { kind: "now" }; + const days = Math.floor(diffMs / DAY_MS); + const hours = Math.floor((diffMs % DAY_MS) / HOUR_MS); + const minutes = Math.floor((diffMs % HOUR_MS) / MINUTE_MS); + if (days > 0) return remaining({ unit: "days", count: days }, { unit: "hours", count: hours }); + if (hours > 0) + return remaining({ unit: "hours", count: hours }, { unit: "minutes", count: minutes }); + return remaining({ unit: "minutes", count: minutes }); } -function formatRelativeDuration(duration: RelativeDuration, t: TFunction): string | null { - switch (duration.unit) { - case "now": - return null; - case "days": - return t("providerUsage.duration.days", { - count: duration.count, - value: formatCount(duration.count), - }); - case "hours": - return t("providerUsage.duration.hours", { - count: duration.count, - value: formatCount(duration.count), - }); - case "minutes": - return t("providerUsage.duration.minutes", { - count: duration.count, - value: formatCount(duration.count), - }); - } +/** The major unit, plus the minor one unless it is zero. */ +function remaining(major: DurationPart, minor?: DurationPart): RelativeDuration { + return { kind: "remaining", parts: minor && minor.count > 0 ? [major, minor] : [major] }; +} + +function formatDurationPart(part: DurationPart, t: TFunction): string { + return t(`providerUsage.duration.${part.unit}`, { + count: part.count, + value: formatCount(part.count), + }); +} + +function formatRelativeDuration( + duration: Extract, + t: TFunction, +): string { + const [first, second] = duration.parts.map((part) => formatDurationPart(part, t)); + return second ? t("providerUsage.duration.pair", { first, second }) : first; } export function formatResetLabel( @@ -99,10 +109,34 @@ export function formatResetLabel( if (!iso) return null; const rel = relativeDuration(iso, now); if (!rel) return null; - if (rel.unit === "now" && retained) return t("providerUsage.timing.resetTimePassed"); - if (rel.unit === "now") return t("providerUsage.timing.resettingNow"); - const duration = formatRelativeDuration(rel, t); - return duration ? t("providerUsage.timing.resetsIn", { duration }) : null; + if (rel.kind === "now" && retained) return t("providerUsage.timing.resetTimePassed"); + if (rel.kind === "now") return t("providerUsage.timing.resettingNow"); + return t("providerUsage.timing.resetsIn", { duration: formatRelativeDuration(rel, t) }); +} + +/** + * When a window resets on the clock: "3:00 PM" within a day, "Fri 3:00 PM" within a week, + * "Oct 31, 3:00 PM" after that. Names follow the app language; 12/24h follows the device. + * Nothing for a time that has passed, where the countdown already says so. + */ +export function formatResetTime( + iso: string | null | undefined, + now = Date.now(), + t: TFunction = i18n.t, +): string | null { + if (!iso) return null; + const at = new Date(iso); + const diffMs = at.getTime() - now; + if (!Number.isFinite(diffMs) || diffMs <= 0) return null; + const time = formatTimeOfDay(at); + const locale = i18n.resolvedLanguage; + if (diffMs < DAY_MS) return time; + if (diffMs < 7 * DAY_MS) { + const weekday = at.toLocaleDateString(locale, { weekday: "short" }); + return t("providerUsage.timing.resetsAtWeekday", { weekday, time }); + } + const date = at.toLocaleDateString(locale, { month: "short", day: "numeric" }); + return t("providerUsage.timing.resetsAtDate", { date, time }); } export function formatRunsOutLabel( @@ -113,9 +147,8 @@ export function formatRunsOutLabel( if (!iso) return null; const rel = relativeDuration(iso, now); if (!rel) return null; - if (rel.unit === "now") return t("providerUsage.timing.runsOutNow"); - const duration = formatRelativeDuration(rel, t); - return duration ? t("providerUsage.timing.runsOutIn", { duration }) : null; + if (rel.kind === "now") return t("providerUsage.timing.runsOutNow"); + return t("providerUsage.timing.runsOutIn", { duration: formatRelativeDuration(rel, t) }); } export function formatAgo( diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx index e80e839680..4921c7f954 100644 --- a/packages/app/src/usage/host-usage-section.tsx +++ b/packages/app/src/usage/host-usage-section.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { useUsagePreferences } from "./display"; +import { useUsageDisplay } from "./display"; import { useHostUsage } from "./queries"; import { UsageSection } from "./usage-section"; @@ -7,7 +7,7 @@ import { UsageSection } from "./usage-section"; export function HostUsageSection({ serverId }: { serverId: string }) { const { t } = useTranslation(); const { view, refresh } = useHostUsage(serverId); - const { display } = useUsagePreferences(serverId); + const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []); return ( ({ track: { - height: 4, - borderRadius: 2, + height: 6, + borderRadius: 3, backgroundColor: theme.colors.surface3, overflow: "hidden", }, diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index a278d66984..3090ac79e4 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -405,3 +405,15 @@ it("available and retained usage show the same successful completion time", () = ); expect(upsertReport([failure], success)).toEqual([failure]); }); + +it("disabled host shows off even with cached reports", () => { + expect( + resolveUsageView({ + hostLabel: "Laptop", + isConnected: true, + supportsUsage: true, + enabled: false, + query: { data: [], error: null, isFetching: false }, + }), + ).toMatchObject({ kind: "off" }); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 48143ad23f..bbe183dcd0 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -106,6 +106,7 @@ export interface UsageQueryState { export function resolveUsageView( input: { + enabled?: boolean; hostLabel: string; isConnected: boolean; supportsUsage: boolean; @@ -125,6 +126,8 @@ export function resolveUsageView( message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }), }; } + if (input.enabled === false) + return { kind: "off", message: t("providerUsage.off", { host: hostLabel }) }; if (query?.data) { return { kind: "ready", reports: query.data, isRefreshing: query.isFetching }; } diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx index b735b612aa..44205fbddd 100644 --- a/packages/app/src/usage/options.tsx +++ b/packages/app/src/usage/options.tsx @@ -8,15 +8,20 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane- import { useIsCompactFormFactor } from "@/constants/layout"; import { SettingsRow, SettingsSwitch } from "@/components/settings"; import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; +import { Alert } from "@/components/ui/alert"; +import { useHosts } from "@/runtime/host-runtime"; import { settingsStyles } from "@/styles/settings"; -import { useUsagePreferences } from "./display"; +import { useUsageSetting } from "./setting"; +import { useUsageDisplay } from "./display"; import { useUsageInSidebar } from "./in-sidebar"; import type { UsageDisplayAs } from "./preferences"; const ThemedSettings = withUnistyles(Settings); /** The shared usage preferences, opened from a cog as a popover or compact sheet. */ -export function UsageOptions() { +export function UsageOptions({ serverId }: { serverId: string | null }) { + const setting = useUsageSetting(serverId); + const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId; const { t } = useTranslation(); const displayAsOptions: SegmentedControlOption[] = [ { value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" }, @@ -27,7 +32,7 @@ export function UsageOptions() { }, ]; const compact = useIsCompactFormFactor(); - const { display } = useUsagePreferences(); + const { display } = useUsageDisplay([]); const { inSidebar, setInSidebar } = useUsageInSidebar(); return ( @@ -49,13 +54,25 @@ export function UsageOptions() { testID="usage-options-surface" > - + {setting.error ? : null} + {setting.supported ? ( + + ) : null} + + + void } { + const setting = useUsageSetting(serverId); const queryClient = useQueryClient(); const isConnected = useHostRuntimeIsConnected(serverId); const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); const query = useFetchQuery({ queryKey: usageReportsQueryKey(serverId), queryFn: () => listReports(queryClient, serverId), - enabled: isConnected && isSupported, + enabled: isConnected && isSupported && setting.enabled, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, }); @@ -150,6 +152,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const view = resolveUsageView( { hostLabel, + enabled: setting.ready ? setting.enabled : undefined, isConnected, supportsUsage: isSupported, query: toQueryState(query), @@ -166,14 +169,16 @@ const NO_REPORTS: UsageReportEntry[] = []; * load, or without a host. */ export function useUsageHostReports(serverId: string | null): UsageReportEntry[] { + const setting = useUsageSetting(serverId); const queryClient = useQueryClient(); const query = useFetchQuery({ + enabled: setting.enabled, queryKey: usageReportsQueryKey(serverId ?? ""), queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, }); - return query.data ?? NO_REPORTS; + return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS; } /** @@ -209,7 +214,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView export function useHostReportsUsage(serverId: string): boolean { const isConnected = useHostRuntimeIsConnected(serverId); const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); - return isConnected && isSupported; + const setting = useUsageSetting(serverId); + return isConnected && isSupported && setting.enabled; } /** Every host with whether it is connected and reports usage, in host order. */ diff --git a/packages/app/src/usage/reset-line.tsx b/packages/app/src/usage/reset-line.tsx new file mode 100644 index 0000000000..8016df2926 --- /dev/null +++ b/packages/app/src/usage/reset-line.tsx @@ -0,0 +1,48 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * The reset data of a window or balance: the countdown first, then the exact time. When the line + * is too narrow the exact time gives way (truncates) and the countdown stays whole. + */ +export function UsageResetLine({ + countdown, + exactTime, + atRisk = false, + testID, +}: { + countdown: string | null; + exactTime: string | null; + /** The countdown is a runs-out warning rather than a reset. */ + atRisk?: boolean; + testID?: string; +}) { + if (!countdown) return null; + return ( + + {countdown} + {exactTime ? ( + + {`\u00a0·\u00a0${exactTime}`} + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + line: { flexDirection: "row", alignItems: "center" }, + text: { + flexShrink: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + atRisk: { + flexShrink: 0, + color: theme.colors.statusDanger, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + exactTime: { flexShrink: 1 }, +})); diff --git a/packages/app/src/usage/setting.ts b/packages/app/src/usage/setting.ts new file mode 100644 index 0000000000..e6916cf8c8 --- /dev/null +++ b/packages/app/src/usage/setting.ts @@ -0,0 +1,26 @@ +import { useMutation } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useSessionStore } from "@/stores/session-store"; + +/** Host policy comes from the live daemon config replica. */ +export function useUsageSetting(serverId: string | null) { + const supported = useSessionStore((state) => + Boolean(serverId && state.sessions[serverId]?.serverInfo?.features?.usageSetting), + ); + const { config, patchConfig } = useDaemonConfig(supported ? serverId : null); + const mutation = useMutation({ + mutationFn: (enabled: boolean) => patchConfig({ usage: { enabled } }), + }); + const setEnabled = mutation.mutate; + const turnOn = useCallback(() => setEnabled(true), [setEnabled]); + return { + supported, + error: mutation.error, + // COMPAT(usageSetting): added in v0.14, remove after 2027-05-01. + enabled: !supported || (config !== null && config.usage?.enabled !== false), + ready: !supported || config !== null, + setEnabled, + turnOn, + }; +} diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 45a8a8b293..437c0a929c 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -17,7 +17,8 @@ import { } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; -import { useUsagePreferences } from "./display"; +import { useUsageDisplay } from "./display"; +import { useUsageInSidebar } from "./in-sidebar"; import { useUsageHostId } from "./hosts"; import { useUsageHostReports } from "./queries"; import { UsageSourceIcon } from "./source-icon"; @@ -33,28 +34,24 @@ import { import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ -function useUsageSummary(): readonly PinnedUsageSource[] { +export function useUsageSummary(): readonly PinnedUsageSource[] { const { t } = useTranslation(); - const { preferences } = useUsagePreferences(); - const reports = useUsageHostReports(useUsageHostId()); + const { inSidebar } = useUsageInSidebar(); + const serverId = useUsageHostId(); + const reports = useUsageHostReports(inSidebar ? serverId : null); + const { preferences } = useUsageDisplay(reports); return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]); } -/** Whether the sidebar Usage item has anything to show. */ -export function useHasUsageSummary(): boolean { - return useUsageSummary().length > 0; -} - /** * The sidebar footer's usage entry: each summary window's source icon and percent, and nothing * while no summary window has data, since the footer's Usage icon already opens Usage. * Pressing it opens the Usage modal. */ -export function UsageSidebarItem() { +export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSource[] }) { const { t } = useTranslation(); const label = t(builtinSidebarNavLabelKey("usage")); const openUsage = useOpenSidebarUsage(); - const sources = useUsageSummary(); if (sources.length === 0) return null; return ; } @@ -192,12 +189,7 @@ function PinnedUsageLine({ {layout === "meters" ? ( - + ) : null} ({ minWidth: MIN_METER_WIDTH, marginRight: METER_GAP, }, - meter: { - height: 6, - borderRadius: 3, - }, percent: { flexShrink: 0, color: theme.colors.foreground, diff --git a/packages/app/src/usage/types.ts b/packages/app/src/usage/types.ts index 58b8e31c98..9e36f58c84 100644 --- a/packages/app/src/usage/types.ts +++ b/packages/app/src/usage/types.ts @@ -10,6 +10,7 @@ export type UsageBalanceUnit = UsageBalance["unit"]; /** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */ export type UsageView = + | { kind: "off"; message: string } | { kind: "unavailable"; message: string } | { kind: "loading" } | { kind: "error"; message: string } diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx index bf92eaa60f..4e049c0b58 100644 --- a/packages/app/src/usage/usage-modal.tsx +++ b/packages/app/src/usage/usage-modal.tsx @@ -4,7 +4,7 @@ import { View } from "react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useHostUsageWithControls } from "./controls"; -import { useUsagePreferences } from "./display"; +import { useUsageDisplay } from "./display"; import { useUsageHostSelection } from "./hosts"; import type { UsageHost } from "./model"; import { UsageBody, UsageMessage } from "./usage-section"; @@ -52,12 +52,12 @@ function HostUsageModal({ hosts: UsageHost[]; onSelectHost: (serverId: string) => void; }) { - const { display } = useUsagePreferences(); const hostSelection = useMemo( () => ({ hosts, serverId, onSelect: onSelectHost }), [hosts, onSelectHost, serverId], ); const { view, refresh, controls } = useHostUsageWithControls(hostSelection); + const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []); return ( diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx index 26699fd447..9c1e9354c1 100644 --- a/packages/app/src/usage/usage-section.tsx +++ b/packages/app/src/usage/usage-section.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { settingsStyles } from "@/styles/settings"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import type { UsageDisplay } from "./display"; +import { useUsageSetting } from "./setting"; import { UsageControls } from "./controls"; import { UsageList } from "./list"; import type { UsageView } from "./types"; @@ -27,8 +28,8 @@ export function UsageSection({ testID?: string; }) { const trailing = useMemo( - () => , - [onRefresh, view], + () => , + [onRefresh, view, serverId], ); return ( @@ -51,6 +52,15 @@ export function UsageBody({ onRefresh: () => void; }) { const { t } = useTranslation(); + const setting = useUsageSetting(serverId); + if (view.kind === "off") + return ( + + + {setting.error ? : null} + + + ); if (view.kind === "unavailable") { return ; } diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 99c1eb9f21..b837872e01 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -1,29 +1,24 @@ import { Pin } from "lucide-react-native"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { Pressable, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { formatDisplayPct, formatProviderUsageLabel, formatResetLabel, + formatResetTime, formatRunsOutLabel, } from "./format"; import { UsageMeter } from "./meter"; import { displayPercent, usageWindowRowLabel } from "./model"; +import { UsageResetLine } from "./reset-line"; import type { UsageDisplayAs } from "./preferences"; import { windowTone } from "./tone"; import type { UsageTone, UsageWindow } from "./types"; import { useRelativeTimeTick } from "./use-relative-time-tick"; -// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength, -// so a hover never reads as the selection. Pinned rows do not react to hover. -function highlightStyle(pinned: boolean, hovered: boolean) { - if (pinned) return styles.highlightPinned; - return hovered ? styles.highlightHovered : styles.highlightNone; -} - export function UsageWindowBar({ window, displayAs, @@ -54,9 +49,12 @@ export function UsageWindowBar({ window.runsOutAt != null && window.shortfallPct != null && (!retained || Date.parse(window.runsOutAt) > now); - const trailing = isAtRisk + const countdown = isAtRisk ? formatRunsOutLabel(window.runsOutAt, now, t) : formatResetLabel(window.resetsAt, retained, now, t); + // The exact time belongs to the reset; beside a runs-out warning it would read as the moment + // the quota runs out. + const exactTime = isAtRisk ? null : formatResetTime(window.resetsAt, now, t); const value = shownPct != null ? formatDisplayPct(shownPct, displayAs, i18n.resolvedLanguage, t) : "—"; @@ -64,60 +62,57 @@ export function UsageWindowBar({ const content = { label: formatProviderUsageLabel(window.id, window.label, t), value, - trailing, + countdown, + exactTime, isAtRisk, percent: shownPct ?? 0, tone, pinned, }; - // Same padding as the pinnable row, so bars line up in both modes. if (!pinnable) { return ( - + ); } - // The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same - // padding so toggling only changes the background. + // The whole row pins the window to the sidebar Usage item; the pin glyph is the only sign of it. return ( - {({ hovered }: { hovered?: boolean }) => ( - - )} + ); } function WindowRowContent({ - highlight, label, value, - trailing, + countdown, + exactTime, isAtRisk, percent, tone, pinnable, pinned, }: { - highlight: StyleProp; label: string; value: string; - trailing: string | null | undefined; + countdown: string | null; + exactTime: string | null; isAtRisk: boolean; percent: number; tone: UsageTone; @@ -126,24 +121,17 @@ function WindowRowContent({ }) { return ( <> - - - - - - {label} - - - {value} - {trailing ? ( - {` · ${trailing}`} - ) : null} - - - - + + + {label} + + + {value} + {pinnable ? : null} + + ); } @@ -153,9 +141,9 @@ const ThemedPin = withUnistyles(Pin); function UsagePinGlyph({ pinned }: { pinned: boolean }) { const { t } = useTranslation(); const iconMapping = useMemo( - () => (theme: { colors: { foregroundMuted: string } }) => ({ - color: theme.colors.foregroundMuted, - fill: pinned ? theme.colors.foregroundMuted : "none", + () => (theme: { colors: { foreground: string; foregroundMuted: string } }) => ({ + color: pinned ? theme.colors.foreground : theme.colors.foregroundMuted, + fill: pinned ? theme.colors.foreground : "none", }), [pinned], ); @@ -179,59 +167,23 @@ function UsagePinGlyph({ pinned }: { pinned: boolean }) { } const styles = StyleSheet.create((theme) => ({ - contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, - windowContent: { flex: 1, gap: 3 }, + row: { gap: 3, paddingVertical: theme.spacing[1.5] }, pin: { width: 12, alignItems: "center" }, tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm }, - row: { - gap: 3, - // The highlight bleeds into the card padding so the label and bar stay on the card's rail. - marginHorizontal: -theme.spacing[2], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1.5], - // Its own stacking context, so the highlight layer paints above the card and below the text. - zIndex: 0, - }, - highlightNone: { - display: "none", - }, - highlightPinned: { - ...StyleSheet.absoluteFill, - zIndex: -1, - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface2, - }, - // The pinned surface at half strength: a separate layer, so the text keeps full opacity. - highlightHovered: { - ...StyleSheet.absoluteFill, - zIndex: -1, - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface2, - opacity: theme.opacity[50], - }, labelRow: { flexDirection: "row", - justifyContent: "space-between", alignItems: "center", gap: theme.spacing[2], }, label: { flex: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - value: { - flexShrink: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, - reset: { - color: theme.colors.foregroundMuted, - fontWeight: theme.fontWeight.normal, - }, - atRisk: { - color: theme.colors.statusDanger, - fontWeight: theme.fontWeight.normal, + value: { + flexShrink: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, }, })); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index cb828f9e5a..c5913366d2 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -518,6 +518,7 @@ export const HOST_SECTION_SLUGS = [ "pair-device", "agents", "metadata", + "voice", "workspaces", "providers", "usage", diff --git a/packages/app/src/utils/terminal-renderer-readiness.test.ts b/packages/app/src/utils/terminal-renderer-readiness.test.ts index 45866f8df6..d7e5f71833 100644 --- a/packages/app/src/utils/terminal-renderer-readiness.test.ts +++ b/packages/app/src/utils/terminal-renderer-readiness.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { applyTerminalRendererReadyChange, resolveTerminalStreamTarget, - shouldReplayTerminalSnapshotForRenderer, shouldShowTerminalLoadingOverlay, } from "./terminal-renderer-readiness"; @@ -74,27 +73,6 @@ describe("terminal-renderer-readiness", () => { expect(current).toBeNull(); }); - it("replays snapshots only for ready events from the current renderer", () => { - expect( - shouldReplayTerminalSnapshotForRenderer({ - change: { streamKey: "scope:terminal-1", isReady: true }, - terminalStreamKey: "scope:terminal-1", - }), - ).toBe(true); - expect( - shouldReplayTerminalSnapshotForRenderer({ - change: { streamKey: "scope:terminal-1", isReady: false }, - terminalStreamKey: "scope:terminal-1", - }), - ).toBe(false); - expect( - shouldReplayTerminalSnapshotForRenderer({ - change: { streamKey: "scope:terminal-1", isReady: true }, - terminalStreamKey: "scope:terminal-2", - }), - ).toBe(false); - }); - it("does not attach the stream before the matching renderer is ready", () => { expect( resolveTerminalStreamTarget({ diff --git a/packages/app/src/utils/terminal-renderer-readiness.ts b/packages/app/src/utils/terminal-renderer-readiness.ts index 7d4f855aba..197a8811f9 100644 --- a/packages/app/src/utils/terminal-renderer-readiness.ts +++ b/packages/app/src/utils/terminal-renderer-readiness.ts @@ -14,13 +14,6 @@ export function applyTerminalRendererReadyChange( return currentReadyStreamKey === change.streamKey ? null : currentReadyStreamKey; } -export function shouldReplayTerminalSnapshotForRenderer(input: { - change: TerminalRendererReadyChange; - terminalStreamKey: string; -}): boolean { - return input.change.isReady && input.change.streamKey === input.terminalStreamKey; -} - export function resolveTerminalStreamTarget(input: { terminalId: string; terminalStreamKey: string; diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index 4dc82dda29..29382b5c89 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -76,6 +76,25 @@ describe("test-daemon-connection connectToDaemon", () => { probe = new FakeDaemonProbe(); }); + it.each([ + { id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" }, + { + id: "relay:test", + type: "relay" as const, + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pubkey", + }, + ])("adoptable $type probe clients use the app WebSocket factory", async (connection) => { + const { connectToDaemon } = await import("./test-daemon-connection"); + const { createAppWebSocketFactory } = await import("@/runtime/websocket-factory"); + const result = await connectToDaemon(connection, { serverId: "srv_probe_test" }, probe.deps); + try { + expect(result.client.config.webSocketFactory).toBe(createAppWebSocketFactory()); + } finally { + await result.client.close(); + } + }); + it("reuses the app clientId for direct connections", async () => { const { connectToDaemon } = await import("./test-daemon-connection"); const first = await connectToDaemon( @@ -129,6 +148,23 @@ describe("test-daemon-connection connectToDaemon", () => { expect(probe.createdConfigs()[0]?.transportFactory).toBeUndefined(); }); + it("keeps the bundle cache factory on a probe client that the host runtime can adopt", async () => { + const { connectToDaemon } = await import("./test-daemon-connection"); + const createPluginClientBundleStore = () => ({ + async load() { + return []; + }, + async replace() {}, + }); + const { client } = await connectToDaemon( + { id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }, + { createPluginClientBundleStore }, + probe.deps, + ); + expect(client.config.createPluginClientBundleStore).toBe(createPluginClientBundleStore); + await client.close(); + }); + it("encodes the local socket target into the client config", async () => { const { connectToDaemon } = await import("./test-daemon-connection"); const result = await connectToDaemon( diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 18ffa62dc4..1d1605af62 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -6,6 +6,7 @@ import { } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; import type { HostConnection } from "@/types/host-connection"; +import { createAppWebSocketFactory } from "@/runtime/websocket-factory"; import { getOrCreateClientId } from "./client-id"; import { resolveAppVersion } from "./app-version"; import { @@ -132,12 +133,13 @@ function resolveConnectionCredentials( export async function buildClientConfig( connection: HostConnection, serverId?: string, - options?: { + options: { capabilities?: DaemonClientConfig["capabilities"]; trace?: DaemonClientConfig["trace"]; + createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"]; password?: string; localCredential?: DaemonClientConfig["localCredential"]; - }, + } = {}, deps: Pick< DaemonConnectionDependencies, | "getClientId" @@ -155,10 +157,12 @@ export async function buildClientConfig( clientType: "mobile" as const, appVersion: deps.resolveAppVersion() ?? undefined, suppressSendErrors: true, + webSocketFactory: createAppWebSocketFactory(), reconnect: { enabled: false }, ...resolveConnectionCredentials(connection, options), - ...(options?.capabilities ? { capabilities: options.capabilities } : {}), - ...(options?.trace ? { trace: options.trace } : {}), + ...(options.capabilities ? { capabilities: options.capabilities } : {}), + ...(options.trace ? { trace: options.trace } : {}), + createPluginClientBundleStore: options.createPluginClientBundleStore, ...((connection.type === "directSocket" || connection.type === "directPipe") && desktopTransportFactory ? { transportFactory: desktopTransportFactory } @@ -288,6 +292,7 @@ interface ProbeOptions { localCredential?: DaemonClientConfig["localCredential"]; capabilities?: DaemonClientConfig["capabilities"]; trace?: DaemonClientConfig["trace"]; + createPluginClientBundleStore?: DaemonClientConfig["createPluginClientBundleStore"]; } function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number { diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts index 948ee8eeb3..1c063d1a27 100644 --- a/packages/app/src/utils/time.ts +++ b/packages/app/src/utils/time.ts @@ -125,6 +125,11 @@ function getTimeFormatter(): Intl.DateTimeFormat { return cachedTimeFormatter; } +/** A clock time in the device's 12h/24h preference: "3:00 PM" or "15:00". */ +export function formatTimeOfDay(date: Date): string { + return getTimeFormatter().format(date); +} + /** * Format a chat-message timestamp for hover-revealed UI. * - Same day: "10:11 PM" or "22:11" depending on user preference @@ -132,7 +137,7 @@ function getTimeFormatter(): Intl.DateTimeFormat { * - Older, including today's weekday last week: "14 May 2026, 10:11 PM" */ export function formatMessageTimestamp(date: Date, now: Date = new Date()): string { - const time = getTimeFormatter().format(date); + const time = formatTimeOfDay(date); const daysAgo = localCalendarDaysBetween(date, now); if (daysAgo === 0) { diff --git a/packages/app/src/utils/tool-call-detail-state.ts b/packages/app/src/utils/tool-call-detail-state.ts index e2d983d8cb..b28848af8e 100644 --- a/packages/app/src/utils/tool-call-detail-state.ts +++ b/packages/app/src/utils/tool-call-detail-state.ts @@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract, -): boolean { - return Boolean(detail.subAgentType || detail.description || detail.log); -} - function hasMeaningfulPlainTextDetail( detail: Extract, ): boolean { @@ -75,9 +69,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined): case "fetch": return hasMeaningfulFetchDetail(detail); case "worktree_setup": - return false; + // A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel. case "sub_agent": - return hasMeaningfulSubAgentDetail(detail); + return false; case "plain_text": return hasMeaningfulPlainTextDetail(detail); case "plan": @@ -92,7 +86,7 @@ export function isPendingToolCallDetail(params: { status: "executing" | "running" | "completed" | "failed" | "canceled"; error: unknown; }): boolean { - if (params.detail?.type === "worktree_setup") return false; + if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false; const isRunning = params.status === "running" || params.status === "executing"; return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail); } diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 59f96fccbc..cc5b8f6e04 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -1,6 +1,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, it } from "vitest"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabSnapshot, createWorkspaceAgentVisibilitySelector, @@ -63,6 +64,118 @@ function makeAgent(input: { const WORKSPACE_ID = "ws-1"; describe("workspace agent visibility", () => { + it("removes a restored pinned tab from an unrelated workspace once its owner is known", async () => { + const values = new Map(); + const storage = { + getItem: async (key: string) => values.get(key) ?? null, + setItem: async (key: string, value: string) => { + values.set(key, value); + }, + removeItem: async (key: string) => { + values.delete(key); + }, + }; + const original = createWorkspaceLayoutStore(undefined, storage); + await original.persist.rehydrate(); + const agent = makeAgent({ id: "restored-agent", cwd: "/project-a", workspaceId: "ws-a" }); + for (const workspaceId of ["ws-a", "ws-b"]) { + original.getState().openTab({ + workspaceKey: `srv:${workspaceId}`, + target: { kind: "agent", agentId: agent.id }, + intent: "reveal", + pin: true, + }); + } + const restored = createWorkspaceLayoutStore(undefined, storage); + await restored.persist.rehydrate(); + const sessionAgents = new Map([[agent.id, agent]]); + const beforeHydration = buildWorkspaceTabSnapshot({ + agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-b" }), + agentsHydrated: false, + terminalsHydrated: true, + knownTerminalIds: [], + standaloneTerminalIds: [], + hasActivePendingTerminalCreate: false, + hasActivePendingDraftCreate: false, + }); + restored.getState().reconcileTabs("srv:ws-b", beforeHydration); + expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toEqual(new Set([agent.id])); + for (const workspaceId of ["ws-a", "ws-b"]) { + restored.getState().reconcileTabs( + `srv:${workspaceId}`, + buildWorkspaceTabSnapshot({ + agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId }), + agentsHydrated: true, + terminalsHydrated: true, + knownTerminalIds: [], + standaloneTerminalIds: [], + hasActivePendingTerminalCreate: false, + hasActivePendingDraftCreate: false, + }), + ); + } + + expect( + restored + .getState() + .getWorkspaceTabs("srv:ws-a") + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.target), + ).toEqual([{ kind: "agent", agentId: agent.id }]); + expect( + restored + .getState() + .getWorkspaceTabs("srv:ws-b") + .filter((tab) => tab.target.kind === "agent"), + ).toEqual([]); + expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined(); + }); + + it("uses directory ownership before historical details when identifying foreign agents", () => { + const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID }); + const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" }); + const unknown = makeAgent({ id: "unknown", cwd: "/repo" }); + const result = deriveWorkspaceAgentVisibility({ + workspaceId: WORKSPACE_ID, + sessionAgents: new Map([ + [own.id, own], + [foreign.id, foreign], + [unknown.id, unknown], + ]), + agentDetails: new Map([ + [own.id, { ...own, workspaceId: "ws-2" }], + [foreign.id, { ...foreign, workspaceId: WORKSPACE_ID }], + [unknown.id, { ...unknown, workspaceId: "ws-2" }], + ]), + }); + + expect(result.activeAgentIds).toEqual(new Set([own.id])); + expect(result.foreignAgentIds).toEqual(new Set([foreign.id])); + }); + + it("recognizes foreign historical ownership without making archived or unknown agents foreign", () => { + const archived = makeAgent({ + id: "archived", + cwd: "/repo", + workspaceId: WORKSPACE_ID, + archivedAt: new Date("2026-03-04T00:01:00.000Z"), + }); + const foreign = { ...archived, id: "foreign", workspaceId: "ws-2" }; + const unknown = { ...archived, id: "unknown", workspaceId: undefined }; + const result = deriveWorkspaceAgentVisibility({ + workspaceId: WORKSPACE_ID, + sessionAgents: new Map(), + agentDetails: new Map([ + [archived.id, archived], + [foreign.id, foreign], + [unknown.id, unknown], + ]), + }); + + expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set([foreign.id])); + }); + it("keeps subagents active while excluding them from auto-open", () => { const parent = makeAgent({ id: "parent-agent", @@ -86,6 +199,7 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); + expect(result.foreignAgentIds).toEqual(new Set()); }); it("excludes archived subagents from active and auto-open", () => { @@ -295,6 +409,7 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"])); }); it("excludes agents without a workspaceId", () => { @@ -308,12 +423,14 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); + expect(result.foreignAgentIds).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"]), + foreignAgentIds: new Set(["foreign-agent"]), }; expect( @@ -331,6 +448,7 @@ describe("workspace agent visibility", () => { terminalsHydrated: true, activeAgentIds: agentVisibility.activeAgentIds, autoOpenAgentIds: agentVisibility.autoOpenAgentIds, + foreignAgentIds: agentVisibility.foreignAgentIds, knownTerminalIds: ["terminal-1", "script-terminal"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: false, @@ -469,6 +587,24 @@ describe("workspace agent visibility", () => { expect(after.activeAgentIds).toEqual(new Set([child.id])); expect(after.autoOpenAgentIds).toEqual(new Set([child.id])); }); + + it("recomputes when only a previously unknown agent's foreign ownership becomes known", () => { + seedSession(new Map([[parent.id, parent]])); + const select = createWorkspaceAgentVisibilitySelector({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + const before = select(useSessionStore.getState()); + useSessionStore + .getState() + .setAgentDetails(SERVER_ID, new Map([[otherWorkspace.id, otherWorkspace]])); + const after = select(useSessionStore.getState()); + + expect(after).not.toBe(before); + expect(after.activeAgentIds).toEqual(before.activeAgentIds); + expect(after.autoOpenAgentIds).toEqual(before.autoOpenAgentIds); + expect(after.foreignAgentIds).toEqual(new Set([otherWorkspace.id])); + }); }); describe("workspaceAgentVisibilityEqual", () => { @@ -476,10 +612,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(["c"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(["c"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); @@ -488,10 +626,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(["b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -500,10 +640,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["b"]), + foreignAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -512,10 +654,12 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; const b = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: 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 3dd1881643..99e127c872 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -6,10 +6,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; export interface WorkspaceAgentVisibility { activeAgentIds: Set; autoOpenAgentIds: Set; -} - -function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean { - return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId; + foreignAgentIds: Set; } export function deriveWorkspaceAgentVisibility(input: { @@ -19,17 +16,23 @@ export function deriveWorkspaceAgentVisibility(input: { }): WorkspaceAgentVisibility { const { sessionAgents, agentDetails } = input; const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); - if ((!sessionAgents && !agentDetails) || !workspaceId) { + if (!workspaceId) { return { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), + foreignAgentIds: new Set(), }; } const activeAgentIds = new Set(); const autoOpenAgentIds = new Set(); + const foreignAgentIds = new Set(); for (const agent of sessionAgents?.values() ?? []) { - if (!agentBelongsToWorkspace(agent, workspaceId)) { + const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId); + if (ownerId !== workspaceId) { + if (ownerId) { + foreignAgentIds.add(agent.id); + } continue; } if (!agent.archivedAt) { @@ -42,7 +45,16 @@ export function deriveWorkspaceAgentVisibility(input: { } } } - return { activeAgentIds, autoOpenAgentIds }; + for (const agent of agentDetails?.values() ?? []) { + if (sessionAgents?.has(agent.id)) { + continue; + } + const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId); + if (ownerId && ownerId !== workspaceId) { + foreignAgentIds.add(agent.id); + } + } + return { activeAgentIds, autoOpenAgentIds, foreignAgentIds }; } interface AgentMapsSnapshot { @@ -99,6 +111,7 @@ export function buildWorkspaceTabSnapshot(input: { terminalsHydrated: input.terminalsHydrated, activeAgentIds: input.agentVisibility.activeAgentIds, autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds, + foreignAgentIds: input.agentVisibility.foreignAgentIds, knownTerminalIds: input.knownTerminalIds, standaloneTerminalIds: input.standaloneTerminalIds, hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate, @@ -113,7 +126,8 @@ export function workspaceAgentVisibilityEqual( return ( a === b || (setsEqual(a.activeAgentIds, b.activeAgentIds) && - setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds)) + setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) && + setsEqual(a.foreignAgentIds, b.foreignAgentIds)) ); } diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index 2029808f31..c55208fc95 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -25,7 +25,6 @@ import { type PanelIconProps, type PanelPresentation, } from "@/panels/panel-registry"; -import { ensurePanelsRegistered } from "@/panels/register-panels"; import { getTerminalProfileIcon, resolveTerminalProfiles, @@ -109,7 +108,6 @@ export function useWorkspaceTabLaunchCatalog(input: { const { config } = useDaemonConfig(serverId); const plugins = useInstalledPlugins(); const isCompact = useIsCompactFormFactor(); - ensurePanelsRegistered(); const launchSelection = useCallback( (selection: NewTabSelection) => (destination: WorkspaceTabLaunchDestination) => { diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts index abf6eba2ec..845c2c7f78 100644 --- a/packages/app/src/workspace-tabs/open-beside.ts +++ b/packages/app/src/workspace-tabs/open-beside.ts @@ -6,6 +6,7 @@ import { DEFAULT_PANE_ID, findPaneById, findPaneContainingTab, + selectSheetTab, useWorkspaceLayoutStore, type WorkspaceTabPlacement, } from "@/stores/workspace-layout-store"; @@ -144,7 +145,7 @@ export function openPreferredWorkspacePreview( // The sheet is already a single replaceable slot, so it needs no preview tab. if ( existing || - store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" }) + selectSheetTab(store, { workspaceKey: input.workspaceKey, target: input.target }) !== null ) { return openPreferredWorkspaceTarget(input); } diff --git a/packages/app/src/workspace/desktop-open-targets.ts b/packages/app/src/workspace/desktop-open-targets.ts index 54730c2dfb..5f4d03e869 100644 --- a/packages/app/src/workspace/desktop-open-targets.ts +++ b/packages/app/src/workspace/desktop-open-targets.ts @@ -56,12 +56,14 @@ export function hasDesktopOpenTargetsBridge(): boolean { return getDesktopEditorBridge() !== null; } -export async function listDesktopOpenTargets(): Promise { +export async function listDesktopOpenTargets( + input: { workspacePath?: string } = {}, +): Promise { const bridge = getDesktopEditorBridge(); if (!bridge) { return []; } - return await bridge.listTargets(); + return await bridge.listTargets(input); } export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise { @@ -72,15 +74,24 @@ export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise< await bridge.openTarget(input); } -export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) { +/** + * Pass `workspacePath` whenever it is known. Project-specific targets such as + * Xcode are only listed for workspaces they can actually open, so callers that + * omit it get the workspace-agnostic targets only. + */ +export function useDesktopOpenTargets(input: { + isLocalExecution: boolean; + workspacePath?: string; +}) { const hasBridge = hasDesktopOpenTargetsBridge(); const canListTargets = hasBridge && input.isLocalExecution; + const workspacePath = input.workspacePath?.trim() ?? ""; const query = useQuery({ - queryKey: ["desktop-open-targets"], + queryKey: ["desktop-open-targets", workspacePath], enabled: canListTargets, staleTime: 60_000, retry: false, - queryFn: listDesktopOpenTargets, + queryFn: () => listDesktopOpenTargets(workspacePath ? { workspacePath } : {}), }); const targets = selectDesktopOpenTargets({ canListTargets, diff --git a/packages/app/src/workspace/open-in-editor/button.tsx b/packages/app/src/workspace/open-in-editor/button.tsx index 384ffe72c6..fbcef49b83 100644 --- a/packages/app/src/workspace/open-in-editor/button.tsx +++ b/packages/app/src/workspace/open-in-editor/button.tsx @@ -33,6 +33,7 @@ import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; interface WorkspaceOpenInEditorButtonProps { serverId: string; cwd: string; + projectId: string | null; activeFile?: WorkspaceFileLocation | null; hideLabels?: boolean; } @@ -83,6 +84,7 @@ function OpenTargetMenuItem({ target, isPreferred, onSelect }: OpenTargetMenuIte export function WorkspaceOpenInEditorButton({ serverId, cwd, + projectId, activeFile, hideLabels, }: WorkspaceOpenInEditorButtonProps) { @@ -90,10 +92,11 @@ export function WorkspaceOpenInEditorButton({ const toast = useToast(); const isConnected = useHostRuntimeIsConnected(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); - const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); + const { preferredEditorId, updatePreferredEditor } = usePreferredEditor({ serverId, projectId }); const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } = useDesktopOpenTargets({ isLocalExecution: isLocalDaemon, + workspacePath: cwd, }); const resolvedFile = useMemo( @@ -185,9 +188,11 @@ export function WorkspaceOpenInEditorButton({ const handleSelectTarget = useCallback( (target: OpenTarget) => { - void updatePreferredEditor(target.id).catch(() => undefined); + void updatePreferredEditor(target.id).catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : t("common.errors.unableToSave")); + }); }, - [updatePreferredEditor], + [updatePreferredEditor, toast, t], ); const primaryPressableStyle = useCallback( diff --git a/packages/app/src/workspace/open-in-editor/directory.ts b/packages/app/src/workspace/open-in-editor/directory.ts index e4fcca74d7..bf90a29e36 100644 --- a/packages/app/src/workspace/open-in-editor/directory.ts +++ b/packages/app/src/workspace/open-in-editor/directory.ts @@ -9,6 +9,7 @@ import { planWorkspaceOpenTargets } from "@/workspace/open-in-editor/planner"; interface UseOpenDirectoryInEditorInput { serverId: string; workspaceDirectory: string; + projectId: string | null; } interface OpenDirectoryInEditorAction { @@ -19,11 +20,12 @@ interface OpenDirectoryInEditorAction { export function useOpenDirectoryInEditor({ serverId, workspaceDirectory, + projectId, }: UseOpenDirectoryInEditorInput): OpenDirectoryInEditorAction | null { const { t } = useTranslation(); const toast = useToast(); const isLocalExecution = useIsLocalDaemon(serverId); - const { preferredEditorId } = usePreferredEditor(); + const { preferredEditorId } = usePreferredEditor({ serverId, projectId }); const { targets, isAvailable } = useDesktopOpenTargets({ isLocalExecution }); const editorTargets = useMemo( () => targets.filter((target) => target.kind === "editor"), diff --git a/packages/app/src/workspace/open-in-file-manager/outside-folder.ts b/packages/app/src/workspace/open-in-file-manager/outside-folder.ts new file mode 100644 index 0000000000..e7d41d72d9 --- /dev/null +++ b/packages/app/src/workspace/open-in-file-manager/outside-folder.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/contexts/toast-context"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; +import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; + +/** + * Opens a folder outside the workspace in the system file manager. The Files + * panel only lists the workspace, so hosts without a local file manager get a + * toast instead. + */ +export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void { + const { t } = useTranslation(); + const toast = useToast(); + const isLocalExecution = useIsLocalDaemon(serverId); + const { targets } = useDesktopOpenTargets({ isLocalExecution }); + const fileManagerTarget = targets.find((target) => target.kind === "file-manager"); + + return useCallback( + (folderPath: string) => { + const showOutsideWorkspace = () => + toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath })); + if (!fileManagerTarget) { + showOutsideWorkspace(); + return; + } + void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch( + (error: unknown) => { + console.warn("[open-outside-workspace-folder] open failed", error); + showOutsideWorkspace(); + }, + ); + }, + [fileManagerTarget, t, toast], + ); +} diff --git a/packages/app/test-stubs/expo-clipboard.ts b/packages/app/test-stubs/expo-clipboard.ts new file mode 100644 index 0000000000..9663320efb --- /dev/null +++ b/packages/app/test-stubs/expo-clipboard.ts @@ -0,0 +1,10 @@ +// The package ships JSX in .js files, which the dependency optimizer cannot parse. +export async function setStringAsync(): Promise { + return true; +} +export async function getStringAsync(): Promise { + return ""; +} +export async function hasStringAsync(): Promise { + return false; +} diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index ee27421efd..7fc1d4bc4f 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -22,6 +22,10 @@ export function useBottomSheetModal(): never { export function useBottomSheet(): never { throw new Error("useBottomSheet is unavailable in tests"); } +// A drag area only hands its gesture to a sheet; with no sheet drawn there is nothing to drag. +export function useBottomSheetGestureHandlers(): never { + throw new Error("useBottomSheetGestureHandlers is unavailable in tests"); +} export const BottomSheetModalInternalProvider = PassThrough; export const INITIAL_CONTAINER_LAYOUT = { height: 0, diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index 200048ad53..a85976e1bf 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -111,3 +111,8 @@ export const Upload = StubIcon; export const Wrench = StubIcon; export const X = StubIcon; export const XCircle = StubIcon; +export const Code = StubIcon; +export const Maximize2 = StubIcon; +export const Workflow = StubIcon; +export const ZoomIn = StubIcon; +export const ZoomOut = StubIcon; diff --git a/packages/app/test-stubs/react-native-safe-area-context.ts b/packages/app/test-stubs/react-native-safe-area-context.ts index ae793cd921..40c064f943 100644 --- a/packages/app/test-stubs/react-native-safe-area-context.ts +++ b/packages/app/test-stubs/react-native-safe-area-context.ts @@ -1,8 +1,18 @@ -// The package ships untranspiled Flow, which the unit project cannot parse. Insets are a device -// fact no test asserts on, so they are zero. +import { createContext, useContext } from "react"; + +// The package ships untranspiled Flow, which the unit project cannot parse. Like the real package, +// the hook reads the insets context; outside a provider the insets are zero. const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 } as const; -export const useSafeAreaInsets = () => ZERO_INSETS; +export interface EdgeInsets { + top: number; + right: number; + bottom: number; + left: number; +} + +export const SafeAreaInsetsContext = createContext(ZERO_INSETS); +export const useSafeAreaInsets = () => useContext(SafeAreaInsetsContext) ?? ZERO_INSETS; export const useSafeAreaFrame = () => ({ x: 0, y: 0, width: 0, height: 0 }); export const SafeAreaProvider = ({ children }: { children?: unknown }) => children; export const SafeAreaView = ({ children }: { children?: unknown }) => children; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 2d7149442e..ba4c0fc979 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -19,6 +19,22 @@ const resolvePackageEntry = (packageName: string) => { export default defineConfig({ plugins: [ + { + name: "markdown-wasm-commonjs-export", + transform(source, id) { + // The ESM package contains a CommonJS WASM factory. Metro handles the + // interop; Vite needs an ESM export instead of the CommonJS trailer. + // Vite can request the file with a version query (`md4c.js?v=`), so match the + // path without it. + if (!id.split("?")[0].endsWith("/lib/module/web/wasm/md4c.js")) return; + const trailer = source.lastIndexOf("\nif (typeof exports === 'object'"); + if (trailer < 0) throw new Error("Markdown WASM CommonJS trailer was not found"); + return { + code: `${source.slice(0, trailer)}\nexport default createMd4cModule;`, + map: null, + }; + }, + }, { name: "compile-usage-components", enforce: "pre", @@ -66,6 +82,7 @@ export default defineConfig({ environment: "node", include: [ "src/**/*.{test,spec}.{ts,tsx}", + "modules/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts", "app.config.test.ts", "vitest-environment.test.ts", @@ -82,7 +99,10 @@ export default defineConfig({ include: ["src/**/*.browser.{test,spec}.{ts,tsx}"], browser: { enabled: true, - provider: playwright(), + provider: playwright({ + // Exercise real WebGL glyph rendering on GPU-less CI runners. + launchOptions: { args: ["--enable-unsafe-swiftshader"] }, + }), headless: true, connectTimeout: 180_000, instances: [{ browser: "chromium" }], @@ -147,15 +167,19 @@ export default defineConfig({ "i18next", "@marijn/find-cluster-break", "tiny-invariant", + "react/jsx-dev-runtime", "react-native-gesture-handler > hoist-non-react-statics", "react-native-gesture-handler > invariant", ], exclude: [ + "react-native-enriched-markdown", "react-native-reanimated", "react-native-gesture-handler", "react-native-keyboard-controller", ], }, + // babel-preset-expo compiles JSX with the automatic runtime, so app modules don't import React. + esbuild: { jsx: "automatic" }, // The globals a React Native bundler defines, which esbuild is no longer there to supply for // the package excluded above. define: { @@ -254,6 +278,10 @@ export default defineConfig({ find: /^react-native-reanimated\/scripts\/validate-worklets-version$/, replacement: path.resolve(__dirname, "test-stubs/reanimated-validate-worklets-version.ts"), }, + { + find: /^expo-clipboard$/, + replacement: path.resolve(__dirname, "test-stubs/expo-clipboard.ts"), + }, { find: /^expo-linking$/, replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"), diff --git a/packages/cli/package.json b/packages/cli/package.json index dd4ee4686d..6c7fa3155d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.13.0", + "version": "0.15.0-beta.1", "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.13.0", - "@getpaseo/protocol": "0.13.0", - "@getpaseo/server": "0.13.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", + "@getpaseo/server": "0.15.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index ef88e39a23..97e2fbf604 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -3,8 +3,11 @@ import { readDaemonInstance, isSameDaemonInstance, DaemonInstanceError, + retryUnconfirmedTerminalCleanup, + TerminalCleanupNotConfirmedError, } from "@getpaseo/server/daemon-control"; import { setTimeout as delay } from "node:timers/promises"; +import type { DaemonGetStatusResponse } from "@getpaseo/protocol/messages"; import { connectToDaemon } from "../../utils/client.js"; import { withOutput, type CommandOptions } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; @@ -20,13 +23,30 @@ export function daemonRestartCommand(): Command { ), ) .option("--timeout ", "Replacement readiness deadline (default: 600)") + .option("--kill-terminals", "Close every terminal instead of keeping them across the restart") .action(withOutput(runRestartCommand)); } +type DaemonStatus = DaemonGetStatusResponse["payload"]; + 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 killTerminals = options.killTerminals === true; + // The supervisor reaps before the respawn and the replacement retries what that left; + // anything still named by a closing record survived both. A local caller retries once more + // itself; a remote one has only what the replacement reports. + const confirmTerminalsClosed = async (status: DaemonStatus) => { + if (!killTerminals) return; + if (target.kind === "instance") { + await retryUnconfirmedTerminalCleanup(target.home); + return; + } + // Absent on daemons without terminalsSurviveRestart, which have no terminal cleanup. + const unconfirmed = status.terminalCleanupUnconfirmed ?? []; + if (unconfirmed.length > 0) throw new TerminalCleanupNotConfirmedError(unconfirmed); + }; const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null; async function checkSupervisor() { if (target.kind !== "instance") return; @@ -54,7 +74,10 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid; await checkSupervisor(); try { - await client.restartServer("cli_restart", undefined, { timeout: remaining() }); + await client.restartServer("cli_restart", undefined, { + timeout: remaining(), + killTerminals, + }); acknowledged = true; } catch (error) { if (!isReconnectFailure(error)) throw error; @@ -77,6 +100,7 @@ export async function runRestartCommand(options: CommandOptions, _command: Comma const status = await replacement.getDaemonStatus({ timeout: remaining() }); if (status.pid !== workerPid) { await checkSupervisor(); + await confirmTerminalsClosed(status); return { type: "single" as const, data: { diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 14c20534e6..cd499ea6cb 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -103,6 +103,7 @@ async function probeDaemonStatus( daemonNode: status.nodePath, providers: status.providers, relay: status.relay, + ...(status.terminalsUnavailable ? { terminals: status.terminalsUnavailable } : {}), }; } } finally { diff --git a/packages/cli/src/commands/daemon/stop.test.ts b/packages/cli/src/commands/daemon/stop.test.ts new file mode 100644 index 0000000000..7ee5f930f9 --- /dev/null +++ b/packages/cli/src/commands/daemon/stop.test.ts @@ -0,0 +1,110 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { hostname, tmpdir } from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, describe, expect, test } from "vitest"; +import { createStopCommandRunner, type StopCommandDeps } from "./stop.js"; + +// The daemon the gate is checked against: a connection whose server_info may or may not +// advertise terminalsSurviveRestart, and that records any shutdown it is asked for. +function createFakeDaemon(features: { terminalsSurviveRestart?: boolean } = {}) { + const shutdownRequests: Array<{ keepTerminals?: boolean }> = []; + const deps: StopCommandDeps = { + connectToDaemon: async () => ({ + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "fake-daemon", + version: "0.11.0", + features, + }), + shutdownServer: async (options) => { + shutdownRequests.push({ keepTerminals: options?.keepTerminals }); + return { status: "shutdown_requested", clientId: "fake-client", requestId: "fake-request" }; + }, + close: async () => undefined, + }), + }; + return { deps, shutdownRequests }; +} + +const cleanups: Array<() => void> = []; + +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function stop(options: Record, deps: StopCommandDeps) { + return createStopCommandRunner(deps)(options as never, new Command("stop")); +} + +// A home whose lock names a live stand-in supervisor, so the local stop path runs for real. +function homeWithRunningSupervisor(): { home: string; supervisor: ChildProcess } { + const home = mkdtempSync(path.join(tmpdir(), "paseo-stop-keep-")); + const supervisor = spawn(process.execPath, ["-e", "setInterval(() => {}, 1e9)"], { + stdio: "ignore", + }); + writeFileSync( + path.join(home, "paseo.pid"), + JSON.stringify({ + pid: supervisor.pid, + startedAt: new Date().toISOString(), + hostname: hostname(), + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:1", + }), + ); + cleanups.push(() => { + supervisor.kill("SIGKILL"); + rmSync(home, { recursive: true, force: true }); + }); + return { home, supervisor }; +} + +describe("daemon stop --keep-terminals", () => { + test("refuses an endpoint daemon that does not advertise terminalsSurviveRestart", async () => { + const daemon = createFakeDaemon(); + await expect( + stop( + { daemonTarget: { kind: "endpoint", host: "127.0.0.1:7777" }, keepTerminals: true }, + daemon.deps, + ), + ).rejects.toMatchObject({ code: "TERMINALS_KEEP_UNSUPPORTED" }); + expect(daemon.shutdownRequests).toEqual([]); + }); + + test.skipIf(process.platform === "win32")( + "refuses a local daemon without the capability and never signals its supervisor", + async () => { + const daemon = createFakeDaemon(); + const { home, supervisor } = homeWithRunningSupervisor(); + await expect( + stop({ daemonTarget: { kind: "instance", home }, keepTerminals: true }, daemon.deps), + ).rejects.toMatchObject({ code: "TERMINALS_KEEP_UNSUPPORTED" }); + expect(daemon.shutdownRequests).toEqual([]); + expect(supervisor.exitCode).toBeNull(); + expect(supervisor.signalCode).toBeNull(); + }, + ); + + test("asks a capable daemon to keep its terminals", async () => { + const daemon = createFakeDaemon({ terminalsSurviveRestart: true }); + await stop( + { daemonTarget: { kind: "endpoint", host: "127.0.0.1:7777" }, keepTerminals: true }, + daemon.deps, + ); + expect(daemon.shutdownRequests).toEqual([{ keepTerminals: true }]); + }); + + test("rejects --force with --keep-terminals before contacting the daemon", async () => { + const daemon = createFakeDaemon({ terminalsSurviveRestart: true }); + const { home } = homeWithRunningSupervisor(); + await expect( + stop( + { daemonTarget: { kind: "instance", home }, keepTerminals: true, force: true }, + daemon.deps, + ), + ).rejects.toMatchObject({ code: "INVALID_OPTIONS" }); + expect(daemon.shutdownRequests).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/daemon/stop.ts b/packages/cli/src/commands/daemon/stop.ts index f2bf8b1255..141d50965e 100644 --- a/packages/cli/src/commands/daemon/stop.ts +++ b/packages/cli/src/commands/daemon/stop.ts @@ -1,6 +1,10 @@ import { Command } from "commander"; +import { + requireTerminalKeepCapability, + type DaemonClient, +} from "@getpaseo/client/internal/daemon-client"; import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server/daemon-control"; -import { connectToDaemon } from "../../utils/client.js"; +import { connectToDaemon, type ConnectOptions } 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"; @@ -10,19 +14,43 @@ 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( + "--keep-terminals", + "Leave terminals running; the next daemon started for this home takes them back", + ) .option("--kill-timeout ", "Forced exit deadline (default: 3)") .action(withOutput(runStopCommand)); } -export async function runStopCommand(options: CommandOptions, _command: Command) { +// The daemon connection stop needs; stop.test.ts passes an in-memory adapter. +export type StopDaemonConnection = Pick< + DaemonClient, + "getLastServerInfoMessage" | "shutdownServer" | "close" +>; + +export interface StopCommandDeps { + connectToDaemon(options: ConnectOptions): Promise; +} + +export const runStopCommand = createStopCommandRunner({ connectToDaemon }); + +export function createStopCommandRunner(deps: StopCommandDeps) { + return (options: CommandOptions, _command: Command) => runStop(options, deps); +} + +async function runStop(options: CommandOptions, deps: StopCommandDeps) { 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 keepTerminals = options.keepTerminals === true; const requestShutdown = async (instance?: DaemonInstance) => { - const client = await connectToDaemon({ target, timeout: remaining(), instance }); + const client = await deps.connectToDaemon({ target, timeout: remaining(), instance }); try { - await client.shutdownServer({ timeout: remaining() }); + // Checked on the connection that carries the shutdown: a daemon without the capability + // would close the terminals it was asked to keep. + if (keepTerminals) requireTerminalKeepCapability(client, describeDaemonTarget(target)); + await client.shutdownServer({ timeout: remaining(), keepTerminals }); } finally { await client.close(); } @@ -32,6 +60,11 @@ export async function runStopCommand(options: CommandOptions, _command: Command) code: "INVALID_OPTIONS", message: "--force requires a local --home; an endpoint gives no remote process authority.", }; + if (options.force && keepTerminals) + throw { + code: "INVALID_OPTIONS", + message: "--force kills terminals; it cannot be combined with --keep-terminals.", + }; const result: { action: string; home?: string; @@ -44,6 +77,7 @@ export async function runStopCommand(options: CommandOptions, _command: Command) ? { ...(await stopDaemonInstance(target.home, { force: options.force === true, + keepTerminals, timeoutMs, killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000), requestShutdown, diff --git a/packages/cli/tests/42-test-daemon-ephemeral-port.test.ts b/packages/cli/tests/42-test-daemon-ephemeral-port.test.ts new file mode 100644 index 0000000000..f559c275bc --- /dev/null +++ b/packages/cli/tests/42-test-daemon-ephemeral-port.test.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert/strict"; +import { startTestDaemon, runPaseoCli } from "./helpers/test-daemon.ts"; + +// Port zero leaves allocation to the listening daemon, with no reserve/release gap. +const daemon = await startTestDaemon({ port: 0 }); +try { + assert(daemon.port > 0, "the helper returns the port the daemon actually bound"); + assert.equal(daemon.wsUrl, `ws://127.0.0.1:${daemon.port}`); + const result = await runPaseoCli(daemon, ["agent", "ls", "--host", daemon.wsUrl, "--json"]); + assert.equal(result.exitCode, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), []); +} finally { + await daemon.stop(); +} diff --git a/packages/cli/tests/45-daemon-terminals-survive.test.ts b/packages/cli/tests/45-daemon-terminals-survive.test.ts new file mode 100644 index 0000000000..7804ea6917 --- /dev/null +++ b/packages/cli/tests/45-daemon-terminals-survive.test.ts @@ -0,0 +1,324 @@ +#!/usr/bin/env npx tsx + +/** + * Terminals survive `paseo daemon restart` and `stop --keep-terminals`; `restart + * --kill-terminals`, `stop` and `stop --force` close them. Runs the built CLI and daemon in a + * throwaway home; every process it starts is stopped in `finally`. + */ + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readDaemonInstance } from "@getpaseo/server/daemon-control"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; +import { getAvailablePort } from "./helpers/network.ts"; + +if (process.platform === "win32") { + console.log("Skipping terminal survival CLI test on Windows (POSIX shell terminals)."); + process.exit(0); +} + +console.log("=== Daemon terminals survive restart ===\n"); + +const paseoHome = await mkdtemp(join(tmpdir(), "paseo-terminals-survive-")); +const workDir = join(paseoHome, "work"); +await mkdir(workDir, { recursive: true }); +const port = await getAvailablePort(); +const env = { + PASEO_HOME: paseoHome, + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + // The daemon inherits this; terminals then run a plain POSIX shell with no startup files. + SHELL: "/bin/sh", +}; + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor( + probe: () => Promise | T | null | false, + message: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await probe(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(message); +} + +async function paseo(args: string[], expectSuccess = true) { + const result = await runLocalPaseo(args, env); + if (expectSuccess) { + assert.equal( + result.exitCode, + 0, + `paseo ${args.join(" ")} failed:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + return result; +} + +async function json(args: string[]): Promise { + return JSON.parse((await paseo([...args, "--json"])).stdout) as T; +} + +async function capture(terminalId: string): Promise { + const { lines } = await json<{ lines: string[] }>(["terminal", "capture", terminalId]); + return lines.join("\n"); +} + +async function waitForScreen(terminalId: string, text: string): Promise { + await waitFor( + async () => (await capture(terminalId)).includes(text), + `terminal ${terminalId} never showed ${text}:\n${await capture(terminalId)}`, + ); +} + +async function terminalIds(): Promise { + return (await json>(["terminal", "ls", "--all"])).map((t) => t.id); +} + +// Worker records and the cleanup records a kill that could not finish leaves beside them. +function workerRecordCount(): number { + const dir = join(paseoHome, "terminal-worker"); + return existsSync(dir) + ? readdirSync(dir).filter((name) => /^(\d+|closing-.+)\.json$/.test(name)).length + : 0; +} + +// A shell terminal that reports its pid and prints a marker. +async function openTerminal(name: string): Promise<{ id: string; pid: number }> { + const terminal = await json<{ id: string }>([ + "terminal", + "create", + "--cwd", + workDir, + "--name", + name, + ]); + const pidFile = join(workDir, `${name}.pid`); + await paseo([ + "terminal", + "send-keys", + terminal.id, + "-l", + `echo $$ > '${pidFile}'; echo MARKER-${name}`, + ]); + await paseo(["terminal", "send-keys", terminal.id, "Enter"]); + const pid = await waitFor( + () => + existsSync(pidFile) && readFileSync(pidFile, "utf8").trim() + ? Number(readFileSync(pidFile, "utf8")) + : null, + `terminal ${name} never wrote its pid`, + ); + await waitForScreen(terminal.id, `MARKER-${name}`); + return { id: terminal.id, pid }; +} + +async function expectKept(terminal: { id: string; pid: number }, name: string, trigger: string) { + assert((await terminalIds()).includes(terminal.id), `${trigger}: terminal is still listed`); + assert(isAlive(terminal.pid), `${trigger}: terminal program is still alive`); + assert((await capture(terminal.id)).includes(`MARKER-${name}`), `${trigger}: marker is kept`); + await paseo(["terminal", "send-keys", terminal.id, "-l", `echo after-${trigger}`]); + await paseo(["terminal", "send-keys", terminal.id, "Enter"]); + await waitForScreen(terminal.id, `\nafter-${trigger}`); +} + +async function expectClosed(pid: number, trigger: string) { + await waitFor(() => !isAlive(pid), `${trigger}: terminal program ${pid} is still alive`, 15_000); +} + +// A zombie: a real process with a birth time that SIGKILL cannot remove while its parent (an +// exec'd `sleep` that never waits) lives. Cleanup can only report it as still alive. +async function startZombie(): Promise<{ zombiePid: number; parentPid: number }> { + const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], { + stdio: ["ignore", "pipe", "ignore"], + }); + const zombiePid = await new Promise((resolve) => { + parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim()))); + }); + parent.unref(); + await waitFor( + async () => (await psField(zombiePid, "stat")).startsWith("Z"), + `process ${zombiePid} never became a zombie`, + ); + return { zombiePid, parentPid: parent.pid! }; +} + +async function psField(pid: number, field: "stat" | "lstart"): Promise { + const child = spawn("ps", ["-o", `${field}=`, "-p", String(pid)], { + env: { ...process.env, LC_ALL: "C" }, + stdio: ["ignore", "pipe", "ignore"], + }); + let out = ""; + child.stdout.on("data", (chunk: Buffer) => (out += chunk.toString())); + await new Promise((resolve) => child.once("close", resolve)); + return out.trim(); +} + +async function goneProcessPid(): Promise { + const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" }); + await new Promise((resolve) => child.once("exit", resolve)); + return child.pid!; +} + +// A record as a dead terminal worker leaves it, naming one terminal program that is a zombie. +async function plantRecordNaming(programPid: number): Promise { + const workerPid = await goneProcessPid(); + const record = { + recordId: "planted-record", + pid: workerPid, + bootId: null, + createdAt: new Date().toISOString(), + identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "planted-worker" }, + protocolVersion: 1, + paseoVersion: "0.0.0-test", + socketPath: join(paseoHome, "planted-worker.sock"), + secret: "planted-secret", + terminals: [ + { id: "planted-terminal", pid: programPid, startedAt: await psField(programPid, "lstart") }, + ], + }; + writeFileSync( + join(paseoHome, "terminal-worker", `${workerPid}.json`), + `${JSON.stringify(record, null, 2)}\n`, + { mode: 0o600 }, + ); +} + +const trackedPids: number[] = []; + +try { + for (const [field, value] of [ + ["daemon.listen", `127.0.0.1:${port}`], + ["daemon.relay.enabled", "false"], + ["features.dictation.enabled", "false"], + ["features.voiceMode.enabled", "false"], + ] as const) { + await paseo(["daemon", "config", "set", field, value]); + } + await paseo(["daemon", "start"]); + + console.log("Test 1: daemon restart keeps the terminal"); + const kept = await openTerminal("kept"); + trackedPids.push(kept.pid); + await paseo(["daemon", "restart", "--home", paseoHome]); + await expectKept(kept, "kept", "restart"); + console.log("✓ same id, same program, marker and input after restart\n"); + + console.log("Test 2: stop --keep-terminals, then start, gives the terminal back"); + const forceAndKeep = await paseo( + ["daemon", "stop", "--home", paseoHome, "--force", "--keep-terminals"], + false, + ); + assert.notEqual(forceAndKeep.exitCode, 0, "--force with --keep-terminals is refused"); + await paseo(["daemon", "stop", "--home", paseoHome, "--keep-terminals"]); + assert.equal(await readDaemonInstance(paseoHome), null, "the supervisor stopped"); + assert(isAlive(kept.pid), "the program outlives the stopped daemon"); + await paseo(["daemon", "start"]); + await expectKept(kept, "kept", "keep-stop"); + console.log("✓ adopted with the same id and still usable\n"); + + console.log("Test 3: a plain stop with no daemon running closes kept terminals"); + await paseo(["daemon", "stop", "--home", paseoHome, "--keep-terminals"]); + assert(isAlive(kept.pid), "kept again"); + await paseo(["daemon", "stop", "--home", paseoHome]); + await expectClosed(kept.pid, "stop without a supervisor"); + assert.equal(workerRecordCount(), 0, "no worker record is left"); + console.log("✓ closed\n"); + + console.log("Test 4: restart --kill-terminals closes the terminal"); + await paseo(["daemon", "start"]); + const killedByRestart = await openTerminal("restart-kill"); + trackedPids.push(killedByRestart.pid); + await paseo(["daemon", "restart", "--home", paseoHome, "--kill-terminals"]); + await expectClosed(killedByRestart.pid, "restart --kill-terminals"); + assert.deepEqual(await terminalIds(), [], "no terminal is listed after the kill restart"); + console.log("✓ closed\n"); + + console.log("Test 5: daemon stop closes the terminal and its worker"); + const killedByStop = await openTerminal("stop-kill"); + trackedPids.push(killedByStop.pid); + await paseo(["daemon", "stop", "--home", paseoHome]); + await expectClosed(killedByStop.pid, "stop"); + assert.equal(workerRecordCount(), 0, "no worker record is left"); + console.log("✓ closed\n"); + + console.log("Test 6: daemon stop --force closes the terminal"); + await paseo(["daemon", "start"]); + const killedByForce = await openTerminal("force-kill"); + trackedPids.push(killedByForce.pid); + await paseo(["daemon", "stop", "--home", paseoHome, "--force"]); + await expectClosed(killedByForce.pid, "stop --force"); + assert.equal(workerRecordCount(), 0, "no worker record is left"); + console.log("✓ closed\n"); + + const endpoint = `127.0.0.1:${port}`; + console.log("Test 7: restart --host --kill-terminals closes the terminal"); + await paseo(["daemon", "start"]); + const killedByEndpointRestart = await openTerminal("endpoint-restart-kill"); + trackedPids.push(killedByEndpointRestart.pid); + await paseo(["daemon", "restart", "--host", endpoint, "--kill-terminals"]); + await expectClosed(killedByEndpointRestart.pid, "restart --host --kill-terminals"); + assert.deepEqual(await terminalIds(), [], "no terminal is listed after the kill restart"); + console.log("✓ closed and confirmed by the replacement\n"); + + console.log("Test 8: restart --host --kill-terminals reports a process cleanup could not end"); + const zombie = await startZombie(); + trackedPids.push(zombie.parentPid); + await plantRecordNaming(zombie.zombiePid); + const unconfirmed = await paseo( + ["daemon", "restart", "--host", endpoint, "--kill-terminals"], + false, + ); + assert.notEqual(unconfirmed.exitCode, 0, "the kill restart fails"); + const output = unconfirmed.stdout + unconfirmed.stderr; + assert(output.includes("Terminal cleanup was not confirmed"), `names the failure:\n${output}`); + assert(output.includes(`pid ${zombie.zombiePid}`), `names the surviving pid:\n${output}`); + process.kill(zombie.parentPid, "SIGKILL"); + await expectClosed(zombie.zombiePid, "zombie reaped once its parent exits"); + await paseo(["daemon", "stop", "--home", paseoHome]); + assert.equal(workerRecordCount(), 0, "the next stop retries the cleanup and retires the record"); + console.log("✓ reported, then retried by stop\n"); + + console.log("Test 9: restart --host --kill-terminals reports a worker record it cannot read"); + await paseo(["daemon", "start"]); + // A kill cleanup cannot check what an unreadable record names, and leaves nothing else + // behind, so the replacement daemon must still report it. + const unreadablePath = join(paseoHome, "terminal-worker", `${await goneProcessPid()}.json`); + writeFileSync(unreadablePath, "{ not json", { mode: 0o600 }); + const unreadable = await paseo( + ["daemon", "restart", "--host", endpoint, "--kill-terminals"], + false, + ); + assert.notEqual(unreadable.exitCode, 0, "the kill restart fails"); + const unreadableOutput = unreadable.stdout + unreadable.stderr; + assert(unreadableOutput.includes(unreadablePath), `names the record:\n${unreadableOutput}`); + assert(existsSync(unreadablePath), "the unreadable record is kept"); + rmSync(unreadablePath); + await paseo(["daemon", "stop", "--home", paseoHome]); + assert.equal(workerRecordCount(), 0, "no worker record is left"); + console.log("✓ reported with the record's path\n"); +} finally { + await runLocalPaseo(["daemon", "stop", "--home", paseoHome, "--force"], env); + for (const pid of trackedPids) { + if (isAlive(pid)) process.kill(pid, "SIGKILL"); + } + await rm(paseoHome, { recursive: true, force: true }); +} + +console.log("=== Daemon terminals survive restart passed ==="); diff --git a/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts b/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts new file mode 100644 index 0000000000..9090456406 --- /dev/null +++ b/packages/cli/tests/46-daemon-terminal-worker-upgrade.test.ts @@ -0,0 +1,561 @@ +#!/usr/bin/env npx tsx + +/** + * A terminal worker survives an upgrade of the installation that started it. Two disposable + * installations A and B (built dist, real copies of @getpaseo/server, @getpaseo/cli and + * node-pty, every other dependency a symlink into this repo) each get their own CLI shim, so + * each daemon's PASEO_CLI is a distinct path: + * + * 1. A daemon from A runs its worker from a copy in $PASEO_HOME/terminal-worker/. After + * `stop --keep-terminals` and moving A's node-pty, worker bundle and shell integration away, + * a daemon from B adopts the same worker and opens a new, working terminal in it with B's + * environment and CLI, while the first terminal keeps A's. + * 2. Worker protocol versions (PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE): protocols 0–4 + * are obsolete unreleased wires, so their programs/workers/copies are closed and a current + * worker serves new terminals. A truly newer protocol is left running, reported as + * terminal_worker_incompatible, and closed by `daemon stop`. + * + * Every process it starts is stopped in `finally`. + */ + +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:net"; + +if (process.platform === "win32") { + console.log("Skipping terminal worker upgrade CLI test on Windows (POSIX shell terminals)."); + process.exit(0); +} + +console.log("=== Terminal worker survives an upgrade ===\n"); + +const repoRoot = join(import.meta.dirname, "..", "..", ".."); +const root = await mkdtemp(join(tmpdir(), "paseo-worker-upgrade-")); +const paseoHome = join(root, "home"); +const cliHome = join(root, "os-home"); +const workDir = join(root, "work"); +for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true }); +const currentProtocolVersion = 5; +const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null; + +interface Installation { + name: string; + dir: string; + version: string; + shim: string; + serverDir: string; +} + +// --------------------------------------------------------------------------------------------- +// Installation fixtures +// --------------------------------------------------------------------------------------------- + +function linkEntries(source: string, target: string, taken: Set): void { + if (!existsSync(source)) return; + for (const name of readdirSync(source)) { + if (name.startsWith(".")) continue; + if (name.startsWith("@")) { + mkdirSync(join(target, name), { recursive: true }); + for (const scoped of readdirSync(join(source, name))) { + const key = `${name}/${scoped}`; + if (taken.has(key)) continue; + taken.add(key); + symlinkSync(join(source, name, scoped), join(target, name, scoped)); + } + continue; + } + if (taken.has(name)) continue; + taken.add(name); + symlinkSync(join(source, name), join(target, name)); + } +} + +function createInstallation(name: string, version: string): Installation { + const dir = join(root, `install-${name}`); + const modules = join(dir, "node_modules"); + mkdirSync(join(modules, "@getpaseo"), { recursive: true }); + + // Real copies: the server (its version marks the installation), the CLI (so it resolves this + // installation's server), and node-pty (so moving it away breaks only this installation). + const serverDir = join(modules, "@getpaseo", "server"); + const serverPackage = JSON.parse( + readFileSync(join(repoRoot, "packages/server/package.json"), "utf8"), + ) as Record; + mkdirSync(join(serverDir, "dist"), { recursive: true }); + writeFileSync( + join(serverDir, "package.json"), + `${JSON.stringify({ ...serverPackage, version }, null, 2)}\n`, + ); + for (const part of ["server", "src", "scripts"]) { + cpSync(join(repoRoot, "packages/server/dist", part), join(serverDir, "dist", part), { + recursive: true, + }); + } + const cliDir = join(modules, "@getpaseo", "cli"); + mkdirSync(cliDir, { recursive: true }); + cpSync(join(repoRoot, "packages/cli/package.json"), join(cliDir, "package.json")); + cpSync(join(repoRoot, "packages/cli/dist"), join(cliDir, "dist"), { recursive: true }); + cpSync(join(repoRoot, "packages/server/node_modules/node-pty"), join(modules, "node-pty"), { + recursive: true, + }); + + const taken = new Set(["@getpaseo/server", "@getpaseo/cli", "node-pty"]); + for (const source of [ + "packages/server/node_modules", + "packages/cli/node_modules", + "node_modules", + ]) { + linkEntries(join(repoRoot, source), modules, taken); + } + + const shim = join(dir, "bin", "paseo"); + mkdirSync(join(dir, "bin")); + writeFileSync( + shim, + `#!/bin/sh\nexec "${process.execPath}" "${join(cliDir, "dist", "index.js")}" "$@"\n`, + ); + chmodSync(shim, 0o755); + return { name, dir, version, shim, serverDir }; +} + +// The runtime files an upgrade replaces: node-pty, the worker bundle, the shell integration. +function moveRuntimeAway(installation: Installation): () => void { + const away = join(installation.dir, "moved-away"); + const moves = [ + join(installation.dir, "node_modules", "node-pty"), + join(installation.serverDir, "dist", "server", "terminal-worker"), + join(installation.serverDir, "dist", "server", "terminal", "shell-integration"), + ].map((from, index) => ({ from, to: join(away, String(index)) })); + mkdirSync(away, { recursive: true }); + for (const move of moves) renameSync(move.from, move.to); + return () => { + for (const move of moves) renameSync(move.to, move.from); + }; +} + +// --------------------------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------------------------- + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor( + probe: () => Promise | T | null | false, + message: () => string | Promise, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await probe(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(await message()); +} + +interface RunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +function run( + installation: Installation, + args: string[], + extraEnv: Record = {}, +): Promise { + const env = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + HOME: cliHome, + USERPROFILE: cliHome, + PASEO_HOME: paseoHome, + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + // Each daemon hands its terminals its own installation's CLI. + PASEO_CLI: installation.shim, + PASEO_TEST_INSTALL: installation.name, + SHELL: "/bin/sh", + ...extraEnv, + }; + return new Promise((resolve) => { + execFile(installation.shim, args, { env, timeout: 120_000 }, (error, stdout, stderr) => { + let exitCode = 0; + if (error) exitCode = typeof error.code === "number" ? error.code : 1; + resolve({ exitCode, stdout, stderr }); + }); + }); +} + +async function paseo( + installation: Installation, + args: string[], + extraEnv: Record = {}, +): Promise { + const result = await run(installation, args, extraEnv); + assert.equal( + result.exitCode, + 0, + `paseo ${args.join(" ")} (${installation.name}) failed:\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + return result; +} + +async function json(installation: Installation, args: string[]): Promise { + return JSON.parse((await paseo(installation, [...args, "--json"])).stdout) as T; +} + +async function capture(installation: Installation, terminalId: string): Promise { + const { lines } = await json<{ lines: string[] }>(installation, [ + "terminal", + "capture", + terminalId, + ]); + return lines.join("\n"); +} + +async function type(installation: Installation, terminalId: string, line: string) { + await paseo(installation, ["terminal", "send-keys", terminalId, "-l", line]); + await paseo(installation, ["terminal", "send-keys", terminalId, "Enter"]); +} + +async function waitForScreen(installation: Installation, terminalId: string, text: string) { + await waitFor( + async () => (await capture(installation, terminalId)).includes(text), + async () => + `terminal ${terminalId} never showed ${text}:\n${await capture(installation, terminalId)}`, + ); +} + +// Opens a terminal, records its shell's pid, and prints a marker. +async function openTerminal( + installation: Installation, + name: string, +): Promise<{ id: string; pid: number }> { + const terminal = await json<{ id: string }>(installation, [ + "terminal", + "create", + "--cwd", + workDir, + "--name", + name, + ]); + const pidFile = join(workDir, `${name}.pid`); + await type(installation, terminal.id, `echo $$ > '${pidFile}'; echo MARKER-${name}`); + const pid = await waitFor( + () => + existsSync(pidFile) && readFileSync(pidFile, "utf8").trim() + ? Number(readFileSync(pidFile, "utf8")) + : null, + () => `terminal ${name} never wrote its pid`, + ); + await waitForScreen(installation, terminal.id, `MARKER-${name}`); + trackedPids.push(pid); + return { id: terminal.id, pid }; +} + +// The value of `$variable` in the terminal's shell, written to a file (a long path would wrap +// on screen). +async function echoVariable( + installation: Installation, + terminalId: string, + label: string, + variable: string, +): Promise { + const valueFile = join(workDir, `${label}.value`); + await type( + installation, + terminalId, + `printf '%s' "$${variable}" > '${valueFile}.tmp' && mv '${valueFile}.tmp' '${valueFile}'`, + ); + await waitFor( + () => existsSync(valueFile), + async () => + `terminal ${terminalId} never wrote ${label}:\n${await capture(installation, terminalId)}`, + ); + return readFileSync(valueFile, "utf8"); +} + +interface WorkerRecordFile { + pid: number; + protocolVersion: unknown; + runtime?: string | null; + terminals: Array<{ id: string; pid: number }>; +} + +function workerRecords(): WorkerRecordFile[] { + const dir = join(paseoHome, "terminal-worker"); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((name) => /^\d+\.json$/.test(name)) + .map((name) => JSON.parse(readFileSync(join(dir, name), "utf8")) as WorkerRecordFile); +} + +function runtimeCopies(): string[] { + const dir = join(paseoHome, "terminal-worker"); + if (!existsSync(dir)) return []; + return readdirSync(dir).filter((name) => + existsSync(join(dir, name, "paseo-terminal-worker-runtime.json")), + ); +} + +function commandLineOf(pid: number): Promise { + return new Promise((resolve) => { + execFile("ps", ["-ww", "-o", "command=", "-p", String(pid)], (_error, stdout) => + resolve(stdout.trim()), + ); + }); +} + +async function terminalIds(installation: Installation): Promise { + return (await json>(installation, ["terminal", "ls", "--all"])).map( + (terminal) => terminal.id, + ); +} + +async function startDaemon(installation: Installation, extraEnv: Record = {}) { + const result = await paseo(installation, ["daemon", "start", "--json"], extraEnv); + lastListen = (JSON.parse(result.stdout) as { listen: string }).listen; + assert.match(lastListen, /^127\.0\.0\.1:[1-9]\d*$/, "start publishes the actual bound port"); + if (competingListener.listening) { + const competitor = competingListener.address(); + assert(competitor && typeof competitor !== "string"); + assert.notEqual( + Number(lastListen.split(":").at(-1)), + competitor.port, + "each daemon binds its own endpoint while the released port belongs to a peer", + ); + } +} + +async function stopDaemon(installation: Installation, keepTerminals = false) { + await paseo(installation, [ + "daemon", + "stop", + "--home", + paseoHome, + ...(keepTerminals ? ["--keep-terminals"] : []), + ]); +} + +// A terminal in a worker reporting `protocolVersion`, left running by `stop --keep-terminals`. +async function leaveWorkerWithProtocol( + installation: Installation, + protocolVersion: number, + name: string, +): Promise<{ terminal: { id: string; pid: number }; workerPid: number; runtime: string }> { + await startDaemon(installation, { + PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE: String(protocolVersion), + }); + const terminal = await openTerminal(installation, name); + const [worker] = workerRecords(); + assert(worker, "the daemon started a worker"); + assert.equal(worker.protocolVersion, protocolVersion, "the worker reports the override"); + await stopDaemon(installation, true); + return { terminal, workerPid: worker.pid, runtime: worker.runtime! }; +} + +const trackedPids: number[] = []; +const a = createInstallation("A", "0.98.0"); +const b = createInstallation("B", "0.99.0"); +let restoreA: (() => void) | null = null; +let passed = false; +let lastListen = ""; +const competingListener = createServer(); + +try { + for (const [field, value] of [ + // Let the listening daemon reserve its port atomically. Clients discover the + // actual endpoint from this home's PID record after every start. + ["daemon.listen", "127.0.0.1:0"], + ["daemon.relay.enabled", "false"], + ["features.dictation.enabled", "false"], + ["features.voiceMode.enabled", "false"], + ] as const) { + await paseo(a, ["daemon", "config", "set", field, value]); + } + + console.log("Test 1: a daemon from A runs its worker from a copy in the home"); + await startDaemon(a); + const t1 = await openTerminal(a, "t1"); + const [firstWorker] = workerRecords(); + assert(firstWorker, "one worker runs"); + assert.equal(firstWorker.protocolVersion, currentProtocolVersion, "the current wire is adopted"); + assert.match(firstWorker.runtime ?? "", /^0\.98\.0-[0-9a-f]{12}$/); + const copyA = join(paseoHome, "terminal-worker", firstWorker.runtime!); + assert( + (await commandLineOf(firstWorker.pid)).includes(join(copyA, "terminal-worker.mjs")), + "the worker runs the copy's bundle", + ); + assert.equal(await echoVariable(a, t1.id, "CLI1", "PASEO_CLI"), a.shim, "T1 has A's CLI"); + console.log("✓ worker runs from", copyA, "\n"); + + console.log("Test 2: B adopts the worker after A's runtime files are gone"); + await stopDaemon(a, true); + restoreA = moveRuntimeAway(a); + await startDaemon(b, zsh ? { SHELL: zsh } : {}); + assert.deepEqual( + workerRecords().map((record) => record.pid), + [firstWorker.pid], + "B adopted the same worker instead of starting one", + ); + assert((await terminalIds(b)).includes(t1.id), "T1 is listed by B"); + const t2 = await openTerminal(b, "t2"); + assert.equal(await echoVariable(b, t2.id, "INSTALL2", "PASEO_TEST_INSTALL"), "B"); + assert.equal(await echoVariable(b, t2.id, "CLI2", "PASEO_CLI"), b.shim, "T2 has B's CLI"); + if (zsh) { + // Creating a zsh terminal reads Paseo's integration from the worker's copy (A's is gone); + // a missing file would have failed the create. + const zshVersion = await echoVariable(b, t2.id, "ZSH", "ZSH_VERSION"); + assert(zshVersion.length > 0, "T2 runs zsh with Paseo's shell integration"); + } else { + console.log(" (zsh is not installed: the shell-integration check is skipped)"); + } + assert.equal(await echoVariable(b, t1.id, "CLI1AGAIN", "PASEO_CLI"), a.shim, "T1 keeps A's CLI"); + console.log("✓ same worker, T2 uses B's environment, T1 still works\n"); + + console.log("Test 3: daemon stop closes both terminals and deletes the copy"); + await stopDaemon(b); + await waitFor( + () => !isAlive(t1.pid) && !isAlive(t2.pid) && !isAlive(firstWorker.pid), + () => "terminals or worker outlived daemon stop", + ); + assert.deepEqual(workerRecords(), [], "no worker record is left"); + assert.deepEqual(runtimeCopies(), [], "no copy is left"); + restoreA(); + restoreA = null; + console.log("✓ closed and cleaned up\n"); + + // A released endpoint belongs to nobody. Reproduce another process taking it + // between starts; worker adoption is keyed by the home, not by a stable TCP port. + const releasedPort = Number(lastListen.split(":").at(-1)); + await new Promise((resolve, reject) => { + competingListener.once("error", reject); + competingListener.listen(releasedPort, "127.0.0.1", resolve); + }); + console.log(`Competing listener owns released port ${releasedPort}`); + + // There is no supported previous wire at protocol 5. Future previous-version classification + // is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter. + for (const protocolVersion of [0, 1, 2, 3, 4]) { + console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`); + const obsolete = await leaveWorkerWithProtocol( + a, + protocolVersion, + `obsolete-${protocolVersion}`, + ); + await startDaemon(b); + await waitFor( + () => !isAlive(obsolete.workerPid) && !isAlive(obsolete.terminal.pid), + () => "the obsolete worker or its terminal survived", + ); + assert(!(await terminalIds(b)).includes(obsolete.terminal.id), "its terminal is gone"); + const [current] = workerRecords(); + assert.equal(workerRecords().length, 1, "one current worker runs"); + assert(current); + assert.equal(current.protocolVersion, currentProtocolVersion); + assert.match(current.runtime ?? "", /^0\.99\.0-/); + await waitFor( + () => !runtimeCopies().includes(obsolete.runtime), + () => `the obsolete worker's copy ${obsolete.runtime} was not deleted`, + ); + assert.deepEqual(runtimeCopies(), [current.runtime], "the current copy stays"); + const replacement = await openTerminal(b, `replacement-${protocolVersion}`); + assert.deepEqual( + workerRecords() + .find((record) => record.pid === current.pid)! + .terminals.map((terminal) => terminal.id), + [replacement.id], + "new terminals open in the current worker", + ); + await type(b, replacement.id, `echo after-protocol-cut-${protocolVersion}`); + await waitForScreen(b, replacement.id, `\nafter-protocol-cut-${protocolVersion}`); + await stopDaemon(b); + await waitFor( + () => !isAlive(replacement.pid) && !isAlive(current.pid), + () => "the replacement worker or program outlived daemon stop", + ); + assert.deepEqual(workerRecords(), [], "no worker record is left"); + assert.deepEqual(runtimeCopies(), [], "no copy is left"); + console.log("✓ obsolete closed, current usable, copies cleaned up\n"); + } + + console.log("Test 5: a worker from a newer Paseo is left running and reported"); + const newer = await leaveWorkerWithProtocol(a, currentProtocolVersion + 1, "newer"); + await startDaemon(b); + const listed = await run(b, ["terminal", "ls", "--all", "--json"]); + assert.notEqual(listed.exitCode, 0, "terminal ls fails"); + assert.match(listed.stdout + listed.stderr, /held by a newer Paseo \(v0\.98\.0/); + const created = await run(b, ["terminal", "create", "--cwd", workDir, "--json"]); + assert.notEqual(created.exitCode, 0, "terminal create fails"); + assert.match(created.stdout + created.stderr, /held by a newer Paseo/); + const status = await paseo(b, ["daemon", "status", "--home", paseoHome]); + assert.match(status.stdout, /terminals: Terminals are held by a newer Paseo/); + assert(isAlive(newer.terminal.pid), "its program keeps running"); + assert.deepEqual( + workerRecords().map((record) => record.pid), + [newer.workerPid], + "no second worker starts", + ); + await stopDaemon(b); + await waitFor( + () => !isAlive(newer.workerPid) && !isAlive(newer.terminal.pid), + () => "daemon stop left the newer worker or its terminal running", + ); + assert.deepEqual(workerRecords(), [], "no worker record is left"); + assert.deepEqual(runtimeCopies(), [], "no copy is left"); + console.log("✓ reported, untouched, closed by stop\n"); + passed = true; +} finally { + if (!passed) { + for (const log of ["daemon.log", "terminal-worker.log"]) { + const logPath = join(paseoHome, log); + if (existsSync(logPath)) { + console.error(`--- ${log} (last 60 lines) ---`); + console.error(readFileSync(logPath, "utf8").split("\n").slice(-60).join("\n")); + } + } + } + if (competingListener.listening) { + await new Promise((resolve, reject) => + competingListener.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); + } + restoreA?.(); + for (const installation of [b, a]) { + await run(installation, ["daemon", "stop", "--home", paseoHome, "--force"]); + } + for (const pid of trackedPids) { + if (isAlive(pid)) process.kill(pid, "SIGKILL"); + } + await rm(root, { recursive: true, force: true }); +} + +console.log("=== Terminal worker survives an upgrade passed ==="); diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 5b5513d1a7..cfc9a87fb1 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -18,7 +18,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { fileURLToPath } from "url"; import { ChildProcess, spawn } from "child_process"; -import { getAvailablePort } from "./network.ts"; +import { readDaemonInstance } from "@getpaseo/server/daemon-control"; export interface TestDaemonContext { /** Available local port for test daemon (never 6767) */ @@ -195,17 +195,20 @@ async function probeDaemonReady( } async function waitForDaemonReady( - port: number, paseoHome: string, timeout = 30000, env?: NodeJS.ProcessEnv, -): Promise { +): Promise { const deadline = Date.now() + timeout; - async function poll(): Promise { - if (await probeDaemonReady(port, paseoHome, env)) return; + async function poll(): Promise { + const instance = await readDaemonInstance(paseoHome); + if (instance?.listen) { + const port = Number(new URL(`http://${instance.listen}`).port); + if (await probeDaemonReady(port, paseoHome, env)) return port; + } if (Date.now() >= deadline) { - throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`); + throw new Error(`Daemon failed to publish a ready endpoint within ${timeout}ms`); } await sleep(100); return poll(); @@ -231,15 +234,14 @@ export async function startTestDaemon(options?: { timeout?: number; env?: NodeJS.ProcessEnv; }): Promise { - const port = options?.port ?? (await getAvailablePort()); + // Let the listening process allocate its port, without a reserve/release race. + const requestedPort = options?.port ?? 0; const { paseoHome, workDir } = options?.paseoHome && options?.workDir ? { paseoHome: options.paseoHome, workDir: options.workDir } : await createTempDirs(); const timeout = options?.timeout ?? 30000; - const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`; - // Find the CLI entry point - use the source file directly with tsx const cliDir = join(import.meta.dirname, "..", ".."); const cliSrcPath = join(cliDir, "src", "index.ts"); @@ -252,7 +254,7 @@ export async function startTestDaemon(options?: { ), ...TEST_DAEMON_ENV_DEFAULTS, PASEO_HOME: paseoHome, - PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, + PASEO_LISTEN: `${TEST_DAEMON_HOST}:${requestedPort}`, // Force no TTY to prevent QR code output CI: "true", ...options?.env, @@ -312,20 +314,18 @@ export async function startTestDaemon(options?: { } }); - const ctx: TestDaemonContext = { - port, - wsUrl, - paseoHome, - workDir, - process: daemonProcess, - isReady: false, - stop: cleanup, - }; - // Wait for daemon to be ready try { - await waitForDaemonReady(port, paseoHome, timeout, options?.env); - ctx.isReady = true; + const port = await waitForDaemonReady(paseoHome, timeout, options?.env); + return { + port, + wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`, + paseoHome, + workDir, + process: daemonProcess, + isReady: true, + stop: cleanup, + }; } catch (err) { // Daemon failed to start - clean up and rethrow await cleanup(); @@ -335,8 +335,6 @@ export async function startTestDaemon(options?: { { cause: err }, ); } - - return ctx; } /** diff --git a/packages/client/package.json b/packages/client/package.json index 64c559cdab..19c0490021 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.13.0", + "version": "0.15.0-beta.1", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.13.0", - "@getpaseo/relay": "0.13.0", + "@getpaseo/protocol": "0.15.0-beta.1", + "@getpaseo/relay": "0.15.0-beta.1", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index eda2fec26b..62753bca5c 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -125,6 +125,8 @@ export class ConnectionSubscriptions extends OwnedSubscriptions { // 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.terminalChunkedReplay]: true, + [CLIENT_CAPS.binaryChunkingV1]: true, [CLIENT_CAPS.helloRejection]: true, [CLIENT_CAPS.ownedSubscriptions]: true, [CLIENT_CAPS.allProviders]: true, diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 258c0aa9b5..0faecdcb5e 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -1,3 +1,4 @@ +import { maxSecuredPlaintextByteLength } from "@getpaseo/relay"; import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { openClientSecureChannel, @@ -155,18 +156,15 @@ export function createEncryptedTransport( const relayTransport: RelayTransport = { send: (data) => { if (typeof data === "string") { - base.send(data); - return; + return base.send(data); } if (ArrayBuffer.isView(data)) { - base.send(normalizeTransportPayload(data)); - return; + return base.send(normalizeTransportPayload(data)); } if (data instanceof ArrayBuffer) { - base.send(data); - return; + return base.send(data); } - base.send(String(data)); + return base.send(String(data)); }, // The channel decides the close code. Report it now: a socket close event // arrives asynchronously, and the consumer must not act on an earlier @@ -226,6 +224,8 @@ export function createEncryptedTransport( }); return { + supportsDrain: base.supportsDrain, + maxFrameBytes: maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES), send: (data) => { if (!channel) { throw new Error("Encrypted channel not ready"); @@ -239,8 +239,9 @@ export function createEncryptedTransport( // Encryption and its transport handoff are synchronous up to send's first await. // Reject capacity failures here so a routed sender loses only its own channel. assertOutboundCapacity(wireBytes); - void channel.send(payload).catch((error) => { + return channel.send(payload).catch((error) => { emitError(error); + throw error; }); }, close: (code?: number, reason?: string) => { diff --git a/packages/client/src/daemon-client-transport-types.ts b/packages/client/src/daemon-client-transport-types.ts index ddcb57246b..64f84cf8c1 100644 --- a/packages/client/src/daemon-client-transport-types.ts +++ b/packages/client/src/daemon-client-transport-types.ts @@ -1,5 +1,8 @@ export interface DaemonTransport { - send: (data: string | Uint8Array | ArrayBuffer) => void; + /** False when the underlying socket cannot report write drain. */ + supportsDrain?: boolean; + maxFrameBytes?: number; + send: (data: string | Uint8Array | ArrayBuffer) => void | Promise; close: (code?: number, reason?: string) => void; onMessage: (handler: (data: unknown, isBinary: boolean) => void) => () => void; onOpen: (handler: () => void) => () => void; @@ -13,12 +16,16 @@ export type DaemonTransportFactory = (options: { protocols?: string[]; }) => DaemonTransport; -export type WebSocketFactory = ( - url: string, - options?: { headers?: Record; protocols?: string[] }, -) => WebSocketLike; +export interface WebSocketFactory { + ( + url: string, + options?: { headers?: Record; protocols?: string[] }, + ): WebSocketLike; + supportsDrain?: boolean; +} export interface WebSocketLike { + bufferedAmount?: number; readyState: number; send: (data: string | Uint8Array | ArrayBuffer) => void; close: (code?: number, reason?: string) => void; diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index 963a8b541c..c95c534129 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -175,3 +175,18 @@ describe("daemon-client transport helpers", () => { expect(decodeMessageData(bytes)).toBe("hello"); }); }); + +test("native drain capability survives WebSocket and encryption wrappers", async () => { + const { nativeWebSocketFactory } = await import("./daemon-client-websocket-transport.js"); + expect(nativeWebSocketFactory.supportsDrain).toBe(false); + const factory = Object.assign(() => ({ readyState: 1, send() {}, close() {} }), { + supportsDrain: false, + }); + const base = createWebSocketTransportFactory(factory)({ url: "ws://test" }); + expect(base.supportsDrain).toBe(false); + const encrypted = createEncryptedTransport(base, { + daemonPublicKeyB64: "unused-key", + logger: { warn() {} }, + }); + expect(encrypted.supportsDrain).toBe(false); +}); diff --git a/packages/client/src/daemon-client-websocket-transport.ts b/packages/client/src/daemon-client-websocket-transport.ts index f5734b4b51..c7ec87555d 100644 --- a/packages/client/src/daemon-client-websocket-transport.ts +++ b/packages/client/src/daemon-client-websocket-transport.ts @@ -39,6 +39,9 @@ export function nativeWebSocketFactory( return new (getGlobalWebSocket())(url, options?.protocols, { headers: options?.headers }); } +// React Native leaves bufferedAmount at zero, including while native output is queued. +nativeWebSocketFactory.supportsDrain = false; + export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory { return ({ url, headers, protocols }) => { const ws = factory(url, { headers, protocols }); @@ -50,11 +53,28 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem } } return { + supportsDrain: factory.supportsDrain !== false, send: (data) => { if (typeof ws.readyState === "number" && ws.readyState !== 1) { throw new Error(`WebSocket not open (readyState=${ws.readyState})`); } ws.send(data); + if ((ws.bufferedAmount ?? 0) > 1024 * 1024) { + return new Promise((resolve, reject) => { + const drain = () => { + if (ws.readyState !== 1) { + reject(new Error("WebSocket closed while draining")); + return; + } + if ((ws.bufferedAmount ?? 0) <= 1024 * 1024) { + resolve(); + return; + } + setTimeout(drain, 10); + }; + drain(); + }); + } }, close: (code?: number, reason?: string) => { // Node's `ws` may emit an `error` when a connecting socket is closed before the diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index f663aeb8be..4fa7d5723c 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; +import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest"; import { z } from "zod"; import { DaemonClient, @@ -9,8 +9,11 @@ import { type CreateAgentRequestOptions, type DaemonTransport, type Logger, + type PluginClientBundle, + type PluginClientBundleStore, } from "./daemon-client"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import { ChunkedTransport } from "@getpaseo/protocol/chunked-transport"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import { decodeFileTransferFrame, @@ -1092,6 +1095,7 @@ test("advertises client capabilities in hello", async () => { provider_snapshot_references: true, explicit_event_subscriptions: true, owned_subscriptions: true, + binary_chunking_v1: true, compact_provider_snapshots: true, custom_mode_icons: true, project_updates: true, @@ -1099,6 +1103,7 @@ test("advertises client capabilities in hello", async () => { projected_subagent_timeline: true, reasoning_merge_enum: true, terminal_reflowable_snapshot: true, + terminal_chunked_replay: true, timeline_notifications: true, plugin_timeline_items: true, workspace_setup_blocked: true, @@ -1640,6 +1645,77 @@ test("preserves legacy fetchAgent id overload", async () => { await expect(responsePromise).rejects.toThrow("legacy fetch sentinel"); }); +test.each([undefined, false, true])( + "loads history with whole-turn support advertised as %s", + async (wholeTurnTimeline) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "history-compatibility", + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ + features: wholeTurnTimeline === undefined ? {} : { wholeTurnTimeline }, + }); + await connected; + + const requests = [ + { direction: "tail" as const }, + { direction: "before" as const, cursor: { epoch: "epoch-1", seq: 50 } }, + { direction: "after" as const, cursor: { epoch: "epoch-1", seq: 50 } }, + { direction: "before" as const, cursor: { epoch: "epoch-1", seq: 80 }, mergeWindow: true }, + ]; + for (const [index, request] of requests.entries()) { + const requestId = `history-${index}`; + const result = client.fetchAgentTimeline("agent-1", { + ...request, + requestId, + wholeTurns: true, + limit: 40, + projection: "projected", + }); + // Attach immediately so the old rejection is captured by the regression. + const completed = expect(result).resolves.toMatchObject({ requestId, entries: [] }); + expect(parseSentFrame(transport.sent.at(-1))).toEqual({ + type: "fetch_agent_timeline_request", + agentId: "agent-1", + requestId, + ...request, + limit: 40, + projection: "projected", + ...(wholeTurnTimeline === true ? { wholeTurns: true } : {}), + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "fetch_agent_timeline_response", + payload: { + requestId, + agentId: "agent-1", + agent: null, + direction: request.direction, + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 1 }, + startCursor: null, + endCursor: null, + hasOlder: false, + hasNewer: false, + entries: [], + error: null, + }, + }), + ); + await completed; + } + }, +); + test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => { useHeartbeatClock(); const logger = createMockLogger(); @@ -2028,6 +2104,188 @@ test("gets a structured plugin log snapshot", async () => { ]); }); +describe("plugin catalog", () => { + async function connectedClient(createStore?: (serverId: string) => PluginClientBundleStore) { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + createPluginClientBundleStore: createStore, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen({ features: { plugins: true } }); + await connectPromise; + return { client, mock }; + } + + async function fetchCatalog( + harness: Awaited>, + respond: (request: Record) => Array>, + ) { + const before = harness.mock.sent.length; + const response = harness.client.getPluginCatalog(); + await vi.waitFor(() => expect(harness.mock.sent.length).toBeGreaterThan(before)); + const request = parseSentFrame(harness.mock.sent.at(-1)); + harness.mock.triggerMessage( + wrapSessionMessage({ + type: "plugin.catalog.get.response", + payload: { requestId: request.requestId, plugins: respond(request) }, + }), + ); + return { request, plugins: await response }; + } + + test("reuses retained client bundles the daemon leaves out", async () => { + const harness = await connectedClient(); + const first = await fetchCatalog(harness, () => [ + { id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" }, + { id: "changed", clientBundle: "old bundle", clientBundleHash: "hash-old" }, + ]); + expect(first.request).not.toHaveProperty("cachedClientBundles"); + + const second = await fetchCatalog(harness, () => [ + { id: "kept", clientBundleHash: "hash-kept" }, + { id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" }, + ]); + + expect(second.request.cachedClientBundles).toEqual([ + { id: "kept", clientBundleHash: "hash-kept" }, + { id: "changed", clientBundleHash: "hash-old" }, + ]); + expect(second.plugins).toEqual([ + { id: "kept", clientBundle: "kept bundle", clientBundleHash: "hash-kept" }, + { id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" }, + ]); + const third = await fetchCatalog(harness, () => []); + expect(third.request.cachedClientBundles).toEqual([ + { id: "kept", clientBundleHash: "hash-kept" }, + { id: "changed", clientBundleHash: "hash-new" }, + ]); + const fourth = await fetchCatalog(harness, () => []); + expect(fourth.request).not.toHaveProperty("cachedClientBundles"); + }); + + test("keeps fetching every bundle from a daemon that sends no bundle hashes", async () => { + const harness = await connectedClient(); + const legacyCatalog = () => [{ id: "legacy", clientBundle: "legacy bundle" }]; + await fetchCatalog(harness, legacyCatalog); + + const second = await fetchCatalog(harness, legacyCatalog); + + expect(second.request).not.toHaveProperty("cachedClientBundles"); + expect(second.plugins).toEqual([{ id: "legacy", clientBundle: "legacy bundle" }]); + }); + + test("a fresh client advertises persisted bundles on its first fetch and expands omitted code", async () => { + let persisted: PluginClientBundle[] = []; + let loads = 0; + const store: PluginClientBundleStore = { + async load() { + loads++; + return persisted; + }, + async replace(bundles) { + persisted = bundles; + }, + }; + const createdFor: string[] = []; + const createStore = (serverId: string) => { + createdFor.push(serverId); + return store; + }; + const bundle = { id: "kept", clientBundleHash: "hash-kept", clientBundle: "stored code" }; + const warm = await connectedClient(createStore); + await fetchCatalog(warm, () => [bundle]); + const cold = await connectedClient(createStore); + const first = await fetchCatalog(cold, () => [{ id: "kept", clientBundleHash: "hash-kept" }]); + expect(first.request.cachedClientBundles).toEqual([ + { id: "kept", clientBundleHash: "hash-kept" }, + ]); + expect(first.plugins).toEqual([bundle]); + expect(loads).toBe(2); + await fetchCatalog(cold, () => []); + expect(loads).toBe(2); + expect(createdFor).toEqual([ + warm.client.getLastServerInfoMessage()!.serverId, + cold.client.getLastServerInfoMessage()!.serverId, + ]); + expect(persisted).toEqual([]); + }); + + test.each(["missing", "unreadable"])( + "a %s persistent cache requests full bundles", + async (state) => { + const store: PluginClientBundleStore = { + async load() { + if (state === "unreadable") throw new Error("Storage unavailable"); + return []; + }, + async replace() {}, + }; + const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" }; + const result = await fetchCatalog(await connectedClient(() => store), () => [bundle]); + expect(result.request).not.toHaveProperty("cachedClientBundles"); + expect(result.plugins).toEqual([bundle]); + }, + ); + + test("failed cache writes never fail plugin loading", async () => { + const store: PluginClientBundleStore = { + async load() { + return []; + }, + async replace() { + throw new Error("Disk full"); + }, + }; + const bundle = { id: "full", clientBundleHash: "hash-full", clientBundle: "full code" }; + expect( + (await fetchCatalog(await connectedClient(() => store), () => [bundle])).plugins, + ).toEqual([bundle]); + }); + + test.each([false, true])( + "usable catalogs do not wait for storage; latest write survives a failed first write: %s", + async (failFirstWrite) => { + const writes: PluginClientBundle[][] = []; + let finishWrite = () => {}; + const blockedWrite = new Promise((resolve) => { + finishWrite = resolve; + }); + const store: PluginClientBundleStore = { + async load() { + return []; + }, + async replace(bundles) { + writes.push(bundles); + if (writes.length === 1) { + await blockedWrite; + if (failFirstWrite) throw new Error("Disk full"); + } + }, + }; + const harness = await connectedClient(() => store); + const first = { id: "plugin", clientBundle: "old", clientBundleHash: "hash-old" }; + const changed = { id: "plugin", clientBundle: "new", clientBundleHash: "hash-new" }; + try { + expect((await fetchCatalog(harness, () => [first])).plugins).toEqual([first]); + await vi.waitFor(() => expect(writes).toEqual([[first]])); + expect((await fetchCatalog(harness, () => [changed])).plugins).toEqual([changed]); + expect((await fetchCatalog(harness, () => [])).plugins).toEqual([]); + expect(writes).toEqual([[first]]); + finishWrite(); + await vi.waitFor(() => expect(writes).toEqual([[first], []])); + } finally { + finishWrite(); + } + }, + ); +}); + test("keeps a plugin reload pending past the session RPC deadline", async () => { useHeartbeatClock(); try { @@ -6132,66 +6390,80 @@ test("emits output events for the active terminal stream", async () => { unsubscribe(); }); -test("emits snapshot events for the subscribed terminal stream", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); +test.each([5, 500, 10000])( + "preserves explicit %s history demand on an old ANSI host and normalizes restore", + async (history) => { + const logger = createMockLogger(); + const mock = createMockTransport(); - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; - const snapshots: unknown[] = []; - client.onTerminalStreamEvent((event) => { - if (event.type !== "snapshot") { - return; - } - snapshots.push(event.state); - }); + const events: unknown[] = []; + const replayEvents: unknown[] = []; + client.onTerminalStreamEvent((event) => { + if (event.type === "replay") replayEvents.push(event.replay); + events.push(event); + }); - 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", + const subscribePromise = client.subscribeTerminal("term-1", { + requestId: "sub-2", + restore: { mode: "visible-snapshot", scrollbackLines: history }, + }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); + expect(parseSentFrame(mock.sent.at(-1)).restore).toEqual({ + mode: "visible-snapshot", + scrollbackLines: history, + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), + terminalId: "term-1", + slot: 12, + error: null, + requestId: "sub-2", + }, + }), + ); + await subscribePromise; + + const state = { + rows: 1, + cols: 5, + grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], + scrollback: [], + cursor: { row: 0, col: 5 }, + }; + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Snapshot, slot: 12, - error: null, - requestId: "sub-2", - }, - }), - ); - await subscribePromise; + payload: encodeTerminalSnapshotPayload(state), + }), + ); - const state = { - rows: 1, - cols: 5, - grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], - scrollback: [], - cursor: { row: 0, col: 5 }, - }; - mock.triggerMessage( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Snapshot, - slot: 12, - payload: encodeTerminalSnapshotPayload(state), - }), - ); - - expect(snapshots).toEqual([state]); -}); + expect(events).toEqual([]); + const data = new TextEncoder().encode("\x1b[31mold ANSI host界🙂"); + mock.triggerMessage( + encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 12, payload: data }), + ); + expect(replayEvents).toEqual([{ type: "begin" }, { type: "data", data }, { type: "end" }]); + }, +); test("sends explicit terminal input and resize commands", async () => { const logger = createMockLogger(); @@ -6562,91 +6834,101 @@ test("drops legacy agent_stream tool_call payloads and logs validation warning", expect(logger.warn).toHaveBeenCalled(); }); -test("parses canonical fetch_agent_timeline_response payloads without crashing", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); +test.each([false, true])( + "parses canonical timeline responses with chunking=%s", + async (chunked) => { + const logger = createMockLogger(); + const mock = createMockTransport(); - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + const connectPromise = client.connect(); + mock.triggerOpen({ features: { ownedSubscriptions: true, binaryChunkingV1: chunked } }); + await connectPromise; - const received: unknown[] = []; - const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => { - received.push(msg); - }); + const received: unknown[] = []; + const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => { + received.push(msg); + }); - mock.triggerMessage( - wrapSessionMessage({ - type: "fetch_agent_timeline_response", + const command = chunked ? "x".repeat(50 * 1024 * 1024) : "pwd"; + const sender = new ChunkedTransport({ + frameBytes: 24 * 1024 * 1024, + send: (frame) => mock.triggerMessage(frame), + deliver: () => {}, + }); + sender.enabled = chunked; + await sender.send( + wrapSessionMessage({ + type: "fetch_agent_timeline_response", + payload: { + requestId: "req-1", + agentId: "agent_cli", + agent: null, + direction: "tail", + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 1 }, + hasOlder: false, + hasNewer: false, + entries: [ + { + timestamp: "2026-02-08T20:20:00.000Z", + provider: "codex", + seqStart: 1, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + collapsed: [], + item: { + type: "tool_call", + callId: "call_cli_snapshot", + name: "shell", + status: "running", + detail: { + type: "shell", + command, + }, + error: null, + }, + }, + ], + error: null, + }, + }), + ); + + unsubscribe(); + + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ payload: { - requestId: "req-1", - agentId: "agent_cli", - agent: null, - direction: "tail", - projection: "projected", - epoch: "epoch-1", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 1 }, - hasOlder: false, - hasNewer: false, entries: [ { - timestamp: "2026-02-08T20:20:00.000Z", - provider: "codex", - seqStart: 1, - seqEnd: 1, - sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], - collapsed: [], item: { type: "tool_call", - callId: "call_cli_snapshot", - name: "shell", status: "running", - detail: { - type: "shell", - command: "pwd", - }, error: null, + detail: { type: "shell", command }, }, }, ], - error: null, }, - }), - ); - - unsubscribe(); - - expect(received).toHaveLength(1); - expect(received[0]).toMatchObject({ - payload: { - entries: [ - { - item: { - type: "tool_call", - status: "running", - error: null, - detail: { type: "shell" }, - }, - }, - ], - }, - }); - expect(logger.warn).not.toHaveBeenCalled(); -}); + }); + expect(logger.warn).not.toHaveBeenCalled(); + }, +); test("rejects and logs a correlated response that violates the protocol schema", async () => { const mock = createMockTransport(); @@ -8216,3 +8498,429 @@ test("closes before admission when a protected-capable daemon is reached in lega expect(daemonReceived).toHaveLength(1); expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" }); }); + +test.each([ + { chunked: false, transportChunked: false }, + { chunked: true, transportChunked: false }, + { chunked: false, transportChunked: true }, + { chunked: true, transportChunked: true }, +])( + "default terminal observation requests ANSI replay (replay=$chunked, transport=$transportChunked)", + async ({ chunked, transportChunked }) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ + preserveSent: true, + features: { + ownedSubscriptions: true, + "terminal-restore-modes": true, + binaryChunkingV1: transportChunked, + }, + }); + await connecting; + const hello = JSON.parse(transport.sent[0] as string); + expect(hello.capabilities[CLIENT_CAPS.terminalChunkedReplay]).toBe(true); + expect(hello.capabilities[CLIENT_CAPS.binaryChunkingV1]).toBe(true); + const wire: Uint8Array[] = []; + const sender = new ChunkedTransport({ + frameBytes: 17, + send: (frame) => { + if (typeof frame === "string") throw new Error("Expected binary terminal frame"); + wire.push(frame); + transport.triggerMessage(frame); + }, + deliver: () => {}, + }); + sender.enabled = transportChunked; + const events: import("./terminal-stream-router").TerminalStreamEvent[] = []; + const observation = client.observeTerminal("default-terminal", (event) => events.push(event)); + await vi.waitFor(() => + expect(parseSentFrame(transport.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); + const request = parseSentFrame(transport.sent.at(-1)); + expect(request.restore).toEqual({ mode: "full-snapshot" }); + transport.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "default-terminal", + requestId: request.requestId, + subscriptionId: "default-subscription", + slot: 2, + error: null, + }, + }), + ); + await observation.ready; + const data = new TextEncoder().encode("\x1b[31mretained 界🙂\x1b[0m"); + const metadata = { rows: 24, cols: 80, retainedHistoryLines: 1000 }; + if (chunked) { + await sender.send( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.ReplayBegin, + slot: 2, + payload: JSON.stringify(metadata), + }), + ); + await sender.send( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.ReplayData, + slot: 2, + payload: data, + }), + ); + await sender.send( + encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.ReplayEnd, slot: 2 }), + ); + } else { + // An ANSI-era host selects its existing Restore path for this explicit request. + await sender.send( + encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: 2, payload: data }), + ); + } + if (transportChunked) { + expect(wire.length).toBeGreaterThan(chunked ? 3 : 1); + expect(wire.some((frame) => frame[0] === 0x31)).toBe(true); + expect(wire.every((frame) => frame.length <= 17)).toBe(true); + } else { + expect(wire.map((frame) => frame[0])).toEqual(chunked ? [6, 7, 8] : [5]); + } + expect(events).toEqual([ + { + terminalId: "default-terminal", + subscriptionId: "default-subscription", + type: "replay", + replay: chunked ? { type: "begin", ...metadata } : { type: "begin" }, + }, + { + terminalId: "default-terminal", + subscriptionId: "default-subscription", + type: "replay", + replay: { type: "data", data }, + }, + { + terminalId: "default-terminal", + subscriptionId: "default-subscription", + type: "replay", + replay: { type: "end" }, + }, + ]); + const live = new TextEncoder().encode("live after replay 界🙂"); + await sender.send( + encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, slot: 2, payload: live }), + ); + expect(events.at(-1)).toEqual({ + terminalId: "default-terminal", + subscriptionId: "default-subscription", + type: "output", + data: live, + }); + }, +); + +test("uses the frame budget selected by an asynchronously opened transport", async () => { + const mock = createMockTransport(); + mock.transport.maxFrameBytes = 1024; + const client = new DaemonClient({ + url: "ws://test", + clientId: "dynamic-budget", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.transport.maxFrameBytes = 16; + mock.triggerOpen({ features: { binaryChunkingV1: true } }); + await connecting; + client.sendHostFrame("route", new Uint8Array(100)); + await vi.waitFor(() => expect(mock.sent.length).toBeGreaterThan(1)); + expect(mock.sent.every((frame) => typeof frame !== "string" && frame.byteLength <= 16)).toBe( + true, + ); +}); + +test.each([false, true])("routed sends wait for origin drain (binary=%s)", async (binary) => { + const mock = createMockTransport(); + let drain!: () => void; + let blocked = false; + const send = mock.transport.send; + mock.transport.send = (frame) => { + send(frame); + if (blocked) + return new Promise((resolve) => { + drain = resolve; + }); + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "routed-drain", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: { binaryChunkingV1: true } }); + await connecting; + blocked = true; + let finished = false; + const write = Promise.resolve( + client.sendHostFrame("route", binary ? new Uint8Array([1]) : "hello"), + ).then(() => { + finished = true; + return undefined; + }); + await Promise.resolve(); + expect(finished).toBe(false); + drain(); + await write; + expect(finished).toBe(true); +}); + +test("a queued admission failure rejects its RPC without closing the client", async () => { + const mock = createMockTransport(); + const send = mock.transport.send; + let block = false; + let drain!: () => void; + mock.transport.send = (frame) => { + if (!block) return send(frame); + if (typeof frame !== "string") + return new Promise((resolve) => { + drain = resolve; + }); + throw new Error("capacity"); + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "queued-admission", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + block = true; + const first = client.sendHostFrame("route", new Uint8Array([1])); + const ping = client.ping({ timeoutMs: 1000 }); + void ping.catch(() => {}); + drain(); + await first; + await expect(ping).rejects.toThrow("capacity"); + expect(client.isConnected).toBe(true); +}); + +test("RPC deadlines remain active while a preceding frame waits for drain", async () => { + const mock = createMockTransport(); + const send = mock.transport.send; + let blocked = false; + let drain!: () => void; + mock.transport.send = (frame) => { + if (!blocked) return send(frame); + if (typeof frame !== "string") + return new Promise((resolve) => { + drain = resolve; + }); + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "drain-deadline", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + blocked = true; + const first = client.sendHostFrame("route", new Uint8Array([1])); + try { + await expect(client.ping({ timeoutMs: 20 })).rejects.toThrow("Timeout waiting for message"); + expect(client.isConnected).toBe(true); + } finally { + drain(); + await first; + } +}); + +test.each([false, true])( + "event listener errors do not close the transport (chunked=%s)", + async (chunked) => { + const mock = createMockTransport(); + const closed = vi.fn(); + mock.transport.close = closed; + const client = new DaemonClient({ + url: "ws://test", + clientId: "listener-error", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: { binaryChunkingV1: chunked } }); + await connecting; + const unsubscribe = client.subscribe(() => { + throw new Error("listener failed"); + }); + const text = wrapSessionMessage({ + type: "agent_deleted", + payload: { agentId: "agent-test", requestId: "listener-test" }, + }); + const payload = new TextEncoder().encode(text); + const frame = chunked ? new Uint8Array([0x31, 1, 0, 3, ...payload]) : text; + expect(() => mock.triggerMessage(frame)).toThrow("listener failed"); + expect(closed).not.toHaveBeenCalled(); + unsubscribe(); + const events: string[] = []; + client.subscribe((event) => { + events.push(event.type); + }); + mock.triggerMessage(frame); + expect(events).toEqual(["agent_deleted"]); + await client.sendHostFrame("route", "still connected"); + expect(client.isConnected).toBe(true); + }, +); + +test("malformed chunk metadata still closes the client transport", async () => { + const mock = createMockTransport(); + const closed = vi.fn(); + mock.transport.close = closed; + const client = new DaemonClient({ + url: "ws://test", + clientId: "malformed-chunk", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: { binaryChunkingV1: true } }); + await connecting; + mock.triggerMessage(new Uint8Array([0x31, 2, 0, 3, 65])); + expect(closed).toHaveBeenCalledWith(4003, "Invalid chunked transport"); +}); + +test.each([FileTransferOpcode.FileBegin, FileTransferOpcode.FileChunk, FileTransferOpcode.FileEnd])( + "upload stops at a queued pre-write rejection (opcode=%s)", + async (opcode) => { + const mock = createMockTransport(); + const send = mock.transport.send; + let drain!: () => void; + let blocked = false; + const attempts: number[] = []; + mock.transport.send = (frame) => { + if (typeof frame !== "string") { + const decoded = decodeFileTransferFrame(assertUint8Array(frame)); + if (decoded) { + attempts.push(decoded.opcode); + if (decoded.opcode === opcode) throw new Error("upload capacity"); + } else if (blocked) + return new Promise((resolve) => { + drain = resolve; + }); + } + return send(frame); + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "upload-admission", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + blocked = true; + const first = client.sendHostFrame("route", new Uint8Array([1])); + const upload = client.uploadFile({ + fileName: "file.bin", + mimeType: "application/octet-stream", + bytes: new Uint8Array([1, 2]), + chunkSize: 1, + }); + let rejected: unknown; + void upload.catch((error) => { + rejected = error; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + drain(); + await first; + await vi.waitFor(() => expect(rejected).toMatchObject({ message: "upload capacity" }), { + timeout: 500, + }); + expect(attempts.at(-1)).toBe(opcode); + expect(client.isConnected).toBe(true); + }, +); + +test.each(["latency", "dictation start", "dictation finish"])( + "queued control send rejection reaches its caller (%s)", + async (operation) => { + const mock = createMockTransport(); + const send = mock.transport.send; + let blocked = false; + let drain!: () => void; + mock.transport.send = (frame) => { + if (!blocked) return send(frame); + if (typeof frame !== "string") + return new Promise((resolve) => { + drain = resolve; + }); + throw new Error("control capacity"); + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "control-admission", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + blocked = true; + const first = client.sendHostFrame("route", new Uint8Array([1])); + let result: Promise; + if (operation === "latency") result = client.measureLatency({ timeoutMs: 1000 }); + else if (operation === "dictation start") + result = client.startDictationStream("dictation", "pcm"); + else result = client.finishDictationStream("dictation", 0); + const rejected = expect(result).rejects.toThrow("control capacity"); + drain(); + await first; + await rejected; + expect(client.isConnected).toBe(true); + }, +); + +test("routed channels inherit an origin without drain support", async () => { + const { createHostChannelTransport } = await import("./hosts/index.js"); + const mock = createMockTransport(); + mock.transport.supportsDrain = false; + const client = new DaemonClient({ + url: "ws://test", + clientId: "unpaced-route", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen(); + await connecting; + const route = createHostChannelTransport( + client, + "srv_destination", + "scope", + "route", + )({ url: "ws://route" }); + expect(route.supportsDrain).toBe(false); + route.close(); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 524df56704..3aa91f84c7 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,3 +1,7 @@ +import { + ChunkedTransport, + ChunkedTransportProtocolError, +} from "@getpaseo/protocol/chunked-transport"; import { HOST_ROUTE_OPCODE, encodeHostRouteFrame, @@ -19,6 +23,7 @@ import { CreationClient } from "./creation/index.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; @@ -395,6 +400,17 @@ export type DaemonEventHandler = (event: DaemonEvent) => void; export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest; export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse; +export interface PluginClientBundle { + id: string; + clientBundleHash: string; + clientBundle: string; +} + +/** Host-scoped storage. load must return only bundles whose contents have been verified. */ +export interface PluginClientBundleStore { + load(): Promise; + replace(bundles: PluginClientBundle[]): Promise; +} /** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */ export type RelaySecurityMode = "legacy" | "protected"; @@ -405,6 +421,8 @@ export interface DaemonClientConfig { sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[]; url: string; clientId: string; + /** Create one cache for the confirmed daemon identity, retained for this client lifetime. */ + createPluginClientBundleStore?: (serverId: string) => PluginClientBundleStore; clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub"; appVersion?: string; runtimeGeneration?: number | null; @@ -685,6 +703,8 @@ export interface FetchAgentOptions { } type LegacyFetchAgentOptions = Omit; export interface FetchAgentTimelineOptions { + /** Prefer whole-turn pages when supported; older hosts retain item-based paging. */ + wholeTurns?: boolean; direction?: FetchAgentTimelineDirection; cursor?: FetchAgentTimelineCursor; limit?: number; @@ -765,6 +785,9 @@ type ShutdownRequestedStatusPayload = z.infer = CorrelatedResponsePayloads[TType]; +type PluginCatalogResponseEntry = + CorrelatedResponsePayload<"plugin.catalog.get.response">["plugins"][number]; +export type PluginCatalogEntry = Omit & { + clientBundle: string; +}; + export class DaemonConnectionError extends Error { readonly serverId?: string; readonly outcome?: "unknown"; @@ -1241,7 +1270,7 @@ function toReasonCode(reason: string | null | undefined): string | null { } interface PendingSend { - send: () => void; + send: () => void | Promise; resolve: () => void; reject: (error: Error) => void; timeoutHandle: ReturnType; @@ -1264,6 +1293,36 @@ export function supportsUsageReports(features: ServerInfoStatusPayload["features return features?.usageSources === true || features?.providerUsageList === true; } +export class TerminalKeepUnsupportedError extends Error { + readonly code = "TERMINALS_KEEP_UNSUPPORTED"; + // Own fields reach the CLI's JSON error output, which desktop reads. + readonly version: string | null; + + constructor(target: string, version: string | null) { + super( + `The daemon at ${target}${version ? ` (v${version})` : ""} closes terminals when it stops. Update it first, or run \`paseo daemon stop\` to stop it and close them.`, + ); + this.name = "TerminalKeepUnsupportedError"; + this.version = version; + } +} + +/** + * Gate for an explicit "keep terminals" stop. Older daemons drop `keepTerminals` and close the + * terminals anyway, so read the capability on the connection that will carry the shutdown and + * refuse before sending it. + */ +// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21. +export function requireTerminalKeepCapability( + client: Pick, + target: string, +): void { + const serverInfo = client.getLastServerInfoMessage(); + if (serverInfo?.features?.terminalsSurviveRestart !== true) { + throw new TerminalKeepUnsupportedError(target, serverInfo?.version ?? null); + } +} + function buildFetchAgentsRequest( options: FetchAgentsOptions | undefined, requestId: string, @@ -1311,6 +1370,7 @@ export class DaemonClient { failed: (error) => this.logger.error({ err: error }, "Subscription failed"), }); private transport: DaemonTransport | null = null; + private chunkedTransport: ChunkedTransport | null = null; private helloAuth: HelloAuth; private transportCleanup: Array<() => void> = []; private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set(); @@ -1340,6 +1400,15 @@ export class DaemonClient { private pendingBinaryFileReads = new Map(); private activeBinaryFileTransfers = new Map(); private completedBinaryFileReads = new Map(); + /** The last catalog's client bundles by plugin id, so later catalogs can leave them out. */ + private pluginClientBundles = new Map< + string, + { clientBundleHash: string; clientBundle: string } + >(); + private pluginClientBundleStore: PluginClientBundleStore | undefined; + private pluginClientBundlesLoaded: Promise | undefined; + private pluginClientBundlesWrite: PluginClientBundle[] | undefined; + private pluginClientBundlesWriting = false; private logger: Logger; private pendingSendQueue: PendingSend[] = []; /** @@ -1496,6 +1565,17 @@ export class DaemonClient { }); this.traceConnectionAttempt += 1; this.transport = transport; + this.chunkedTransport = new ChunkedTransport({ + get supportsDrain() { + return transport.supportsDrain; + }, + get frameBytes() { + return transport.maxFrameBytes ?? 32 * 1024 * 1024; + }, + send: (frame) => transport.send(frame), + onFailure: () => transport.close(4003, "Transport send failed"), + deliver: (frame) => this.handleTransportMessage(frame), + }); this.lastServerInfoMessage = null; this.authFailureReasonValue = null; @@ -1602,7 +1682,16 @@ export class DaemonClient { }); }), transport.onMessage((data) => { - if (this.transport === transport) this.handleTransportMessage(data); + if (this.transport !== transport) return; + const bytes = asUint8Array(data); + try { + if (typeof data === "string" || bytes) + this.chunkedTransport!.receive(typeof data === "string" ? data : bytes!); + else this.handleTransportMessage(data); + } catch (error) { + if (!(error instanceof ChunkedTransportProtocolError)) throw error; + transport.close(4003, "Invalid chunked transport"); + } }), ]; } catch (error) { @@ -1803,9 +1892,9 @@ export class DaemonClient { }) ).routeId; } - sendHostFrame(routeId: string, data: string | Uint8Array): void { + sendHostFrame(routeId: string, data: string | Uint8Array): Promise { if (typeof data === "string") { - this.sendSessionMessageStrict({ + return this.sendSessionMessageStrict({ type: "hosts.route.frame.request", routeId, data, @@ -1813,7 +1902,7 @@ export class DaemonClient { }); } else { if (!this.isConnected || !this.transport) throw this.unavailableError(); - this.sendTransportFrame(encodeHostRouteFrame(routeId, data)); + return this.sendTransportFrame(encodeHostRouteFrame(routeId, data)); } } private readonly hostFrameListeners = new Set<(frame: HostRouteFrame) => void>(); @@ -1871,6 +1960,11 @@ export class DaemonClient { // Message Subscription // ============================================================================ + /** Drain capability inherited by transports routed through this connection. */ + get supportsDrain(): boolean { + return this.transport?.supportsDrain !== false; + } + subscribe(handler: DaemonEventHandler): () => void { this.eventListeners.add(handler); return () => { @@ -1979,7 +2073,7 @@ export class DaemonClient { messageType: string, message: unknown, submitted?: () => void, - ): void { + ): Promise { const isOpen = this.config.trace?.isEnabled() === true && this.beginTraceSection("paseo.ws.message.outbound", { @@ -1988,25 +2082,30 @@ export class DaemonClient { ...describeRequestIdentity(message), }); try { - this.sendTransportFrame(JSON.stringify(message), submitted); + return this.sendTransportFrame(JSON.stringify(message), submitted); } finally { this.endTraceSection(isOpen); } } - private sendSessionPayload(payload: SessionInboundMessage): void { - this.sendJsonMessage("session", payload.type, { type: "session", message: payload }, () => { - if (!("requestId" in payload) || typeof payload.requestId !== "string") return; - for (const waiter of this.waiters) { - if (waiter.requestId === payload.requestId) waiter.submitted = true; - } - }); + private sendSessionPayload(payload: SessionInboundMessage): Promise { + return this.sendJsonMessage( + "session", + payload.type, + { type: "session", message: payload }, + () => { + if (!("requestId" in payload) || typeof payload.requestId !== "string") return; + for (const waiter of this.waiters) { + if (waiter.requestId === payload.requestId) waiter.submitted = true; + } + }, + ); } private sendTransportFrame( frame: string | Uint8Array | ArrayBuffer, submitted?: () => void, - ): void { + ): Promise { if (!this.transport) { throw this.unavailableError(); } @@ -2019,7 +2118,8 @@ export class DaemonClient { }); try { submitted?.(); - this.transport.send(frame); + const payload = typeof frame === "string" ? frame : asUint8Array(frame)!; + return this.chunkedTransport!.send(payload); } finally { this.endTraceSection(isOpen); } @@ -2048,10 +2148,10 @@ export class DaemonClient { } } - private sendBinaryFrame(frame: Uint8Array): void { + private sendBinaryFrame(frame: Uint8Array): Promise { if (!this.transport || this.connectionState.status !== "connected") { if (this.config.suppressSendErrors) { - return; + return Promise.resolve(); } throw new Error(`Transport not connected (status: ${this.connectionState.status})`); } @@ -2060,10 +2160,10 @@ export class DaemonClient { envelopeType: "binary", messageType: "binary", }); - this.sendTransportFrame(frame); + return this.sendTransportFrame(frame); } catch (error) { if (this.config.suppressSendErrors) { - return; + return Promise.resolve(); } throw error instanceof Error ? error : new Error(String(error)); } @@ -2078,7 +2178,7 @@ export class DaemonClient { private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise { return this.sendWhenConnected(() => { const payload = SessionInboundMessageSchema.parse(message); - this.sendSessionPayload(payload); + return this.sendSessionPayload(payload); }); } @@ -2087,13 +2187,12 @@ export class DaemonClient { return this.sendWhenConnected(() => undefined); } - private sendWhenConnected(send: () => void): Promise { + private sendWhenConnected(send: () => void | Promise): Promise { const status = this.connectionState.status; // If connected, send immediately if (this.transport && status === "connected") { - send(); - return Promise.resolve(); + return Promise.resolve(send()); } // If connecting, queue the message to be sent once connected @@ -2132,8 +2231,7 @@ export class DaemonClient { clearTimeout(pending.timeoutHandle); try { if (this.transport && this.connectionState.status === "connected") { - pending.send(); - pending.resolve(); + void Promise.resolve(pending.send()).then(pending.resolve, pending.reject); } else { pending.reject(new DaemonConnectionError("Connection lost before message could be sent")); } @@ -2189,19 +2287,17 @@ export class DaemonClient { { ...params.options, requestId: params.requestId }, ); + // The response deadline still applies while the transport waits for drain. + void promise.catch(() => undefined); try { - await this.sendSessionMessageOrThrow(wire.message); + const sent = this.sendSessionMessageOrThrow(wire.message); + const result = await Promise.race([promise, sent.then(() => promise)]); + if (result.kind === "error") throw result.error; + return result.value; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); cancel(err); - void promise.catch(() => undefined); throw err; - } - - try { - const result = await promise; - if (result.kind === "error") throw result.error; - return result.value; } finally { await wire.finish(); } @@ -2291,13 +2387,13 @@ export class DaemonClient { }); } - private sendSessionMessageStrict(message: SessionInboundMessage): void { + private sendSessionMessageStrict(message: SessionInboundMessage): Promise { if (!this.transport || this.connectionState.status !== "connected") { throw this.unavailableError(); } const payload = SessionInboundMessageSchema.parse(message); try { - this.sendSessionPayload(payload); + return this.sendSessionPayload(payload); } catch (error) { throw error instanceof Error ? error : new Error(String(error)); } @@ -2495,15 +2591,16 @@ export class DaemonClient { }; this.pingProbe = probe; - try { - this.sendJsonMessage("ping", "ping", { type: "ping" }); - } catch (error) { - this.clearPingProbe(); + const rejectSend = (error: unknown) => { + if (this.pingProbe !== probe) return; const sendError = error instanceof Error ? error : new Error(String(error)); - if (probe.drivesLivenessFailure) { - this.recordLivenessFailure(sendError); - } - return Promise.reject(sendError); + this.rejectPingProbe(sendError); + if (probe.drivesLivenessFailure) this.recordLivenessFailure(sendError); + }; + try { + void this.sendJsonMessage("ping", "ping", { type: "ping" }).catch(rejectSend); + } catch (error) { + rejectSend(error); } return promise; @@ -3547,9 +3644,15 @@ export class DaemonClient { agentId: string, options: FetchAgentTimelineOptions = {}, ): Promise { + // COMPAT(wholeTurnTimeline): added after v0.13.2 (#8010); remove after 2027-04-27 once the daemon floor supports whole-turn pages. + // Reading history predates this capability and must keep working on older hosts. + const supportsWholeTurns = this.lastServerInfoMessage?.features?.wholeTurnTimeline === true; const resolvedRequestId = this.createRequestId(options.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agent_timeline_request", + ...(supportsWholeTurns && options.wholeTurns !== undefined + ? { wholeTurns: options.wholeTurns } + : {}), agentId, requestId: resolvedRequestId, ...(options.direction ? { direction: options.direction } : {}), @@ -4034,13 +4137,14 @@ export class DaemonClient { async restartServer( reason?: string, requestId?: string, - options?: { timeout?: number }, + options?: { timeout?: number; killTerminals?: boolean }, ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "restart_server_request", ...(reason && reason.trim().length > 0 ? { reason } : {}), requestId: resolvedRequestId, + ...(options?.killTerminals ? { killTerminals: true } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, @@ -4068,6 +4172,7 @@ export class DaemonClient { const message = SessionInboundMessageSchema.parse({ type: "shutdown_server_request", requestId: resolvedRequestId, + ...(options?.keepTerminals ? { keepTerminals: true } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, @@ -4150,7 +4255,7 @@ export class DaemonClient { } async sendVoiceAudioChunk(audio: string, format: string, isLast = false): Promise { - this.sendSessionMessage({ type: "voice_audio_chunk", audio, format, isLast }); + return this.sendSessionMessageStrict({ type: "voice_audio_chunk", audio, format, isLast }); } async startDictationStream(dictationId: string, format: string): Promise { @@ -4191,8 +4296,13 @@ export class DaemonClient { const cleanupError = new Error("Cancelled dictation start waiter"); try { - this.sendSessionMessageStrict({ type: "dictation_stream_start", dictationId, format }); - await Promise.race([ackPromise, errorPromise]); + const sent = this.sendSessionMessageStrict({ + type: "dictation_stream_start", + dictationId, + format, + }); + const response = Promise.race([ackPromise, errorPromise]); + await Promise.race([response, sent.then(() => response)]); } finally { ack.cancel(cleanupError); streamError.cancel(cleanupError); @@ -4327,12 +4437,17 @@ export class DaemonClient { const cleanupError = new Error("Cancelled dictation finish waiter"); try { - this.sendSessionMessageStrict({ type: "dictation_stream_finish", dictationId, finalSeq }); - const firstOutcome = await Promise.race([ + const sent = this.sendSessionMessageStrict({ + type: "dictation_stream_finish", + dictationId, + finalSeq, + }); + const response = Promise.race([ finalOutcomePromise, errorOutcomePromise, finishAcceptedOutcomePromise, ]); + const firstOutcome = await Promise.race([response, sent.then(() => response)]); if (firstOutcome.kind === "final") { return firstOutcome.payload; @@ -5248,8 +5363,10 @@ export class DaemonClient { return undefined; }, ); + const sendFrame = (frame: Uint8Array) => + Promise.race([this.sendBinaryFrame(frame), responsePromise]); try { - this.sendBinaryFrame( + await sendFrame( encodeFileTransferFrame({ opcode: FileTransferOpcode.FileBegin, requestId: resolvedRequestId, @@ -5263,6 +5380,7 @@ export class DaemonClient { }), ); + if (settled) return await responsePromise; const chunkSize = input.chunkSize ?? 128 * 1024; for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { // Native WebSocket.send encodes binary synchronously. Let rendering and @@ -5272,7 +5390,7 @@ export class DaemonClient { if (this.transport !== uploadTransport || this.connectionState.status !== "connected") { throw new DaemonConnectionError("Connection changed during file upload"); } - this.sendBinaryFrame( + await sendFrame( encodeFileTransferFrame({ opcode: FileTransferOpcode.FileChunk, requestId: resolvedRequestId, @@ -5281,7 +5399,7 @@ export class DaemonClient { ); } - this.sendBinaryFrame( + await sendFrame( encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: resolvedRequestId, @@ -5547,6 +5665,34 @@ export class DaemonClient { }); } + async listLocalSpeechModels(requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.list.request" }, + responseType: "speech.local_models.list.response", + }); + if (payload.error) throw new Error(payload.error); + return payload.models; + } + + async downloadLocalSpeechModel(modelId: string, requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.download.request", modelId }, + responseType: "speech.local_models.download.response", + }); + if (payload.error) throw new Error(payload.error); + } + + async deleteLocalSpeechModel(modelId: string, requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.delete.request", modelId }, + responseType: "speech.local_models.delete.response", + }); + if (payload.error) throw new Error(payload.error); + } + sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void { this.sendSessionMessageStrict(response); } @@ -5756,14 +5902,82 @@ export class DaemonClient { }); } - async getPluginCatalog() { + async getPluginCatalog(): Promise { + if (this.config.createPluginClientBundleStore && !this.pluginClientBundleStore) { + const serverInfo = this.getLastServerInfoMessage(); + if (!serverInfo) throw new Error("Plugin catalog requires a connected host"); + this.pluginClientBundleStore = this.config.createPluginClientBundleStore(serverInfo.serverId); + } + const store = this.pluginClientBundleStore; + if (store) { + this.pluginClientBundlesLoaded ??= store.load().then( + (bundles) => { + for (const { id, ...bundle } of bundles) this.pluginClientBundles.set(id, bundle); + return undefined; + }, + (error) => this.logger.warn({ err: error }, "Failed to load plugin client bundles"), + ); + await this.pluginClientBundlesLoaded; + } const requestId = this.createRequestId(); + const cached = this.pluginClientBundles; + const cachedClientBundles = [...cached].map(([id, { clientBundleHash }]) => ({ + id, + clientBundleHash, + })); const payload = await this.sendCorrelatedSessionRequest({ requestId, - message: { type: "plugin.catalog.get.request", requestId }, + message: { + type: "plugin.catalog.get.request", + requestId, + ...(cachedClientBundles.length > 0 ? { cachedClientBundles } : {}), + }, responseType: "plugin.catalog.get.response", }); - return payload.plugins; + const retained = new Map(); + const plugins = payload.plugins.map(({ clientBundle: sent, ...plugin }): PluginCatalogEntry => { + const previous = cached.get(plugin.id); + const clientBundle = + sent ?? + (previous?.clientBundleHash === plugin.clientBundleHash + ? previous?.clientBundle + : undefined); + if (clientBundle === undefined) { + throw new Error(`Plugin catalog left out the client bundle for ${plugin.id}`); + } + // COMPAT(pluginCatalogBundleHash): introduced by PR #6732 (unreleased); remove after + // 2027-04-27 once the daemon floor includes bundle hashes. Older bundles are not retained. + if (plugin.clientBundleHash !== undefined) { + retained.set(plugin.id, { clientBundleHash: plugin.clientBundleHash, clientBundle }); + } + return Object.assign(plugin, { clientBundle }); + }); + this.pluginClientBundles = retained; + if (store) { + this.pluginClientBundlesWrite = [...retained].map(([id, bundle]) => + Object.assign({ id }, bundle), + ); + void this.persistPluginClientBundles(store); + } + return plugins; + } + + private async persistPluginClientBundles(store: PluginClientBundleStore): Promise { + if (this.pluginClientBundlesWriting) return; + this.pluginClientBundlesWriting = true; + try { + while (this.pluginClientBundlesWrite) { + const bundles = this.pluginClientBundlesWrite; + this.pluginClientBundlesWrite = undefined; + try { + await store.replace(bundles); + } catch (error) { + this.logger.warn({ err: error }, "Failed to persist plugin client bundles"); + } + } + } finally { + this.pluginClientBundlesWriting = false; + } } async listPlugins(): Promise { @@ -6246,7 +6460,8 @@ export class DaemonClient { } /** - * Snapshot/restore and output arrive in receive. Subscribe to the returned handle + * Defaults to a full ANSI replay. Replay begin/data/end and live 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. */ @@ -6265,7 +6480,7 @@ export class DaemonClient { { type: "subscribe_terminal_request", terminalId, - ...(options?.restore ? { restore: options.restore } : {}), + restore: options?.restore ?? { mode: "full-snapshot" }, }, options, ); @@ -6595,6 +6810,8 @@ export class DaemonClient { } private disposeTransport(code = 1001, reason = "Reconnecting"): void { + this.chunkedTransport?.close(); + this.chunkedTransport = null; this.owned.disconnected(); this.providerSnapshotUpdates.clear(); this.stopLivenessHeartbeat(); @@ -7064,15 +7281,6 @@ export class DaemonClient { probe.resolve(perfNow() - probe.startedAt); } - private clearPingProbe(): void { - const probe = this.pingProbe; - if (!probe) { - return; - } - this.pingProbe = null; - clearTimeout(probe.timeoutHandle); - } - private rejectPingProbe(error: Error): void { const probe = this.pingProbe; if (!probe) { @@ -7121,6 +7329,10 @@ export class DaemonClient { if (consumerMessage.type === "status") { const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload); if (serverInfo) { + if (this.chunkedTransport) + this.chunkedTransport.enabled = + serverInfo.features?.binaryChunkingV1 === true && + this.config.capabilities?.[CLIENT_CAPS.binaryChunkingV1] !== false; this.lastServerInfoMessage = serverInfo; this.destinationServerId = serverInfo.serverId; this.connectionFailure = undefined; diff --git a/packages/client/src/hosts/index.ts b/packages/client/src/hosts/index.ts index 1baa9ebe43..aa5c910158 100644 --- a/packages/client/src/hosts/index.ts +++ b/packages/client/src/hosts/index.ts @@ -1,3 +1,4 @@ +import { MAX_HOST_CHANNEL_FRAME_BYTES } from "@getpaseo/protocol/binary-frames/index"; import { safeRandomId } from "../daemon-client-transport-utils.js"; import { DaemonClient } from "../daemon-client.js"; import type { DaemonTransportFactory } from "../daemon-client-transport-types.js"; @@ -84,13 +85,17 @@ export function createHostChannelTransport( }) .catch(close); return { + get supportsDrain() { + return origin.supportsDrain; + }, + maxFrameBytes: MAX_HOST_CHANNEL_FRAME_BYTES, send(data) { if (closed || !routeId) throw Object.assign(new Error(`Host ${serverId} is offline`), { code: "host_offline", serverId, }); - origin.sendHostFrame( + return origin.sendHostFrame( routeId, typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data), ); diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index a7bb17121c..1b11a3032f 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1770,3 +1770,66 @@ test("qualified parents preserve foreign identity without resolving an equal loc await client.close(); expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" }); }); + +test("agent reload reopens through the daemon RPC then returns the observed snapshot; refresh remains a read", async () => { + const { client, ws } = await connectClient(); + try { + const agent = client.agents.ref("agent_sdk"); + const snapshot = createAgent(); + const reloaded = agent.reload({ requestId: "reload-sdk" }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "refresh_agent_request", + agentId: "agent_sdk", + requestId: "reload-sdk", + }); + ws.message( + sessionMessage({ + type: "status", + payload: { + status: "agent_refreshed", + requestId: "reload-sdk", + agentId: "agent_sdk", + timelineSize: 0, + }, + }), + ); + await expect + .poll(() => parseSentSessionMessage(ws.sent.at(-1)).type) + .toBe("fetch_agent_request"); + const fetched = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "fetch_agent_response", + payload: { + requestId: fetched.requestId, + agentId: "agent_sdk", + agent: snapshot, + project: null, + error: null, + }, + }), + ); + expect(await reloaded).toEqual({ agent: snapshot, project: null }); + expect(agent.current()).toEqual(snapshot); + const read = agent.refresh("snapshot-read"); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "fetch_agent_request", + requestId: "snapshot-read", + }); + ws.message( + sessionMessage({ + type: "fetch_agent_response", + payload: { + requestId: "snapshot-read", + agentId: "agent_sdk", + agent: snapshot, + project: null, + error: null, + }, + }), + ); + expect(await read).toEqual({ agent: snapshot, project: null }); + } finally { + await client.close(); + } +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 455d377634..3655b0023a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,4 @@ +export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js"; import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; export type { AgentAddress } from "@getpaseo/protocol/addresses"; @@ -369,6 +370,11 @@ export interface PaseoAgentHandle { readonly timeline: PaseoAgentTimelineHandle; current(): PaseoAgent | null; refresh(requestId?: string): Promise; + /** + * Reopens the provider session with current configuration, rerunning session-open hooks. + * Interrupts a running turn and returns the refreshed snapshot. + */ + reload(options?: { requestId?: string }): Promise; send(text: string, options?: PaseoAgentSendOptions): Promise; respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise; /** Sends a prompt and resolves when that turn finishes or needs attention. */ @@ -981,6 +987,12 @@ function createAgentHandleFactory( current = result?.agent ?? null; return result; }, + reload: async (options) => { + await daemonClient.refreshAgent(id, options?.requestId); + const result = await daemonClient.fetchAgent({ agentId: id }); + current = result?.agent ?? null; + return result; + }, send: async (text, options) => { await daemonClient.sendAgentMessage(id, text, options); }, diff --git a/packages/client/src/node/connector.test.ts b/packages/client/src/node/connector.test.ts index 6e916d4bec..882a2f5706 100644 --- a/packages/client/src/node/connector.test.ts +++ b/packages/client/src/node/connector.test.ts @@ -44,12 +44,14 @@ test("a slow consumer cannot exceed the shared byte budget and close releases re socket.pause(); await ready; const frame = new Uint8Array(1024 * 1024); - for (let index = 0; index < 4; index++) transport.send(frame); + const writes = Array.from({ length: 4 }, () => transport.send(frame)); + const drained = Promise.allSettled(writes); expect(budget.bufferedBytes).toBe(4 * 1024 * 1024); expect(() => transport.send(frame)).toThrow("Connected host buffer limit reached"); expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit); transport.close(); await expect.poll(() => budget.bufferedBytes).toBe(0); + await drained; }); test("direct connector preserves 25 MiB binary frames in both directions", async () => { const server = await endpoint(); @@ -65,12 +67,13 @@ test("direct connector preserves 25 MiB binary frames in both directions", async await ready; const bytes = new Uint8Array(25 * 1024 * 1024).fill(173); const echoed = once(socket, "message"); - transport.send(bytes); + const sending = transport.send(bytes); const [sent, binary] = await echoed; expect(binary).toBe(true); expect(Buffer.from(sent as Uint8Array).equals(bytes)).toBe(true); socket.send(bytes); expect(Buffer.from(await received).equals(bytes)).toBe(true); + await sending; }); import { @@ -124,24 +127,29 @@ test("encrypted connector rejects overload synchronously and preserves the physi }); await new Promise((resolve) => transport.onOpen(resolve)); await ready; + expect(transport.maxFrameBytes).toBeLessThan(32 * 1024 * 1024); peer.pause(); const frame = new Uint8Array(1024 * 1024).fill(173); let rejected: unknown; + const writes: (void | Promise)[] = []; for (let index = 0; index < 8; index++) { try { - transport.send(frame); + writes.push(transport.send(frame)); } catch (error) { rejected = error; break; } } + const drained = Promise.allSettled(writes); expect(rejected).toMatchObject({ code: "resource_limit" }); expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit); await new Promise((resolve) => setImmediate(resolve)); expect(errors).toEqual([]); expect(closed).toBe(false); - transport.send("sibling control"); + const sibling = transport.send("sibling control"); peer.resume(); + await drained; + await sibling; await expect.poll(() => received.includes("sibling control")).toBe(true); expect(received.filter((data) => data instanceof ArrayBuffer).length).toBeGreaterThan(0); expect( diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts index 7132d09f10..464d16332d 100644 --- a/packages/client/src/node/index.ts +++ b/packages/client/src/node/index.ts @@ -2,6 +2,7 @@ export { createHostChannelTransport } from "../hosts/index.js"; import { HostTransportBudget } from "./buffering.js"; export { HostTransportBudget } from "./buffering.js"; import { homedir } from "node:os"; +import type { Agent } from "node:http"; import { WebSocket } from "ws"; import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target"; import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js"; @@ -19,10 +20,15 @@ export { parseHostTarget }; export type { SshTunnel } from "./ssh-tunnel.js"; export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js"; +export interface HostTransportOptions { + webSocketAgent?: (url: string) => Agent | undefined; +} + /** A factory owns one attempt, including a fresh SSH process and its teardown. */ export function createHostTransportFactory( connection: string, budget = new HostTransportBudget(), + options: HostTransportOptions = {}, ): DaemonTransportFactory { const target = parseHostTarget(connection); // Rises to "protected" once the daemon negotiates it, so a later attempt @@ -68,12 +74,13 @@ export function createHostTransportFactory( return; } const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir()); - let factory = createWebSocketTransportFactory((url, options) => { + let factory = createWebSocketTransportFactory((url, socketOptions) => { const socket = new WebSocket( socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url, - options?.protocols, + socketOptions?.protocols, { - headers: options?.headers, + headers: socketOptions?.headers, + agent: socketPath ? undefined : options.webSocketAgent?.(url), ...(socketPath ? { socketPath } : {}), maxPayload: 100 * 1024 * 1024, }, @@ -165,9 +172,15 @@ export function createHostTransportFactory( close(); }); return { + get supportsDrain() { + return transport?.supportsDrain; + }, + get maxFrameBytes() { + return transport?.maxFrameBytes; + }, send(data) { if (closed || !transport) throw new Error("Host transport is unavailable"); - transport.send(data); + return transport.send(data); }, close, onOpen(handler) { @@ -191,14 +204,16 @@ export function createHostTransportFactory( } export function createHostClient( connection: string, - config: Omit, + config: Omit & HostTransportOptions, budget?: HostTransportBudget, ): DaemonClient { const target = parseHostTarget(connection); + const { webSocketAgent, transportFactory, ...clientConfig } = config; return new DaemonClient({ - ...config, + ...clientConfig, url: "ws://host-connector/ws", ...(target.password ? { password: target.password } : {}), - transportFactory: createHostTransportFactory(connection, budget), + transportFactory: + transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }), }); } diff --git a/packages/client/src/node/ssh-tunnel.test.ts b/packages/client/src/node/ssh-tunnel.test.ts new file mode 100644 index 0000000000..6bdfe27fe4 --- /dev/null +++ b/packages/client/src/node/ssh-tunnel.test.ts @@ -0,0 +1,55 @@ +import { execFile, spawnSync } from "node:child_process"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const run = promisify(execFile); +const hasSsh = !spawnSync("ssh", ["-V"]).error; + +test.skipIf(!hasSsh)( + "an SSH peer abort during a pending write closes the tunnel without crashing its caller", + async () => { + // Isolate the caller so an unhandled stream error fails this assertion instead + // of crashing the test worker. Both the TCP peer and OpenSSH are real. + const { stdout } = await run( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "-e", + ` + import { createServer, createConnection } from 'node:net'; + import { once } from 'node:events'; + import { createSshTunnel } from ${JSON.stringify(new URL("./ssh-tunnel.ts", import.meta.url).href)}; + + const remote = createServer(socket => socket.destroy()); + remote.listen(0, '127.0.0.1'); + await once(remote, 'listening'); + const tunnel = await createSshTunnel({ + host: '127.0.0.1', sshPort: remote.address().port, daemonPort: 6767, + }); + const [host, port] = tunnel.endpoint.split(':'); + const client = createConnection({ host, port: Number(port) }); + client.on('error', () => undefined); + try { + const closed = new Promise(resolve => client.once('close', resolve)); + await once(client, 'connect'); + client.write(Buffer.alloc(8 * 1024 * 1024, 65)); + await closed; + console.log(JSON.stringify({ closed: client.destroyed, failure: tunnel.failureDetail() })); + } finally { + client.destroy(); + tunnel.close(); + await new Promise(resolve => remote.close(resolve)); + } + `, + ], + { timeout: 10_000 }, + ); + expect(JSON.parse(stdout)).toEqual({ + closed: true, + failure: expect.any(String), + }); + expect(JSON.parse(stdout).failure).not.toBe(""); + }, +); diff --git a/packages/client/src/node/ssh-tunnel.ts b/packages/client/src/node/ssh-tunnel.ts index 33900d0baa..a67c0a91c4 100644 --- a/packages/client/src/node/ssh-tunnel.ts +++ b/packages/client/src/node/ssh-tunnel.ts @@ -54,10 +54,12 @@ export function createSshTunnel(target: SshTransportTarget): Promise child.stderr.on("data", (chunk: Buffer | string) => { stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT); }); - child.on("error", (error) => { + const fail = (error: Error) => { failure = error.message; acceptedSocket.destroy(error); - }); + }; + child.on("error", fail); + child.stdin.on("error", fail); child.on("exit", (code, signal) => { if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal); acceptedSocket.destroy(failure ? new Error(failure) : undefined); diff --git a/packages/client/src/terminal-stream-router.test.ts b/packages/client/src/terminal-stream-router.test.ts index 7b9e598410..b8ed727117 100644 --- a/packages/client/src/terminal-stream-router.test.ts +++ b/packages/client/src/terminal-stream-router.test.ts @@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest"; import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js"; describe("terminal-stream-router", () => { - test("routes restore frames as restore events", () => { + test("normalizes old ANSI restore into the replay lifecycle", () => { const router = new TerminalStreamRouter(); const events: TerminalStreamEvent[] = []; const payload = new TextEncoder().encode("restored screen"); @@ -16,14 +16,14 @@ describe("terminal-stream-router", () => { payload, }); - expect(events).toEqual([ - { + expect(events).toEqual( + ["begin", "data", "end"].map((type) => ({ terminalId: "term-1", subscriptionId: "subscription-1", - type: "restore", - data: payload, - }, - ]); + type: "replay", + replay: type === "data" ? { type, data: payload } : { type }, + })), + ); }); }); @@ -61,3 +61,45 @@ test("legacy listeners share a terminal slot without releasing each other", () = router.handleFrame(frame); expect(received).toEqual(["first", "second", "second"]); }); + +test("streams chunks without joining and ignores released slots during replay", () => { + const router = new TerminalStreamRouter(); + const events: TerminalStreamEvent[] = []; + const release = router.attach("old", "terminal", 4, (event) => events.push(event)); + const payload = new TextEncoder().encode( + JSON.stringify({ rows: 24, cols: 80, retainedHistoryLines: 1000 }), + ); + router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 4, payload }); + const data = new TextEncoder().encode("\x1b[31m界🙂"); + router.handleFrame({ + opcode: TerminalStreamOpcode.ReplayData, + slot: 4, + payload: data.subarray(0, 7), + }); + release(); + router.attach("new", "terminal", 5, (event) => events.push(event)); + router.handleFrame({ + opcode: TerminalStreamOpcode.ReplayData, + slot: 4, + payload: data.subarray(7), + }); + router.handleFrame({ + opcode: TerminalStreamOpcode.ReplayEnd, + slot: 4, + payload: new Uint8Array(), + }); + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + subscriptionId: "old", + type: "replay", + replay: { type: "data", data: data.subarray(0, 7) }, + }); + router.handleFrame({ opcode: TerminalStreamOpcode.ReplayBegin, slot: 5, payload }); + router.handleFrame({ opcode: TerminalStreamOpcode.ReplayData, slot: 5, payload: data }); + router.handleFrame({ + opcode: TerminalStreamOpcode.ReplayEnd, + slot: 5, + payload: new Uint8Array(), + }); + expect(events.slice(2).map((event) => event.subscriptionId)).toEqual(["new", "new", "new"]); +}); diff --git a/packages/client/src/terminal-stream-router.ts b/packages/client/src/terminal-stream-router.ts index a2c9575450..bca9fb3ea1 100644 --- a/packages/client/src/terminal-stream-router.ts +++ b/packages/client/src/terminal-stream-router.ts @@ -1,14 +1,17 @@ import { - decodeTerminalSnapshotPayload, TerminalStreamOpcode, + TerminalReplayMetadataSchema, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { TerminalState } from "@getpaseo/protocol/messages"; + +export type TerminalReplayEvent = + | { type: "begin"; rows?: number; cols?: number; retainedHistoryLines?: number } + | { type: "data"; data: Uint8Array } + | { type: "end" }; export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & ( | { type: "output"; data: Uint8Array } - | { type: "snapshot"; state: TerminalState } - | { type: "restore"; data: Uint8Array } + | { type: "replay"; replay: TerminalReplayEvent } ); export class TerminalStreamRouter { @@ -61,20 +64,42 @@ export class TerminalStreamRouter { 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) { + else if (frame.opcode === TerminalStreamOpcode.Restore) { + // COMPAT(terminalSingleRestore): added after v0.14.0; remove when ANSI host floor supports chunked replay after 2027-04-30. + for (const replay of [ + { type: "begin" }, + { type: "data", data: frame.payload }, + { type: "end" }, + ] as TerminalReplayEvent[]) + this.deliver(registration.receive, { ...identity, type: "replay", replay }); + continue; + } else if (frame.opcode === TerminalStreamOpcode.ReplayBegin) { + let raw: unknown; try { - listener(event); + raw = JSON.parse(new TextDecoder().decode(frame.payload)); } catch { - /* Passive diagnostics cannot interrupt delivery. */ + return; } + const parsed = TerminalReplayMetadataSchema.safeParse(raw); + if (!parsed.success) return; + const metadata = parsed.data; + event = { ...identity, type: "replay", replay: { type: "begin", ...metadata } }; + } else if (frame.opcode === TerminalStreamOpcode.ReplayData) + event = { ...identity, type: "replay", replay: { type: "data", data: frame.payload } }; + else if (frame.opcode === TerminalStreamOpcode.ReplayEnd) + event = { ...identity, type: "replay", replay: { type: "end" } }; + else return; + this.deliver(registration.receive, event); + } + } + + private deliver(receive: (event: TerminalStreamEvent) => void, event: TerminalStreamEvent): void { + receive(event); + for (const listener of this.listeners) { + try { + listener(event); + } catch { + /* Passive diagnostics cannot interrupt delivery. */ } } } diff --git a/packages/desktop/assets/editor-targets/xcode.png b/packages/desktop/assets/editor-targets/xcode.png new file mode 100644 index 0000000000..1c1ba1549d Binary files /dev/null and b/packages/desktop/assets/editor-targets/xcode.png differ diff --git a/packages/desktop/e2e/appearance-theme.electron.mjs b/packages/desktop/e2e/appearance-theme.electron.mjs new file mode 100644 index 0000000000..426c50f355 --- /dev/null +++ b/packages/desktop/e2e/appearance-theme.electron.mjs @@ -0,0 +1,158 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import electronPath from "electron"; +import { build } from "esbuild"; + +export async function runAdaptiveThemeRegression(artifactDir) { + const { outputFiles } = await build({ + stdin: { + resolveDir: fileURLToPath(new URL("../../app", import.meta.url)), + contents: ` + import { createElement } from "react"; + import { createRoot } from "react-dom/client"; + import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; + StyleSheet.configure({ + themes: { + dark: { foreground: "#fafafa", background: "#181b1a" }, + light: { foreground: "#1a1a1e", background: "#ffffff" }, + }, + settings: { adaptiveThemes: true }, + }); + const Message = withUnistyles(({ color }) => + createElement("p", { style: { color } }, "Theme-reactive message")); + createRoot(document.getElementById("root")).render(createElement(Message, { + style: { margin: 0 }, + uniProps: theme => ({ color: theme.foreground }), + })); + window.readThemeForTest = () => UnistylesRuntime.themeName; + `, + }, + bundle: true, + write: false, + format: "iife", + platform: "browser", + alias: { "react-native": "react-native-web" }, + define: { __DEV__: "false" }, + resolveExtensions: [".web.js", ".js", ".json"], + }); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-adaptive-theme-")); + try { + await fs.writeFile(path.join(directory, "renderer.js"), outputFiles[0].text); + await fs.writeFile(path.join(directory, "main.cjs"), `(${electronRegression.toString()})()`); + // A separate process avoids Playwright's focus emulation, which keeps hidden pages visible. + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const args = [ + ...(process.platform === "linux" ? ["--no-sandbox"] : []), + path.join(directory, "main.cjs"), + artifactDir, + ]; + const command = process.platform === "linux" ? "xvfb-run" : electronPath; + const commandArgs = + process.platform === "linux" + ? ["-a", "--server-args=-screen 0 1280x800x24", electronPath, ...args] + : args; + await promisify(execFile)(command, commandArgs, { + env, + timeout: 30_000, + }); + return JSON.parse(await fs.readFile(path.join(artifactDir, "adaptive-theme.json"), "utf8")); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +} + +async function electronRegression() { + const { app, BrowserWindow, nativeTheme } = require("electron"); + const assert = require("node:assert/strict"); + const files = require("node:fs"); + const paths = require("node:path"); + const { setTimeout: delay } = require("node:timers/promises"); + app.setPath("userData", paths.join(__dirname, "profile")); + app.on("window-all-closed", () => {}); + // Electron keeps Linux's --no-sandbox in argv; the artifact directory is always last. + const artifactDir = process.argv.at(-1); + let window; + const observations = []; + const colors = { + dark: { foreground: "rgb(250, 250, 250)", background: "rgb(24, 27, 26)" }, + light: { foreground: "rgb(26, 26, 30)", background: "rgb(255, 255, 255)" }, + }; + async function read(expression) { + return window.webContents.executeJavaScript(expression); + } + async function expectValue(expression, expected) { + let actual; + for (let attempt = 0; attempt < 50; attempt++) { + actual = await read(expression); + if (actual === expected) return; + await delay(50); + } + assert.equal(actual, expected, expression); + } + try { + await app.whenReady(); + if (process.platform === "darwin") { + app.setActivationPolicy("accessory"); + app.dock.hide(); + } + for (const [from, to] of [ + ["dark", "light"], + ["light", "dark"], + ]) { + nativeTheme.themeSource = from; + window = new BrowserWindow({ width: 600, height: 200, show: false, skipTaskbar: true }); + await window.loadURL( + "data:text/html," + + encodeURIComponent(` + +
`), + ); + await read(files.readFileSync(paths.join(__dirname, "renderer.js"), "utf8")); + window.showInactive(); + const foreground = + "document.querySelector('p') && getComputedStyle(document.querySelector('p')).color"; + const background = "getComputedStyle(document.body).backgroundColor"; + await expectValue(foreground, colors[from].foreground); + await expectValue(background, colors[from].background); + window.hide(); + await expectValue("document.visibilityState", "hidden"); + nativeTheme.themeSource = to; + await expectValue(`matchMedia('(prefers-color-scheme: ${to})').matches`, true); + // Reading the public runtime while hidden must not consume the pending theme notification. + assert.equal(await read("window.readThemeForTest()"), to); + window.showInactive(); + await expectValue("document.visibilityState", "visible"); + await expectValue(background, colors[to].background); + try { + await expectValue(foreground, colors[to].foreground); + } finally { + files.writeFileSync( + paths.join(artifactDir, `theme-${from}-to-${to}.png`), + (await window.webContents.capturePage()).toPNG(), + ); + } + observations.push({ + from, + to, + foreground: await read(foreground), + background: await read(background), + }); + window.destroy(); + window = undefined; + } + files.writeFileSync( + paths.join(artifactDir, "adaptive-theme.json"), + JSON.stringify(observations, null, 2), + ); + app.exit(0); + } catch (error) { + console.error(error); + window?.destroy(); + app.exit(1); + } +} diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index d403bfdfc3..28afbab94f 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -14,6 +14,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { chromium } from "playwright"; import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; +import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs"; +import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs"; @@ -455,17 +457,17 @@ function recordViewportMismatch(failures, label, actual, expected) { ); } -async function setWindowHidden(inspectorPort, hidden) { +async function evaluateDesktopWindow(inspectorPort, expression) { 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) => { + return 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(); + else resolve(response.result.result.value); }); socket.addEventListener( "open", @@ -475,7 +477,8 @@ async function setWindowHidden(inspectorPort, hidden) { 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'); })()`, + expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); ${expression} })()`, + returnByValue: true, }, }), ), @@ -487,6 +490,20 @@ async function setWindowHidden(inspectorPort, hidden) { } } +async function setWindowHidden(inspectorPort, hidden) { + await evaluateDesktopWindow( + inspectorPort, + `win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change');`, + ); +} + +async function resizeDesktopWindow(inspectorPort, { width, height }) { + return evaluateDesktopWindow( + inspectorPort, + `const [width, height] = win.getContentSize(); win.setContentSize(${width}, ${height}); return { width, height };`, + ); +} + async function verifyHiddenBrowserScreenshots({ page, client, @@ -1208,6 +1225,14 @@ async function main() { console.log("Plugin external links and workspace browser passed."); return; } + const settingsScroll = await runSettingsScrollRegression(page, (size) => + resizeDesktopWindow(inspectorPort, size), + ); + if (process.env.PASEO_DESKTOP_SETTINGS_SCROLL_ONLY === "1") { + writeJson(path.join(artifactDir, "result.json"), { settingsScroll }); + console.log("Desktop Settings scroll regression passed."); + return; + } const settingsMemory = await runSettingsMemoryRegression(page); if (process.env.PASEO_DESKTOP_SETTINGS_MEMORY_ONLY === "1") { writeJson(path.join(artifactDir, "result.json"), { settingsMemory }); @@ -1217,6 +1242,7 @@ async function main() { return; } + const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir); await runAppearanceFontSizeRegression(page); const callerAgentId = await createCallerAgent(daemonPort); @@ -1241,7 +1267,12 @@ async function main() { artifactDir, }); const pluginLinks = await checkPluginLinks(); - writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks }); + writeJson(path.join(artifactDir, "result.json"), { + ...report, + settingsMemory, + pluginLinks, + adaptiveTheme, + }); console.log( `Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`, ); diff --git a/packages/desktop/e2e/open-in-editor.spec.ts b/packages/desktop/e2e/open-in-editor.spec.ts index 739974ff0e..7521f7655d 100644 --- a/packages/desktop/e2e/open-in-editor.spec.ts +++ b/packages/desktop/e2e/open-in-editor.spec.ts @@ -3,6 +3,13 @@ import path from "node:path"; import { expect, test, type Page } from "../../app/e2e/support/fixtures"; import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; import { expandFolder, openFileExplorer } from "../../app/e2e/support/helpers/file-explorer"; +import { + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + archiveWorkspaceFromDaemon, +} from "../../app/e2e/support/helpers/new-workspace"; +import type { WithWorkspace } from "../../app/e2e/support/helpers/with-workspace"; +import { selectWorkspaceInSidebar } from "../../app/e2e/support/helpers/sidebar"; import { installDesktopRuntime } from "./support/runtime"; import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings"; @@ -38,14 +45,20 @@ async function readEditorOpenRecords(recordPath: string): Promise { - await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({ + await expect(page.getByRole("button", { name: /^Open workspace in / })).toBeVisible({ timeout: 30_000, }); - await page.getByTestId("workspace-open-in-editor-caret").click(); + await page.getByRole("button", { name: "Choose editor", exact: true }).click(); await expect(page.getByTestId("workspace-open-in-editor-menu")).toBeVisible(); await page.getByTestId(`workspace-open-in-editor-item-${targetId}`).click(); } +async function expectPreferredEditor(page: Page, label: string): Promise { + await expect( + page.getByRole("button", { name: `Open workspace in ${label}`, exact: true }), + ).toBeVisible(); +} + async function expectEditorOpened(input: { recordPath: string; editorId: string; @@ -67,7 +80,82 @@ async function expectEditorOpened(input: { .toBe(true); } +async function createEditorPreferenceFixture(page: Page, withWorkspace: WithWorkspace) { + const serverId = requireE2EEnv("E2E_SERVER_ID"); + await installDesktopRuntime(page, { + serverId, + editorTargets: [ + { + id: "cursor", + label: "Cursor", + kind: "editor", + icon: { kind: "symbol", name: "terminal" }, + }, + { + id: "vscode", + label: "VS Code", + kind: "editor", + icon: { kind: "symbol", name: "terminal" }, + }, + ], + }); + const projectA = await withWorkspace({ prefix: "editor-project-a-" }); + const projectB = await withWorkspace({ prefix: "editor-project-b-" }); + const client = await connectNewWorkspaceDaemonClient(); + const worktree = await createWorktreeViaDaemon(client, { + cwd: projectA.repoPath, + slug: "editor-preference-sibling", + }); + return { + projectA, + projectB, + worktree, + async close() { + try { + await archiveWorkspaceFromDaemon(client, worktree.workspaceDirectory); + } finally { + await client.close(); + } + }, + }; +} + test.describe("Workspace open in editor", () => { + test("keeps editor choices per project across switching, reload and worktrees", async ({ + page, + withWorkspace, + }) => { + test.setTimeout(120_000); + const fixture = await createEditorPreferenceFixture(page, withWorkspace); + const { projectA, projectB, worktree } = fixture; + try { + await projectA.navigateTo(); + await selectEditorTarget(page, "vscode"); + await expectPreferredEditor(page, "VS Code"); + await projectB.navigateTo(); + await expectPreferredEditor(page, "Cursor"); + await selectEditorTarget(page, "cursor"); + await projectA.navigateTo(); + await expectPreferredEditor(page, "VS Code"); + await selectWorkspaceInSidebar(page, worktree.workspaceId); + await expectPreferredEditor(page, "VS Code"); + await selectEditorTarget(page, "cursor"); + await projectA.navigateTo(); + await expectPreferredEditor(page, "Cursor"); + await selectEditorTarget(page, "vscode"); + await page.reload(); + await expectPreferredEditor(page, "VS Code"); + await projectB.navigateTo(); + await expectPreferredEditor(page, "Cursor"); + await page.reload(); + await expectPreferredEditor(page, "Cursor"); + await projectA.navigateTo(); + await expectPreferredEditor(page, "VS Code"); + } finally { + await fixture.close(); + } + }); + test("opens a nested folder in the preferred editor", async ({ page, withWorkspace }) => { test.setTimeout(90_000); diff --git a/packages/desktop/e2e/settings-scroll.electron.mjs b/packages/desktop/e2e/settings-scroll.electron.mjs new file mode 100644 index 0000000000..e72656db02 --- /dev/null +++ b/packages/desktop/e2e/settings-scroll.electron.mjs @@ -0,0 +1,68 @@ +import { expect } from "@playwright/test"; + +export async function runSettingsScrollRegression(page, resizeWindow) { + // Device-metrics emulation also pins later webview guests. Resize the real + // window so the following browser regression starts without an override. + const original = await resizeWindow({ width: 1000, height: 500 }); + const sidebar = () => page.locator('[data-testid="settings-sidebar-scroll-body"]:visible'); + const offset = () => sidebar().evaluate((node) => node.scrollTop); + const observations = []; + try { + await expect + .poll(() => page.evaluate(() => ({ width: innerWidth, height: innerHeight }))) + .toEqual({ width: 1000, height: 500 }); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await sidebar().hover(); + await page.mouse.wheel(0, 2000); + await expect.poll(offset).toBeGreaterThan(0); + const saved = await offset(); + for (const title of ["Providers", "Terminals", "Providers", "Terminals"]) { + const button = page + .locator('[data-testid="settings-sidebar"]:visible') + .getByRole("button", { name: title, exact: true }); + await expect(button).toBeInViewport(); + await button.click(); + await expect(page.locator('[data-testid="settings-detail-header-title"]:visible')).toHaveText( + title, + ); + await expect.poll(offset).toBe(saved); + observations.push({ title, offset: await offset() }); + } + const detail = page.locator('[data-testid="settings-detail-scroll-body"]:visible'); + await page.getByRole("button", { name: "Providers", exact: true }).click(); + await detail.hover(); + await page.mouse.wheel(0, 1000); + await expect.poll(() => detail.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + await expect.poll(offset).toBe(saved); + observations.push({ + independentDetailOffset: await detail.evaluate((node) => node.scrollTop), + sidebarOffset: await offset(), + }); + await sidebar().hover(); + await page.mouse.wheel(0, -2000); + await expect.poll(offset).toBe(0); + await page.mouse.wheel(0, 60); + await expect.poll(offset).toBe(60); + for (const title of ["Sidebar", "Appearance", "Sidebar"]) { + const button = page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: title, exact: true }); + await expect(button).toBeInViewport(); + await button.click(); + await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title); + await expect.poll(offset).toBe(60); + observations.push({ title, offset: await offset() }); + } + await page.locator('[data-testid="settings-back-to-workspace"]:visible').click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect.poll(offset).toBe(0); + observations.push({ reentryOffset: await offset() }); + await page.locator('[data-testid="settings-back-to-workspace"]:visible').click(); + return observations; + } finally { + await resizeWindow(original); + await expect + .poll(() => page.evaluate(() => ({ width: innerWidth, height: innerHeight }))) + .toEqual(original); + } +} diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b96b9f88e6..23198969dc 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.13.0", + "version": "0.15.0-beta.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", @@ -41,7 +41,7 @@ "@types/node": "24.6.0", "@types/ws": "^8.5.14", "electron": "44.2.0", - "electron-builder": "26.8.1", + "electron-builder": "26.18.0", "typescript": "5.9.3", "unzip-crx-3": "^0.2.0", "vitest": "^4.1.6", diff --git a/packages/desktop/src/daemon/cli/external.ts b/packages/desktop/src/daemon/cli/external.ts index 6c9deeccb1..4a55adff1c 100644 --- a/packages/desktop/src/daemon/cli/external.ts +++ b/packages/desktop/src/daemon/cli/external.ts @@ -4,6 +4,31 @@ import type { NodeEntrypointInvocation } from "../node-entrypoint-launcher.js"; import { createNodeEntrypointInvocation } from "../runtime-paths.js"; import { resolveExternalCliEntrypoint } from "./entrypoints.js"; +/** A CLI command that exited non-zero, with the `--json` error it printed when there was one. */ +export class ExternalCliCommandError extends Error { + readonly code: string | null; + readonly error: Record | null; + + constructor(message: string, error: Record | null) { + super(message); + this.name = "ExternalCliCommandError"; + this.error = error; + this.code = typeof error?.code === "string" ? error.code : null; + } +} + +// `--json` commands print `{ "error": { code, message, ... } }` on stderr when they fail. +function parseJsonCliError(stderr: string): Record | null { + try { + const parsed = JSON.parse(stderr) as { error?: unknown }; + return typeof parsed.error === "object" && parsed.error !== null + ? (parsed.error as Record) + : null; + } catch { + return null; + } +} + function createExternalCliInvocation(args: string[]): NodeEntrypointInvocation { return createNodeEntrypointInvocation({ entrypoint: resolveExternalCliEntrypoint(), @@ -85,7 +110,10 @@ export async function runExternalCliJsonCommand(args: string[]): Promise ({ paseoHome: "", @@ -73,7 +79,8 @@ vi.mock("./runtime-paths.js", () => ({ })), })); -vi.mock("./cli/external.js", () => ({ +vi.mock("./cli/external.js", async (importOriginal) => ({ + ...(await importOriginal>()), runExternalCliJsonCommand: mocks.runExternalCliJsonCommand, runExternalCliTextCommand: mocks.runExternalCliTextCommand, })); @@ -176,3 +183,107 @@ describe("daemon-manager commands", () => { expect(await handler({ listen: "localhost:6799" })).toBeNull(); }); }); + +describe("desktop stops and terminals", () => { + const reasons: DesktopDaemonStopReason[] = [ + "manual_ipc", + "settings", + "host_remove", + "quit", + "app_update", + "version_mismatch", + "restart", + ]; + + it("keeps terminals only for an app update or version-mismatch restart, never on Windows", () => { + for (const platform of ["darwin", "linux"] as const) { + expect(reasons.filter((reason) => keepsTerminalsOnStop(reason, platform))).toEqual([ + "app_update", + "version_mismatch", + ]); + } + expect(reasons.filter((reason) => keepsTerminalsOnStop(reason, "win32"))).toEqual([]); + }); + + function stopHarness(runCliJson: (args: string[]) => Promise) { + const stops: Array<{ keepTerminals: boolean | undefined }> = []; + const logged: string[] = []; + const instance = { + pid: 4242, + startedAt: "2026-10-21T00:00:00.000Z", + hostname: "host", + uid: 501, + listen: "127.0.0.1:6799", + }; + const stopInstance = (async ( + _home: string, + options: { + keepTerminals?: boolean; + requestShutdown?: (ready: { listen: string }) => Promise; + }, + ) => { + stops.push({ keepTerminals: options.keepTerminals }); + await options.requestShutdown?.({ listen: instance.listen }); + return { action: "stopped", pid: instance.pid, forced: false, usedLifecycleRpc: true }; + }) as unknown as NonNullable[0]["stopInstance"]>; + return { + stops, + logged, + input: { + home: "/tmp/paseo-home", + instance, + stopInstance, + runCliJson, + log: (message: string) => logged.push(message), + } as Omit[0], "reason" | "keepTerminals">, + }; + } + + it("asks the daemon to keep its terminals on an app update", async () => { + const calls: string[][] = []; + const harness = stopHarness(async (args) => { + calls.push(args); + return { action: "shutdown_requested" }; + }); + + await stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true }); + + expect(harness.stops).toEqual([{ keepTerminals: true }]); + expect(calls).toEqual([ + ["daemon", "stop", "--host", "127.0.0.1:6799", "--keep-terminals", "--json"], + ]); + }); + + it("stops a daemon without the capability with its terminals closed, and says so", async () => { + const calls: string[][] = []; + const harness = stopHarness(async (args) => { + calls.push(args); + if (args.includes("--keep-terminals")) { + throw new ExternalCliCommandError("refused", { + code: "TERMINALS_KEEP_UNSUPPORTED", + version: "0.11.0", + }); + } + return { action: "shutdown_requested" }; + }); + + await stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true }); + + expect(harness.stops).toEqual([{ keepTerminals: true }, { keepTerminals: false }]); + expect(calls[1]).toEqual(["daemon", "stop", "--host", "127.0.0.1:6799", "--json"]); + expect(harness.logged).toContain( + "daemon v0.11.0 cannot keep terminals; stopping it and closing them", + ); + }); + + it("does not retry a keep stop that failed for another reason", async () => { + const harness = stopHarness(async () => { + throw new ExternalCliCommandError("unreachable", { code: "DAEMON_UNREACHABLE" }); + }); + + await expect( + stopCapturedDaemon({ ...harness.input, reason: "app_update", keepTerminals: true }), + ).rejects.toThrow("unreachable"); + expect(harness.stops).toEqual([{ keepTerminals: true }]); + }); +}); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 5f3f5394c6..769b5513f5 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -37,7 +37,11 @@ import { closeLocalTransportSession, } from "./local-transport.js"; import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js"; -import { runExternalCliJsonCommand, runExternalCliTextCommand } from "./cli/external.js"; +import { + ExternalCliCommandError, + runExternalCliJsonCommand, + runExternalCliTextCommand, +} from "./cli/external.js"; import { createDesktopSettingsCommandHandlers, type DesktopCommandHandler, @@ -348,18 +352,84 @@ export async function stopDesktopDaemon( "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"]); - }, + const keepTerminals = keepsTerminalsOnStop(reason, process.platform); + logDesktopDaemonLifecycle("stopping captured supervisor", { + reason, + pid: instance.pid, + owned, + keepTerminals, }); + await stopCapturedDaemon({ home, instance, reason, keepTerminals }); if (owned) ownedLaunch = null; return resolveDesktopDaemonStatus(); } +export interface StopCapturedDaemonInput { + home: string; + instance: DaemonInstance; + reason: DesktopDaemonStopReason; + keepTerminals: boolean; + stopInstance?: typeof stopDaemonInstance; + runCliJson?: typeof runExternalCliJsonCommand; + log?: typeof logDesktopDaemonLifecycle; +} + +/** Stops the supervisor desktop captured, keeping its terminals when asked and supported. */ +export async function stopCapturedDaemon(input: StopCapturedDaemonInput): Promise { + const stopInstance = input.stopInstance ?? stopDaemonInstance; + const runCliJson = input.runCliJson ?? runExternalCliJsonCommand; + const logLifecycle = input.log ?? logDesktopDaemonLifecycle; + const stop = (keep: boolean) => + stopInstance(input.home, { + instance: input.instance, + timeoutMs: 15_000, + keepTerminals: keep, + requestShutdown: async (ready) => { + await runCliJson([ + "daemon", + "stop", + "--host", + ready.listen, + ...(keep ? ["--keep-terminals"] : []), + "--json", + ]); + }, + }); + try { + await stop(input.keepTerminals); + } catch (error) { + // The CLI refuses a keep stop before sending anything when the daemon predates the feature; + // that daemon closes terminals on every stop, as every upgrade did before. + if ( + !input.keepTerminals || + !(error instanceof ExternalCliCommandError) || + error.code !== "TERMINALS_KEEP_UNSUPPORTED" + ) { + throw error; + } + const version = typeof error.error?.version === "string" ? error.error.version : "unknown"; + logLifecycle(`daemon v${version} cannot keep terminals; stopping it and closing them`, { + reason: input.reason, + pid: input.instance.pid, + }); + await stop(false); + } +} + +/** + * Whether a desktop stop keeps the daemon's terminals running for the daemon that replaces it. + * Only when one comes straight back (an app update, a version-mismatch restart), and never on + * Windows: a surviving terminal worker runs Paseo.exe and would lock the install folder the + * update replaces. Quit, Settings, host removal and manual stops close terminals. + */ +export function keepsTerminalsOnStop( + reason: DesktopDaemonStopReason, + platform: NodeJS.Platform, +): boolean { + if (platform === "win32") return false; + return reason === "app_update" || reason === "version_mismatch"; +} + async function restartDaemon(): Promise { await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]); return resolveDesktopDaemonStatus(); diff --git a/packages/desktop/src/features/editor-targets/ipc.ts b/packages/desktop/src/features/editor-targets/ipc.ts index 4737f4f328..3bed68c583 100644 --- a/packages/desktop/src/features/editor-targets/ipc.ts +++ b/packages/desktop/src/features/editor-targets/ipc.ts @@ -9,6 +9,10 @@ interface IpcHandlerRegistry { handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void; } +const EditorTargetListInputSchema = z.object({ + workspacePath: z.string().trim().min(1).optional(), +}); + const EditorTargetLaunchInputSchema = z.object({ editorId: z.string().trim().min(1), workspacePath: z.string().trim().min(1), @@ -27,9 +31,10 @@ export function registerEditorTargetHandlers( const ipc = options.ipc ?? ipcMain; const runtime = options.runtime ?? createEditorTargetRuntime(); - ipc.handle("paseo:editor:listTargets", () => - listAvailableEditorTargets(runtime, options.targets), - ); + ipc.handle("paseo:editor:listTargets", (_event, payload: unknown) => { + const { workspacePath } = EditorTargetListInputSchema.parse(payload ?? {}); + return listAvailableEditorTargets(runtime, options.targets, { workspacePath }); + }); ipc.handle("paseo:editor:openTarget", async (_event, payload: unknown) => { const input = EditorTargetLaunchInputSchema.parse(payload); await openEditorTarget(input, runtime, options.targets); diff --git a/packages/desktop/src/features/editor-targets/registry.test.ts b/packages/desktop/src/features/editor-targets/registry.test.ts index 4a11594b4f..0a6ae12328 100644 --- a/packages/desktop/src/features/editor-targets/registry.test.ts +++ b/packages/desktop/src/features/editor-targets/registry.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from "vitest"; import { listAvailableEditorTargets, openEditorTarget } from "./registry.js"; -import type { EditorTargetIcon, EditorTargetRuntime } from "./target.js"; +import type { + EditorTargetDirectoryEntry, + EditorTargetIcon, + EditorTargetRuntime, +} from "./target.js"; import { cursorTarget } from "./targets/cursor.js"; import { explorerTarget, fileManagerTarget, finderTarget } from "./targets/file-manager.js"; import { intellijIdeaTarget } from "./targets/intellij-idea.js"; import { pycharmTarget } from "./targets/pycharm.js"; import { vscodeTarget } from "./targets/vscode.js"; import { webstormTarget } from "./targets/webstorm.js"; +import { xcodeTarget } from "./targets/xcode.js"; import { zedTarget } from "./targets/zed.js"; interface RecordedLaunch { @@ -15,6 +20,14 @@ interface RecordedLaunch { args: string[]; } +function testDirectoryEntry(name: string): EditorTargetDirectoryEntry { + if (name === "Package.swift") return { name, kind: "file" }; + if (name.endsWith(".xcodeproj") || name.endsWith(".xcworkspace")) { + return { name, kind: "directory" }; + } + return { name, kind: "other" }; +} + class FakeEditorTargets implements EditorTargetRuntime { readonly launches: RecordedLaunch[] = []; readonly openedPaths: string[] = []; @@ -29,6 +42,8 @@ class FakeEditorTargets implements EditorTargetRuntime { private readonly paths = new Set(); private readonly commands = new Map(); private readonly macApplications = new Set(); + private readonly directories = new Map(); + private readonly directoryErrors = new Map(); constructor(platform: NodeJS.Platform = "linux", env: NodeJS.ProcessEnv = {}) { this.platform = platform; @@ -47,10 +62,34 @@ class FakeEditorTargets implements EditorTargetRuntime { this.macApplications.add(applicationName); } + addDirectory(targetPath: string, entries: readonly string[]): void { + this.paths.add(targetPath); + this.directories.set(targetPath, entries.map(testDirectoryEntry)); + } + + addDirectoryEntries(targetPath: string, entries: readonly EditorTargetDirectoryEntry[]): void { + this.paths.add(targetPath); + this.directories.set(targetPath, [...entries]); + } + + listDirectory(targetPath: string): EditorTargetDirectoryEntry[] { + const error = this.directoryErrors.get(targetPath); + if (error) throw error; + return this.directories.get(targetPath) ?? []; + } + + failDirectoryRead(targetPath: string, error: Error): void { + this.directoryErrors.set(targetPath, error); + } + pathExists(targetPath: string): boolean { return this.paths.has(targetPath); } + expandHomePath(targetPath: string): string { + return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath; + } + isAbsolutePath(targetPath: string): boolean { return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath); } @@ -83,8 +122,13 @@ class FakeEditorTargets implements EditorTargetRuntime { return this.macApplications.has(applicationName); } + getMacApplicationBundle(applicationName: string): string | null { + return this.hasMacApplication(applicationName) ? `/Applications/${applicationName}.app` : null; + } + async openMacApplication(input: { applicationName: string; + applicationPath?: string; paths: readonly string[]; }): Promise { this.openedMacApplications.push({ @@ -268,6 +312,170 @@ describe("editor target registry", () => { ]); }); + it("lists Xcode only for workspaces that hold something Xcode can open", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/ios-app", ["ios-app.xcodeproj", "Sources"]); + runtime.addDirectory("/repo/web-app", ["package.json", "src"]); + + const iosTargets = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo/ios-app", + }); + const webTargets = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo/web-app", + }); + const unscopedTargets = await listAvailableEditorTargets(runtime, [xcodeTarget]); + + expect(iosTargets).toEqual([ + { + id: "xcode", + label: "Xcode", + kind: "editor", + icon: { kind: "image", dataUrl: "data:image/png;base64,xcode.png" }, + }, + ]); + expect(webTargets).toEqual([]); + expect(unscopedTargets).toEqual([]); + }); + + it("does not mistake similarly named files and directories for Xcode documents", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectoryEntries("/repo/not-an-xcode-project", [ + { name: "Notes.xcodeproj", kind: "file" }, + { name: "App.xcworkspace", kind: "file" }, + { name: "Package.swift", kind: "directory" }, + ]); + + await expect( + listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo/not-an-xcode-project", + }), + ).resolves.toEqual([]); + }); + + it("hands Xcode the umbrella workspace and the requested file", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/Tickets", [ + "Package.swift", + "Widgets.xcworkspace", + "Tickets.xcworkspace", + "Tickets.xcodeproj", + ]); + runtime.addPath("/repo/Tickets/Sources/App.swift"); + + await openEditorTarget( + { + editorId: "xcode", + workspacePath: "/repo/Tickets", + filePath: "/repo/Tickets/Sources/App.swift", + line: 12, + }, + runtime, + [xcodeTarget], + ); + + expect(runtime.openedMacApplications).toEqual([ + { + applicationName: "Xcode", + paths: ["/repo/Tickets/Tickets.xcworkspace", "/repo/Tickets/Sources/App.swift"], + }, + ]); + }); + + it("falls back from Xcode workspaces to projects to Swift packages", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/project-only", ["Beta.xcodeproj", "Alpha.xcodeproj"]); + runtime.addDirectory("/repo/package-only", ["Package.swift", "Sources"]); + + await openEditorTarget({ editorId: "xcode", workspacePath: "/repo/project-only" }, runtime); + await openEditorTarget({ editorId: "xcode", workspacePath: "/repo/package-only" }, runtime); + + expect(runtime.openedMacApplications.map((entry) => entry.paths)).toEqual([ + ["/repo/project-only/Alpha.xcodeproj"], + ["/repo/package-only/Package.swift"], + ]); + }); + + it("refuses to launch Xcode for a directory it cannot open", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/web-app", ["package.json"]); + + await expect( + openEditorTarget({ editorId: "xcode", workspacePath: "/repo/web-app" }, runtime), + ).rejects.toThrow(/No Xcode project/u); + expect(runtime.openedMacApplications).toEqual([]); + }); + + it("keeps installed editors in registration order when Xcode matches", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installCommand("code"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo", ["App.xcodeproj"]); + + const targets = await listAvailableEditorTargets(runtime, [vscodeTarget, xcodeTarget], { + workspacePath: "/repo", + }); + + expect(targets.map((target) => target.id)).toEqual(["vscode", "xcode"]); + }); + + it.each(["EACCES", "EPERM", "ENOENT", "ENOTDIR"])( + "keeps general editor targets when Xcode discovery fails with %s", + async (code) => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installCommand("code"); + runtime.installMacApplication("Xcode"); + runtime.addPath("/repo"); + const error = Object.assign(new Error("Cannot read directory"), { code }); + runtime.failDirectoryRead("/repo", error); + + const targets = await listAvailableEditorTargets( + runtime, + [vscodeTarget, xcodeTarget, finderTarget], + { + workspacePath: "/repo", + }, + ); + + expect(targets.map((target) => target.id)).toEqual(["vscode", "finder"]); + await expect( + openEditorTarget({ editorId: "xcode", workspacePath: "/repo" }, runtime), + ).rejects.toBe(error); + }, + ); + + it("propagates unexpected Xcode discovery failures", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + const error = Object.assign(new Error("I/O failure"), { code: "EIO" }); + runtime.failDirectoryRead("/repo", error); + + await expect( + listAvailableEditorTargets(runtime, [xcodeTarget], { workspacePath: "/repo" }), + ).rejects.toBe(error); + }); + + it("does not discover nested Xcode documents or list an uninstalled Xcode", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.addDirectory("/repo", ["App.xcodeproj"]); + const withoutXcode = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo", + }); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/nested-repo", ["ios"]); + runtime.addDirectory("/nested-repo/ios", ["App.xcworkspace"]); + const nested = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/nested-repo", + }); + + expect(withoutXcode).toEqual([]); + expect(nested).toEqual([]); + }); + it("detects Cursor's installed Windows command when it is absent from PATH", async () => { const runtime = new FakeEditorTargets("win32", { LOCALAPPDATA: "C:/Users/me/AppData/Local", @@ -309,6 +517,19 @@ describe("editor target registry", () => { expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]); }); + it("opens a home-relative folder in the file manager", async () => { + const runtime = new FakeEditorTargets(); + runtime.addPath("/home/me/Downloads/contracts"); + + await openEditorTarget( + { editorId: "file-manager", workspacePath: "~/Downloads/contracts" }, + runtime, + [fileManagerTarget], + ); + + expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]); + }); + it("keeps the platform file-manager ids used by stored preferences", async () => { const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [ finderTarget, diff --git a/packages/desktop/src/features/editor-targets/registry.ts b/packages/desktop/src/features/editor-targets/registry.ts index 060302c729..99a8e8d461 100644 --- a/packages/desktop/src/features/editor-targets/registry.ts +++ b/packages/desktop/src/features/editor-targets/registry.ts @@ -25,6 +25,7 @@ import { vscodiumTarget } from "./targets/vscodium.js"; import { vscodeInsidersTarget } from "./targets/vscode-insiders.js"; import { vscodeTarget } from "./targets/vscode.js"; import { webstormTarget } from "./targets/webstorm.js"; +import { xcodeTarget } from "./targets/xcode.js"; import { zedTarget } from "./targets/zed.js"; export const EDITOR_TARGETS: readonly EditorTarget[] = [ @@ -37,6 +38,7 @@ export const EDITOR_TARGETS: readonly EditorTarget[] = [ zedTarget, antigravityTarget, androidStudioTarget, + xcodeTarget, intellijIdeaTarget, aquaTarget, clionTarget, @@ -54,15 +56,33 @@ export const EDITOR_TARGETS: readonly EditorTarget[] = [ fileManagerTarget, ]; +export interface ListEditorTargetsOptions { + /** + * Absolute workspace root. Without it, targets that declare + * `supportsWorkspace` are left out — there is nothing to match them against. + */ + workspacePath?: string; +} + export async function listAvailableEditorTargets( runtime: EditorTargetRuntime, targets: readonly EditorTarget[] = EDITOR_TARGETS, + options: ListEditorTargetsOptions = {}, ): Promise { + const workspacePath = + options.workspacePath && runtime.isAbsolutePath(options.workspacePath) + ? options.workspacePath + : null; const descriptors: EditorTargetDescriptor[] = []; for (const target of targets) { - if (await target.isInstalled(runtime)) { + if (!(await target.isInstalled(runtime))) continue; + if (!target.supportsWorkspace) { descriptors.push(await target.describe(runtime)); + continue; } + if (!workspacePath) continue; + if (!(await target.supportsWorkspace(workspacePath, runtime))) continue; + descriptors.push(await target.describe(runtime)); } return descriptors; } @@ -77,10 +97,16 @@ export function getEditorTarget( } export async function openEditorTarget( - input: EditorTargetLaunchInput & { editorId: string }, + request: EditorTargetLaunchInput & { editorId: string }, runtime: EditorTargetRuntime, targets: readonly EditorTarget[] = EDITOR_TARGETS, ): Promise { + // Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard. + const input = { + ...request, + workspacePath: runtime.expandHomePath(request.workspacePath), + filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath), + }; if (!runtime.isAbsolutePath(input.workspacePath)) { throw new Error("Editor target workspace path must be an absolute local path"); } diff --git a/packages/desktop/src/features/editor-targets/runtime.posix.test.ts b/packages/desktop/src/features/editor-targets/runtime.posix.test.ts new file mode 100644 index 0000000000..591bffe9f1 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/runtime.posix.test.ts @@ -0,0 +1,55 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { listAvailableEditorTargets, openEditorTarget } from "./registry.js"; +import { createEditorTargetRuntime } from "./runtime.js"; +import { xcodeTarget } from "./targets/xcode.js"; + +// The macOS runtime interprets real fixture paths as POSIX paths. +const it = test.runIf(process.platform !== "win32"); + +describe("editor target POSIX filesystem", () => { + it("discovers and opens Xcode documents from the real filesystem", async () => { + const workspacePath = mkdtempSync(path.join(os.tmpdir(), "paseo-xcode-")); + const commands: Array<{ command: string; args: string[] }> = []; + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + pathExists: (targetPath) => + targetPath === "/Applications/Xcode.app" || existsSync(targetPath), + loadIcon: async () => ({ kind: "symbol", name: "terminal" }), + spawn: (command, args) => { + commands.push({ command, args }); + const child = { + once(event: "error" | "spawn", handler: (error?: Error) => void) { + if (event === "spawn") queueMicrotask(() => handler()); + return child; + }, + unref() {}, + }; + return child; + }, + }); + try { + writeFileSync(path.join(workspacePath, "Notes.xcodeproj"), "not a project directory"); + mkdirSync(path.join(workspacePath, "Package.swift")); + expect(await listAvailableEditorTargets(runtime, [xcodeTarget], { workspacePath })).toEqual( + [], + ); + + const documentPath = path.join(workspacePath, "App.xcworkspace"); + mkdirSync(documentPath); + const targets = await listAvailableEditorTargets(runtime, [xcodeTarget], { workspacePath }); + expect(targets.map((target) => target.id)).toEqual(["xcode"]); + await openEditorTarget({ editorId: "xcode", workspacePath }, runtime); + + expect(commands).toEqual([ + { command: "/usr/bin/open", args: ["-a", "/Applications/Xcode.app", documentPath] }, + ]); + } finally { + rmSync(workspacePath, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/desktop/src/features/editor-targets/runtime.test.ts b/packages/desktop/src/features/editor-targets/runtime.test.ts index 6a2342e901..2fa123d37a 100644 --- a/packages/desktop/src/features/editor-targets/runtime.test.ts +++ b/packages/desktop/src/features/editor-targets/runtime.test.ts @@ -57,4 +57,86 @@ describe("editor target runtime", () => { }, ]); }); + + it("launches an explicitly selected macOS application bundle", async () => { + const records: SpawnRecord[] = []; + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + homeDirectory: "/Users/me", + // A version manager leaves Xcode.app as a symlink beside the real + // bundles, so `open -a Xcode` finds no uniquely named application. + pathExists: (targetPath) => targetPath === "/Applications/Xcode.app", + spawn: (command, args, options) => { + const record = { command, args, options, unrefed: false }; + records.push(record); + const child = { + once(event: "error" | "spawn", handler: (error?: Error) => void) { + if (event === "spawn") queueMicrotask(() => handler()); + return child; + }, + unref() { + record.unrefed = true; + }, + }; + return child; + }, + }); + + expect(runtime.hasMacApplication("Xcode")).toBe(true); + const applicationPath = runtime.getMacApplicationBundle("Xcode"); + if (!applicationPath) throw new Error("Expected Xcode to resolve"); + await runtime.openMacApplication({ + applicationName: "Xcode", + applicationPath, + paths: ["/repo/App.xcworkspace"], + }); + + expect(records.map((record) => ({ command: record.command, args: record.args }))).toEqual([ + { + command: "/usr/bin/open", + args: ["-a", "/Applications/Xcode.app", "/repo/App.xcworkspace"], + }, + ]); + }); + + it("uses LaunchServices application-name resolution by default", async () => { + const records: SpawnRecord[] = []; + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + pathExists: (targetPath) => targetPath === "/Applications/Cursor.app", + spawn: (command, args, options) => { + const record = { command, args, options, unrefed: false }; + records.push(record); + const child = { + once(event: "error" | "spawn", handler: (error?: Error) => void) { + if (event === "spawn") queueMicrotask(() => handler()); + return child; + }, + unref() { + record.unrefed = true; + }, + }; + return child; + }, + }); + + await runtime.openMacApplication({ applicationName: "Cursor", paths: ["/repo"] }); + + expect(records[0]?.args).toEqual(["-a", "Cursor", "/repo"]); + }); + + it("surfaces directory read errors", () => { + const readError = new Error("Permission denied"); + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + readDirectory: () => { + throw readError; + }, + }); + + expect(() => runtime.listDirectory("/repo")).toThrow(readError); + }); }); diff --git a/packages/desktop/src/features/editor-targets/runtime.ts b/packages/desktop/src/features/editor-targets/runtime.ts index dcd5b514c4..36bfac0e65 100644 --- a/packages/desktop/src/features/editor-targets/runtime.ts +++ b/packages/desktop/src/features/editor-targets/runtime.ts @@ -1,12 +1,16 @@ import type { ChildProcess, SpawnOptions } from "node:child_process"; import { spawn as nodeSpawn } from "node:child_process"; -import { existsSync as nodeExistsSync } from "node:fs"; +import { existsSync as nodeExistsSync, readdirSync as nodeReaddirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import os from "node:os"; import path, { posix, win32 } from "node:path"; import { app, shell } from "electron"; -import type { EditorTargetIcon, EditorTargetRuntime } from "./target.js"; +import type { + EditorTargetDirectoryEntry, + EditorTargetIcon, + EditorTargetRuntime, +} from "./target.js"; interface SpawnedProcess { once(event: "error", handler: (error: Error) => void): SpawnedProcess; @@ -24,6 +28,7 @@ export interface EditorTargetRuntimeOptions { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; pathExists?: (path: string) => boolean; + readDirectory?: (path: string) => EditorTargetDirectoryEntry[]; spawn?: (command: string, args: string[], options: SpawnOptions) => SpawnedProcess; openPath?: (path: string) => Promise; revealPath?: (path: string) => void; @@ -56,6 +61,14 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean { return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value); } +function expandHomePath(value: string, homeDirectory: string): string { + if (value === "~") return homeDirectory; + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(homeDirectory, value.slice(2)); + } + return value; +} + function resolveExecutable( commands: readonly string[], input: { @@ -107,6 +120,14 @@ function escapeWindowsCmdValue(value: string): string { return `"${quoted}"`; } +function readDirectory(targetPath: string): EditorTargetDirectoryEntry[] { + return nodeReaddirSync(targetPath, { withFileTypes: true }).map((entry) => { + if (entry.isFile()) return { name: entry.name, kind: "file" }; + if (entry.isDirectory()) return { name: entry.name, kind: "directory" }; + return { name: entry.name, kind: "other" }; + }); +} + function spawnProcess(command: string, args: string[], options: SpawnOptions): SpawnedProcess { return nodeSpawn(command, args, options) as ChildProcess as SpawnedProcess; } @@ -129,17 +150,31 @@ export function createEditorTargetRuntime( const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const pathExists = options.pathExists ?? nodeExistsSync; + const listDirectory = options.readDirectory ?? readDirectory; const spawn = options.spawn ?? spawnProcess; const openPath = options.openPath ?? ((targetPath) => shell.openPath(targetPath)); const revealPath = options.revealPath ?? ((targetPath) => shell.showItemInFolder(targetPath)); const loadIcon = options.loadIcon ?? loadBundledIcon; const homeDirectory = options.homeDirectory ?? os.homedir(); + function resolveMacApplicationBundle(applicationName: string): string | null { + if (platform !== "darwin") return null; + return ( + [ + `/Applications/${applicationName}.app`, + `${homeDirectory}/Applications/${applicationName}.app`, + `/System/Applications/${applicationName}.app`, + ].find(pathExists) ?? null + ); + } + return { platform, env, pathExists, isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform), + listDirectory, + expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory), resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }), async spawnDetached({ command, args }) { const commandScript = isWindowsCommandScript(command, platform); @@ -172,17 +207,13 @@ export function createEditorTargetRuntime( revealPath, loadIcon, hasMacApplication(applicationName) { - if (platform !== "darwin") return false; - return [ - `/Applications/${applicationName}.app`, - `${homeDirectory}/Applications/${applicationName}.app`, - `/System/Applications/${applicationName}.app`, - ].some(pathExists); + return resolveMacApplicationBundle(applicationName) !== null; }, - async openMacApplication({ applicationName, paths }) { + getMacApplicationBundle: resolveMacApplicationBundle, + async openMacApplication({ applicationName, applicationPath, paths }) { await this.spawnDetached({ command: "/usr/bin/open", - args: ["-a", applicationName, ...paths], + args: ["-a", applicationPath ?? applicationName, ...paths], }); }, }; diff --git a/packages/desktop/src/features/editor-targets/target.ts b/packages/desktop/src/features/editor-targets/target.ts index 6e92d71b54..b6a9f93668 100644 --- a/packages/desktop/src/features/editor-targets/target.ts +++ b/packages/desktop/src/features/editor-targets/target.ts @@ -1,5 +1,10 @@ export type EditorTargetKind = "editor" | "file-manager"; +export interface EditorTargetDirectoryEntry { + name: string; + kind: "file" | "directory" | "other"; +} + export type EditorTargetIcon = | { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" }; @@ -24,13 +29,21 @@ export interface EditorTargetRuntime { pathExists(path: string): boolean; isAbsolutePath(path: string): boolean; + listDirectory(path: string): EditorTargetDirectoryEntry[]; + /** Resolves a leading `~` to the user's home directory. */ + expandHomePath(path: string): string; resolveCommand(commands: readonly string[]): string | null; spawnDetached(input: { command: string; args: readonly string[] }): Promise; openPath(path: string): Promise; revealPath(path: string): void; loadIcon(fileName: string): Promise; hasMacApplication(applicationName: string): boolean; - openMacApplication(input: { applicationName: string; paths: readonly string[] }): Promise; + getMacApplicationBundle(applicationName: string): string | null; + openMacApplication(input: { + applicationName: string; + applicationPath?: string; + paths: readonly string[]; + }): Promise; } export interface EditorTarget { @@ -38,5 +51,11 @@ export interface EditorTarget { describe(runtime: EditorTargetRuntime): Promise; isInstalled(runtime: EditorTargetRuntime): Promise; + /** + * Targets that only make sense for a specific kind of project implement this. + * They are listed only when the caller knows the workspace path and this + * returns true, so a general-purpose repo never shows Xcode-shaped entries. + */ + supportsWorkspace?(workspacePath: string, runtime: EditorTargetRuntime): Promise; launch(input: EditorTargetLaunchInput, runtime: EditorTargetRuntime): Promise; } diff --git a/packages/desktop/src/features/editor-targets/targets/xcode.ts b/packages/desktop/src/features/editor-targets/targets/xcode.ts new file mode 100644 index 0000000000..2acdf993ee --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/xcode.ts @@ -0,0 +1,101 @@ +import type { EditorTarget, EditorTargetDirectoryEntry, EditorTargetRuntime } from "../target.js"; + +const XCODE_APPLICATION_NAME = "Xcode"; +const SWIFT_PACKAGE_MANIFEST = "Package.swift"; + +function joinPath(directory: string, entry: string): string { + return `${directory.replace(/\/+$/u, "")}/${entry}`; +} + +function directoryName(directory: string): string { + const segments = directory.split("/").filter(Boolean); + return segments[segments.length - 1] ?? ""; +} + +function isUnavailableDirectoryError(error: unknown): boolean { + if (!(error instanceof Error) || !("code" in error)) return false; + switch (error.code) { + case "EACCES": + case "EPERM": + case "ENOENT": + case "ENOTDIR": + return true; + default: + return false; + } +} + +function pickByExtension( + entries: readonly EditorTargetDirectoryEntry[], + directory: string, + extension: string, +): string | null { + const matches = entries + .filter((entry) => entry.kind === "directory" && entry.name.endsWith(extension)) + .map((entry) => entry.name) + .sort(); + if (matches.length === 0) return null; + // A repo can hold several projects; the one named after its directory is the + // umbrella project far more often than not, so prefer it before falling back + // to alphabetical order. + const preferred = `${directoryName(directory)}${extension}`; + return matches.includes(preferred) ? preferred : matches[0]; +} + +/** + * Resolves what Xcode should actually be handed. Xcode cannot open a plain + * directory, so the workspace root is searched for a document it understands: + * an `.xcworkspace` first (it supersedes the projects it contains), then an + * `.xcodeproj`, then a Swift package manifest. + */ +function findXcodeDocument(workspacePath: string, runtime: EditorTargetRuntime): string | null { + const entries = runtime.listDirectory(workspacePath); + const workspace = pickByExtension(entries, workspacePath, ".xcworkspace"); + if (workspace) return joinPath(workspacePath, workspace); + const project = pickByExtension(entries, workspacePath, ".xcodeproj"); + if (project) return joinPath(workspacePath, project); + if (entries.some((entry) => entry.kind === "file" && entry.name === SWIFT_PACKAGE_MANIFEST)) { + return joinPath(workspacePath, SWIFT_PACKAGE_MANIFEST); + } + return null; +} + +export const xcodeTarget: EditorTarget = { + id: "xcode", + async describe(runtime) { + return { + id: this.id, + label: "Xcode", + kind: "editor", + icon: await runtime.loadIcon("xcode.png"), + }; + }, + async isInstalled(runtime) { + return runtime.hasMacApplication(XCODE_APPLICATION_NAME); + }, + async supportsWorkspace(workspacePath, runtime) { + try { + return findXcodeDocument(workspacePath, runtime) !== null; + } catch (error) { + // Discovery is optional; an unavailable directory does not prevent the + // user from choosing another installed editor. Explicit launches still fail. + if (isUnavailableDirectoryError(error)) return false; + throw error; + } + }, + async launch(input, runtime) { + const document = findXcodeDocument(input.workspacePath, runtime); + if (!document) { + throw new Error(`No Xcode project, workspace, or Swift package in ${input.workspacePath}`); + } + const applicationPath = runtime.getMacApplicationBundle(XCODE_APPLICATION_NAME); + if (!applicationPath) throw new Error("Xcode is not installed"); + // Pass the requested file alongside the project. This launch path does not + // support line or column navigation. + await runtime.openMacApplication({ + applicationName: XCODE_APPLICATION_NAME, + applicationPath, + paths: input.filePath ? [document, input.filePath] : [document], + }); + }, +}; diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 6075fbe64c..0942134521 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -95,7 +95,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", { openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url), }, editor: { - listTargets: () => ipcRenderer.invoke("paseo:editor:listTargets"), + listTargets: (input?: { workspacePath?: string }) => + ipcRenderer.invoke("paseo:editor:listTargets", input), openTarget: (input: { editorId: string; workspacePath: string; diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index b2da02d6e0..ff08d35c76 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.13.0", + "version": "0.15.0-beta.1", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", @@ -33,7 +33,6 @@ "devDependencies": { "@types/jest": "^29.5.14", "@types/react": "^18.0.25", - "expo-module-scripts": "^5.0.8", "expo-modules-core": "^2.0.4" }, "peerDependencies": { diff --git a/packages/expo-two-way-audio/tsconfig.json b/packages/expo-two-way-audio/tsconfig.json index 102525ffa9..24e443895c 100644 --- a/packages/expo-two-way-audio/tsconfig.json +++ b/packages/expo-two-way-audio/tsconfig.json @@ -6,7 +6,7 @@ "module": "esnext", "moduleResolution": "bundler", "typeRoots": [ - "./node_modules/expo-module-scripts/ts-declarations", + "../../node_modules/expo-module-scripts/ts-declarations", "./node_modules/@types", "../../node_modules/@types" ] diff --git a/packages/highlight/package.json b/packages/highlight/package.json index c1dd7c9381..dfc43982bf 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.13.0", + "version": "0.15.0-beta.1", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/highlight/src/__tests__/highlighter.test.ts b/packages/highlight/src/__tests__/highlighter.test.ts index fde3f9d740..095f9689a8 100644 --- a/packages/highlight/src/__tests__/highlighter.test.ts +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { setImmediate } from "node:timers/promises"; +import { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "../budgeted-highlighter.js"; import type { HighlightToken } from "../types.js"; import { highlightCode, highlightCodeInSteps, highlightLine } from "../highlighter.js"; @@ -420,3 +422,55 @@ describe("highlightLine", () => { expect(tokens).toEqual([{ text: "hello", style: null }]); }); }); + +describe("highlightCodeWithBudget", () => { + it("yields to IO while producing byte-identical output below the source budget", async () => { + const code = "const value = Math.max(42, 7);\n".repeat(3_000); + const expected = JSON.stringify(highlightCode(code, "file.ts")); + let ticks = 0; + let longestGap = 0; + let lastTick = performance.now(); + const timer = setInterval(() => { + const now = performance.now(); + longestGap = Math.max(longestGap, now - lastTick); + lastTick = now; + ticks++; + }, 1); + try { + const actual = await highlightCodeWithBudget(code, "file.ts", { + yieldToEventLoop: setImmediate, + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(ticks).toBeGreaterThan(1); + expect(longestGap).toBeLessThan(50); + expect(JSON.stringify(actual)).toBe(expected); + } finally { + clearInterval(timer); + } + }); + + it("budgets UTF-8 bytes before parsing, including multibyte text", async () => { + expect( + await highlightCodeWithBudget("é".repeat(HIGHLIGHT_MAX_SOURCE_BYTES / 2 + 1), "file.ts", { + yieldToEventLoop: setImmediate, + }), + ).toBeNull(); + expect( + await highlightCodeWithBudget("a".repeat(HIGHLIGHT_MAX_SOURCE_BYTES + 1), "file.ts", { + yieldToEventLoop: setImmediate, + }), + ).toBeNull(); + }); + + it("stops a parser that is cancelled between real event-loop turns", async () => { + const controller = new AbortController(); + const pending = highlightCodeWithBudget( + "const value = Math.max(42, 7);\n".repeat(3_000), + "file.ts", + { yieldToEventLoop: setImmediate, signal: controller.signal }, + ); + await setImmediate(); + controller.abort(); + expect(await pending).toBeNull(); + }); +}); diff --git a/packages/highlight/src/budgeted-highlighter.ts b/packages/highlight/src/budgeted-highlighter.ts new file mode 100644 index 0000000000..e949a619ff --- /dev/null +++ b/packages/highlight/src/budgeted-highlighter.ts @@ -0,0 +1,49 @@ +import { highlightCodeInSteps } from "./highlighter.js"; +import type { HighlightToken } from "./types.js"; + +// Full source, rather than patch size, bounds parser and token allocation work. +// Each token consumes a source code unit or an empty line: at most the byte cap + 1 records. +export const HIGHLIGHT_MAX_SOURCE_BYTES = 768 * 1024; +const SLICE_MS = 4; + +/** + * Returns null when source exceeds the budget or the request is cancelled. + * The host supplies a real event-loop yield (setImmediate on the daemon). + * Existing synchronous highlighting remains available for its existing consumers. + */ +export async function highlightCodeWithBudget( + code: string, + filename: string, + options: { yieldToEventLoop: () => Promise; signal?: { readonly aborted: boolean } }, +): Promise { + if (options.signal?.aborted || exceedsSourceBudget(code)) return null; + const steps = highlightCodeInSteps(code, filename); + let sliceStarted = Date.now(); + try { + for (;;) { + if (options.signal?.aborted) return null; + const step = steps.next(); + if (step.done) return step.value; + if (Date.now() - sliceStarted >= SLICE_MS) { + await options.yieldToEventLoop(); + sliceStarted = Date.now(); + } + } + } finally { + steps.return([]); + } +} + +function exceedsSourceBudget(code: string): boolean { + if (code.length > HIGHLIGHT_MAX_SOURCE_BYTES) return true; + let bytes = 0; + for (const char of code) { + const point = char.codePointAt(0)!; + if (point < 0x80) bytes++; + else if (point < 0x800) bytes += 2; + else if (point < 0x10000) bytes += 3; + else bytes += 4; + if (bytes > HIGHLIGHT_MAX_SOURCE_BYTES) return true; + } + return false; +} diff --git a/packages/highlight/src/index.ts b/packages/highlight/src/index.ts index a69820ee28..da468e2f87 100644 --- a/packages/highlight/src/index.ts +++ b/packages/highlight/src/index.ts @@ -15,3 +15,4 @@ export { isSyntaxThemeId, resolveSyntaxColors, } from "./themes.js"; +export { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "./budgeted-highlighter.js"; diff --git a/packages/plugin/package.json b/packages/plugin/package.json index b6967d6fc1..8ed758eb5f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.13.0", + "version": "0.15.0-beta.1", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -52,6 +52,11 @@ "types": "./dist/client/index.d.ts", "source": "./src/client/index.ts", "default": "./dist/client/index.js" + }, + "./internal/lifecycle": { + "types": "./dist/server/lifecycle.d.ts", + "source": "./src/server/lifecycle.ts", + "default": "./dist/server/lifecycle.js" } }, "publishConfig": { @@ -72,8 +77,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0", - "@getpaseo/protocol": "0.13.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -83,8 +88,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0", - "@getpaseo/protocol": "0.13.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index c9b54ecdb7..b0834956d6 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -13,6 +13,7 @@ const entries = { "./server/provider": "server", "./server/usage": "server", "./server/acp": "server", + "./internal/lifecycle": "server", "./client": "client", "./client/host": "client", "./client/ui": "client", @@ -92,7 +93,12 @@ function typeErrors(code: string): string[] { } describe("plugin SDK import boundaries", () => { it("publishes process capabilities without their Windows implementation helpers", () => { - expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); + expect(Object.keys(server).sort()).toEqual([ + "defineAgentTool", + "execCommand", + "spawnProcess", + "terminateProcess", + ]); }); it("classifies every published entry", () => { @@ -129,12 +135,8 @@ describe("plugin SDK import boundaries", () => { it.each(Object.entries(entries))( "%s respects its %s boundary, including type dependencies", (specifier, runtime) => { - const name = - specifier === "." - ? "index" - : specifier.slice(2) + - (specifier === "./client" || specifier === "./server" ? "/index" : ""); - expect(boundaryViolations(path.join(sourceDirectory, `${name}.ts`), runtime)).toEqual([]); + const file = path.resolve(sourceDirectory, "..", manifest.exports[specifier].source); + expect(boundaryViolations(file, runtime)).toEqual([]); }, ); }); @@ -165,6 +167,7 @@ describe("plugin example import boundaries", () => { const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries; if ( entry === "./client/host" || + entry.startsWith("./internal/") || !(entry in entries) || (entries[entry] !== "shared" && entries[entry] !== runtime) ) @@ -209,3 +212,114 @@ describe("plugin panel SDK boundary", () => { ).toEqual([]); }); }); + +describe("plugin agent tools SDK boundary", () => { + it("lets a session hook attach typed tools without exposing their transport", () => { + expect( + typeErrors(` + import { z } from "zod"; + import { defineAgentTool, type PluginServerContext, type AgentToolResult, type AgentToolContext, type AgentToolDefinition } from "@getpaseo/plugin/server"; + const tool: AgentToolDefinition = defineAgentTool({ + name: "create_issue", + description: "Create an issue", + input: z.object({ title: z.string(), count: z.string().transform(Number) }), + async execute(input, { agent, paseo, signal }) { + const count: number = input.count; + // @ts-expect-error input is inferred from the schema + input.missing; + return { text: agent.id + input.title, structuredContent: { count } }; + }, + }); + export default function contribute(server: PluginServerContext) { + server.addHook("agent.open.before", async ({ request }) => { + const title: string | null = request.title; + // @ts-expect-error labels are read-only + request.labels.x = "changed"; + if (request.labels.x !== "y") return; + // @ts-expect-error MCP servers use the same typed transport config as saved servers + request.mcpServers.invalid = { type: "http", url: 42 }; + return { ...request, tools: [...request.tools, tool], mcpServers: { + ...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" }, + } }; + }); + return () => {}; + } + `), + ).toEqual([]); + }); +}); + +describe("lifecycle hook SDK types", () => { + it("infers payloads and replacement requests and requires async handlers and new names", () => { + expect( + typeErrors(` + import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server"; + export default function contribute(server: PluginServerContext) { + server.addHook("worktree.create.before", async ({ request }) => { + if (request.kind === "checkout-branch") { + // @ts-expect-error Branch checkout has no branch-off base. + request.baseRef; + } else { + const base: string = request.baseRef; + } + const branch: string = request.branch; + // @ts-expect-error Repository identity is read-only. + request.repoRoot = "/other"; + // @ts-expect-error Source checkout identity is read-only. + request.sourceCheckout = "/other"; + if (request.kind === "checkout-change-request") { + const number: number = request.changeRequestNumber; + } + return { ...request, branch: "feature", baseRef: "main", slug: "feature", path: "/work/feature" }; + }); + server.addHook("worktree.create.before", async () => {}); + server.addHook("worktree.create.after", async ({ worktree }) => { + const path: string = worktree.path; + const branch: string = worktree.branch; + const source: string = worktree.sourceCheckout; + // @ts-expect-error Worktree facts have no workspace identity. + worktree.workspaceId; + }); + server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" })); + server.addHook("workspace.create.before", async () => {}); + server.addHook("agent.open.before", async () => {}); + server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => { + const kind: "completed" | "failed" | "canceled" = outcome.kind; + // @ts-expect-error Notification payloads have no creation request. + outcome.request; + }); + // @ts-expect-error Async handlers are required even for notifications. + server.addHook("agent.turn.before", () => {}); + // @ts-expect-error Legacy names are only accepted by deprecated registration methods. + server.addHook("agent.session_open", async () => {}); + // @ts-expect-error Unknown names fail typechecking. + server.addHook("nonsense", async () => {}); + // @ts-expect-error A prepare handler must return the whole request or undefined. + server.addHook("agent.create.before", async () => ({ invalid: true })); + server.before("agent.session_open", ({ request }) => request); + server.on("agent.turn_ended", () => {}); + return () => {}; + } + `), + ).toEqual([]); + }); +}); + +describe("agent session reload SDK boundary", () => { + it("exposes reload separately from snapshot refresh", () => { + expect( + typeErrors(` + import { type PluginServerContext } from "@getpaseo/plugin/server"; + import { type PaseoAgentRefetchResult } from "@getpaseo/client"; + async function reload(server: PluginServerContext, paseo: import("@getpaseo/client").PaseoApi) { + const agent = paseo.agents.ref("chosen-agent"); + const read: PaseoAgentRefetchResult | null = await agent.refresh("read-snapshot"); + const reopened: PaseoAgentRefetchResult | null = await agent.reload({ requestId: "reload-session" }); + await agent.reload(); + // @ts-expect-error reload options can grow without a positional signature change + await agent.reload("reload-session"); + } + `), + ).toEqual([]); + }); +}); diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index b2fbee8e2c..86e22b2afe 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -997,7 +997,9 @@ class AcpRuntime { ): string { if (messageId) { this.fallbackChunkIds.delete(kind); - return messageId; + // Native IDs identify a message within its chunk kind. Timeline IDs must + // also distinguish reasoning from replies and from generated fallback IDs. + return JSON.stringify([kind, messageId]); } let id = this.fallbackChunkIds.get(kind); if (!id) { diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index f4b429578f..1d99c73a75 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -1307,7 +1307,15 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => { const items = events.flatMap((event) => event.type === "timeline.item" && (event.item.type === "assistant_message" || event.item.type === "reasoning") - ? [{ id: event.item.id, text: event.item.text }] + ? [ + { + id: event.item.id, + text: event.item.text, + ...(event.item.type === "assistant_message" && event.item.messageId + ? { messageId: event.item.messageId } + : {}), + }, + ] : [], ); expect(items).toEqual([ @@ -1315,7 +1323,11 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => { { id: "agent_message_chunk:1", text: "- **Current temperature**: 25°C" }, { id: "agent_message_chunk:2", text: "next turn" }, { id: "agent_thought_chunk:3", text: "think A" }, - { id: "m-explicit", text: "say" }, + { + id: JSON.stringify(["agent_message_chunk", "m-explicit"]), + messageId: "m-explicit", + text: "say", + }, { id: "agent_thought_chunk:4", text: "think B" }, ]); await connection.close(); @@ -1402,6 +1414,21 @@ async function streamChunkTurns( } describe("ACP streamed message boundaries", () => { + it("keeps reasoning separate from a reply sharing its native message ID", async () => { + expect( + await streamChunkTurns([ + textChunk("private ", "agent_thought_chunk", "shared-id"), + textChunk("reasoning", "agent_thought_chunk", "shared-id"), + textChunk("public ", "agent_message_chunk", "shared-id"), + textChunk("reply", "agent_message_chunk", "shared-id"), + ]), + ).toEqual([ + { type: "reasoning", text: "private reasoning" }, + { type: "assistant_message", text: "public reply" }, + { type: "assistant_message", text: "Next turn" }, + ]); + }); + it.each([ { name: "tool call", @@ -1584,8 +1611,12 @@ describe("ACP session history replay", () => { ); try { expect(timelineProjection(events)).toEqual([ - { type: "user_message", id: "user-1", text: "What is the " }, - { type: "user_message", id: "user-1", text: "What is the temperature?" }, + { type: "user_message", id: '["user_message_chunk","user-1"]', text: "What is the " }, + { + type: "user_message", + id: '["user_message_chunk","user-1"]', + text: "What is the temperature?", + }, { type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" }, { type: "tool_call", id: "tool-1", status: "running" }, { type: "tool_call", id: "tool-1", status: "completed" }, @@ -1599,6 +1630,48 @@ describe("ACP session history replay", () => { } }); + it("keeps replayed user, reasoning and reply chunks separate when their native IDs match", async () => { + const { connection, events } = await openReplaySession( + [ + userChunk("What is the ", "shared-id"), + userChunk("temperature?", "shared-id"), + textChunk("Checking ", "agent_thought_chunk", "shared-id"), + textChunk("the sensor", "agent_thought_chunk", "shared-id"), + textChunk("It is ", "agent_message_chunk", "shared-id"), + textChunk("25°C", "agent_message_chunk", "shared-id"), + ], + "replay", + ); + try { + const items = new Map( + events.flatMap((event) => + event.type === "timeline.item" ? [[event.item.id, event.item] as const] : [], + ), + ); + expect([...items.values()]).toEqual([ + { + type: "user_message", + id: '["user_message_chunk","shared-id"]', + messageId: "shared-id", + text: "What is the temperature?", + }, + { + type: "reasoning", + id: '["agent_thought_chunk","shared-id"]', + text: "Checking the sensor", + }, + { + type: "assistant_message", + id: '["agent_message_chunk","shared-id"]', + messageId: "shared-id", + text: "It is 25°C", + }, + ]); + } finally { + await connection.close(); + } + }); + it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => { const { connection, events } = await openReplaySession( [ diff --git a/packages/plugin/src/server/agent-tools.ts b/packages/plugin/src/server/agent-tools.ts new file mode 100644 index 0000000000..202040cefb --- /dev/null +++ b/packages/plugin/src/server/agent-tools.ts @@ -0,0 +1,39 @@ +import type { PaseoApi } from "@getpaseo/client"; +import type { z } from "zod"; +import type { PluginHookAgent } from "./lifecycle.js"; + +export interface AgentToolContext { + agent: PluginHookAgent; + paseo: PaseoApi; + signal: AbortSignal; +} + +export interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} + +export interface AgentToolDefinition { + name: string; + title?: string; + description: string; + input: z.ZodType; + execute(input: unknown, context: AgentToolContext): AgentToolResult | Promise; +} + +export function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition { + if (!/^[a-z][a-z0-9_]*$/.test(tool.name) || tool.name.length > 64) { + throw new Error(`Invalid agent tool name: ${tool.name}`); + } + return tool; +} diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index 9180023d19..bb6bf4ede8 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -13,8 +13,15 @@ export type { PluginSettingsState, } from "./contracts.js"; export type { + PluginHooks, + PluginHookEvents, + PluginHookRequests, + PluginHookPayload, + PluginHookResult, PluginHookContext, PluginHookWorkspace, + PluginHookWorktree, + PluginWorktreeCreateRequest, PluginHookAgent, PluginSessionOpenRequest, PluginTurnOutcome, @@ -24,3 +31,6 @@ export type { } from "./lifecycle.js"; export { spawnProcess, execCommand, terminateProcess } from "./process.js"; + +export { defineAgentTool } from "./agent-tools.js"; +export type { AgentToolDefinition, AgentToolResult, AgentToolContext } from "./agent-tools.js"; diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts index 350039face..20df9c2dcb 100644 --- a/packages/plugin/src/server/lifecycle.ts +++ b/packages/plugin/src/server/lifecycle.ts @@ -1,3 +1,4 @@ +import type { AgentToolDefinition } from "./agent-tools.js"; import type { AgentPermissionRequest, AgentPermissionResponse, @@ -20,6 +21,46 @@ export interface PluginHookWorkspace { archivedAt: string | null; } +export interface PluginHookWorktree { + path: string; + branch: string; + sourceCheckout: string; +} + +/** Resolved creation options before Git fetches refs or chooses a unique branch name. */ +export type PluginWorktreeCreateRequest = { + readonly repoRoot: string; + readonly sourceCheckout: string; + branch: string; + slug: string; + path: string; +} & ( + | { kind: "branch-off"; baseRef: string } + | { kind: "checkout-branch" } + | { + kind: "checkout-change-request"; + baseRef: string; + forge: string; + changeRequestNumber: number; + headRef: string; + headRepositoryOwner?: string; + headRepository?: string; + checkoutRefs?: { remoteName?: string; remoteRef: string }[]; + pushRemoteUrl?: string; + trackOriginHead?: boolean; + } + | { + kind: "checkout-github-pr"; + baseRef: string; + githubPrNumber: number; + headRef: string; + headRepositoryOwner?: string; + checkoutRefs?: { remoteName?: string; remoteRef: string }[]; + pushRemoteUrl?: string; + trackOriginHead?: boolean; + } +); + export interface PluginHookAgent { id: string; workspaceId: string | null; @@ -36,7 +77,11 @@ export interface PluginSessionOpenRequest { cwd: string; reason: "create" | "resume" | "refresh" | "import"; purpose: "interactive" | "history"; + readonly title: string | null; + readonly labels: Readonly>; + tools: AgentToolDefinition[]; env: Record; + mcpServers: NonNullable; } export type PluginTurnOutcome = @@ -44,9 +89,9 @@ export type PluginTurnOutcome = | { kind: "failed"; error: { message: string; code?: string } } | { kind: "canceled"; reason: string }; -export interface PluginLifecycleEvents { - "agent.turn_started": { agent: PluginHookAgent; turnId: string | null }; - "agent.turn_ended": { +export interface PluginHookEvents { + "agent.turn.before": { agent: PluginHookAgent; turnId: string | null }; + "agent.turn.after": { agent: PluginHookAgent; turnId: string | null; outcome: PluginTurnOutcome; @@ -56,26 +101,58 @@ export interface PluginLifecycleEvents { */ timeline: readonly AgentTimelineItem[]; }; - "agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest }; - "agent.permission_resolved": { + "agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest }; + "agent.permission.after": { agent: PluginHookAgent; requestId: string; resolution: AgentPermissionResponse; }; - "agent.archived": { agent: PluginHookAgent; archivedAt: string }; - "agent.created": { agent: PluginHookAgent }; - "agent.closed": { agent: PluginHookAgent }; - "workspace.created": { workspace: PluginHookWorkspace }; - "workspace.archived": { workspace: PluginHookWorkspace }; + "agent.archive.after": { agent: PluginHookAgent; archivedAt: string }; + "agent.create.after": { agent: PluginHookAgent }; + "agent.close.after": { agent: PluginHookAgent }; + "workspace.create.after": { workspace: PluginHookWorkspace }; + "workspace.archive.after": { workspace: PluginHookWorkspace }; } -export interface PluginBeforeRequests { - "agent.create": { config: AgentSessionConfig; env?: Record }; - "agent.session_open": PluginSessionOpenRequest; - "workspace.create": Omit; +export interface PluginHookRequests { + "worktree.create.before": PluginWorktreeCreateRequest; + "agent.create.before": { config: AgentSessionConfig; env?: Record }; + "agent.open.before": PluginSessionOpenRequest; + "workspace.create.before": Omit; } +export type PluginHooks = { + [Name in keyof PluginHookRequests]: { + request: PluginHookRequests[Name]; + result: PluginHookRequests[Name]; + }; +} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & { + "worktree.create.after": { payload: { worktree: PluginHookWorktree } }; +}; + +export type PluginHookPayload = { + [Key in keyof PluginHooks]: PluginHooks[Key] extends { request: infer Request } + ? { request: Request } + : PluginHooks[Key] extends { payload: infer Payload } + ? Payload + : never; +}[Name]; +export type PluginHookResult = { + [Key in keyof PluginHooks]: PluginHooks[Key] extends { result: infer Result } + ? Result | void + : void; +}[Name]; + +type HookHandlers = { + [Name in keyof PluginHooks]: ( + payload: PluginHookPayload, + context: PluginHookContext, + ) => Promise>; +}; + export interface PluginLifecycleRegistration { + addHook(name: Name, handler: HookHandlers[Name]): () => void; + /** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */ on( name: Name, handler: ( @@ -83,6 +160,7 @@ export interface PluginLifecycleRegistration { context: PluginHookContext, ) => void | Promise, ): () => void; + /** @deprecated Use addHook with the renamed hook. Removed after 2027-04-30. */ before( name: Name, handler: ( @@ -91,3 +169,69 @@ export interface PluginLifecycleRegistration { ) => PluginBeforeRequests[Name] | void | Promise, ): () => void; } + +/** @internal Lifecycle policy shared by the daemon and plugin process. */ +export const pluginHookRegistry = { + "workspace.create.before": { waits: true }, + "workspace.create.after": { waits: false }, + "workspace.archive.after": { waits: false }, + "worktree.create.before": { waits: true }, + "worktree.create.after": { waits: true }, + "agent.create.before": { waits: true }, + "agent.create.after": { waits: false }, + "agent.open.before": { waits: true }, + "agent.close.after": { waits: false }, + "agent.archive.after": { waits: false }, + "agent.turn.before": { waits: false }, + "agent.turn.after": { waits: false }, + "agent.permission.before": { waits: false }, + "agent.permission.after": { waits: false }, +} as const satisfies Record; + +// COMPAT(pluginHookNames): added in v0.14, remove after 2027-04-30 +const legacyEvents = { + "workspace.created": "workspace.create.after", + "workspace.archived": "workspace.archive.after", + "agent.created": "agent.create.after", + "agent.closed": "agent.close.after", + "agent.archived": "agent.archive.after", + "agent.turn_started": "agent.turn.before", + "agent.turn_ended": "agent.turn.after", + "agent.permission_requested": "agent.permission.before", + "agent.permission_resolved": "agent.permission.after", +} as const; +const legacyRequests = { + "workspace.create": "workspace.create.before", + "agent.create": "agent.create.before", + "agent.session_open": "agent.open.before", +} as const; +export type PluginLifecycleEvents = { + [Name in keyof typeof legacyEvents]: PluginHookEvents[(typeof legacyEvents)[Name]]; +}; +export type PluginBeforeRequests = { + [Name in keyof typeof legacyRequests]: PluginHookRequests[(typeof legacyRequests)[Name]]; +}; + +/** @internal SDK adapter for the daemon's hook registry. */ +export function createLifecycleRegistration( + register: ( + name: keyof PluginHooks, + handler: (input: never, context: PluginHookContext) => unknown, + ) => () => void, +): PluginLifecycleRegistration { + const names = new Set(Object.keys(pluginHookRegistry)); + return { + addHook(name, handler) { + if (!names.has(name)) throw new Error(`Unknown lifecycle hook: ${name}`); + return register(name, handler); + }, + on(name, handler) { + if (!Object.hasOwn(legacyEvents, name)) throw new Error(`Unknown lifecycle event: ${name}`); + return register(legacyEvents[name], handler); + }, + before(name, handler) { + if (!Object.hasOwn(legacyRequests, name)) throw new Error(`Unknown before hook: ${name}`); + return register(legacyRequests[name], handler); + }, + }; +} diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index 1646e8473b..ee9fbc21c2 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -9,6 +9,7 @@ export const PROVIDER_CAPABILITIES = [ "prompt.image", "prompt.output_schema", "prompt.steer", + "session.can_stop_runtime", "session.archive", "session.configure", "session.list", @@ -277,6 +278,7 @@ export type ProviderInput = } | { type: "session.archive"; requestId: string; persistence: ProviderPersistence } | { type: "session.unarchive"; requestId: string; persistence: ProviderPersistence } + | { type: "session.can_stop_runtime"; requestId: string; sessionId: string } | { type: "session.close"; requestId: string; sessionId: string }; export interface ProviderThinkingOption { @@ -552,6 +554,7 @@ export type ProviderTimelineItem = }); export type ProviderEvent = + | { type: "session.can_stop_runtime"; requestId: string; sessionId: string; canStop: boolean } | { type: "catalog"; requestId: string; catalog: ProviderCatalog } | { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] } | { type: "request.completed"; requestId: string } @@ -634,6 +637,8 @@ function requiredPromptCapabilities(prompt: ProviderPrompt): readonly ProviderCa export function requiredProviderCapabilities(input: ProviderInput): readonly ProviderCapability[] { switch (input.type) { + case "session.can_stop_runtime": + return ["session.can_stop_runtime"]; case "catalog": case "session.interrupt": case "session.close": @@ -955,6 +960,13 @@ export const ProviderInputSchema: z.ZodType = z.discriminatedUnio persistence: persistenceSchema, }) .strict(), + z + .object({ + type: z.literal("session.can_stop_runtime"), + requestId: idSchema, + sessionId: idSchema, + }) + .strict(), z.object({ type: z.literal("session.close"), requestId: idSchema, sessionId: idSchema }).strict(), ]); @@ -1326,6 +1338,14 @@ export const ProviderEventSchema: z.ZodType = z.discriminatedUnio sessionId: idSchema, }) .strip(), + z + .object({ + type: z.literal("session.can_stop_runtime"), + requestId: idSchema, + sessionId: idSchema, + canStop: z.boolean(), + }) + .strip(), z .object({ type: z.literal("session.closed"), diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 90ba994043..70d783f701 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.13.0", + "version": "0.15.0-beta.1", "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 c7fffe8ce9..ca04655430 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -288,7 +288,7 @@ export type ToolCallDetail = subAgentType?: string; description?: string; childSessionId?: string; - log: string; + log?: string; actions?: Array<{ index: number; toolName: string; diff --git a/packages/protocol/src/binary-frames/demux.test.ts b/packages/protocol/src/binary-frames/demux.test.ts index c56e417528..a350cfaf03 100644 --- a/packages/protocol/src/binary-frames/demux.test.ts +++ b/packages/protocol/src/binary-frames/demux.test.ts @@ -89,3 +89,18 @@ describe("host route binary framing", () => { ).toThrow("Invalid host route ID"); }); }); + +it.each([ + TerminalStreamOpcode.ReplayBegin, + TerminalStreamOpcode.ReplayData, + TerminalStreamOpcode.ReplayEnd, +])("decodes replay opcode %s directly and inside a host route", (opcode) => { + const payload = new TextEncoder().encode("界 replay"); + const bytes = encodeTerminalStreamFrame({ opcode, slot: 12, payload }); + const expected = { kind: "terminal", frame: { opcode, slot: 12, payload } }; + expect(decodeBinaryFrame(bytes)).toEqual(expected); + const routed = decodeBinaryFrame(encodeHostRouteFrame("host", bytes)); + expect(routed?.kind).toBe("host_route"); + if (routed?.kind !== "host_route") throw new Error("Missing host route"); + expect(decodeBinaryFrame(routed.frame.payload)).toEqual(expected); +}); diff --git a/packages/protocol/src/binary-frames/demux.ts b/packages/protocol/src/binary-frames/demux.ts index a8503b898f..c207026362 100644 --- a/packages/protocol/src/binary-frames/demux.ts +++ b/packages/protocol/src/binary-frames/demux.ts @@ -25,6 +25,9 @@ export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null { case TerminalStreamOpcode.Input: case TerminalStreamOpcode.Resize: case TerminalStreamOpcode.Snapshot: + case TerminalStreamOpcode.ReplayBegin: + case TerminalStreamOpcode.ReplayData: + case TerminalStreamOpcode.ReplayEnd: case TerminalStreamOpcode.Restore: { const frame = decodeTerminalStreamFrame(bytes); return frame ? { kind: "terminal", frame } : null; diff --git a/packages/protocol/src/binary-frames/host-route.ts b/packages/protocol/src/binary-frames/host-route.ts index f45cd714f9..643dcb4e98 100644 --- a/packages/protocol/src/binary-frames/host-route.ts +++ b/packages/protocol/src/binary-frames/host-route.ts @@ -3,6 +3,11 @@ import { MAX_WEBSOCKET_MESSAGE_BYTES } from "../websocket-message-limit.js"; export const HOST_ROUTE_OPCODE = 0x30; export const MAX_HOST_ROUTE_ID_BYTES = 256; export const MAX_HOST_ROUTE_HEADER_BYTES = 3 + MAX_HOST_ROUTE_ID_BYTES; +// A logical host channel crosses the 32 MiB route admission boundary. Text can +// expand sixfold when enclosed in JSON; binary adds the bounded route header. +export const MAX_HOST_CHANNEL_FRAME_BYTES = Math.floor( + (32 * 1024 * 1024 - MAX_HOST_ROUTE_HEADER_BYTES) / 6, +); export const MAX_ROUTED_PAYLOAD_BYTES = MAX_WEBSOCKET_MESSAGE_BYTES - MAX_HOST_ROUTE_HEADER_BYTES; export interface HostRouteFrame { routeId: string; diff --git a/packages/protocol/src/binary-frames/terminal.ts b/packages/protocol/src/binary-frames/terminal.ts index 3936502c89..b74ea7a8de 100644 --- a/packages/protocol/src/binary-frames/terminal.ts +++ b/packages/protocol/src/binary-frames/terminal.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import { TerminalStateSchema } from "../messages.js"; +export const TerminalReplayMetadataSchema = z.object({ + rows: z.number().int().positive(), + cols: z.number().int().positive(), + retainedHistoryLines: z.number().int().nonnegative(), +}); + export const TerminalStreamResizeSchema = z.object({ rows: z.number().int().positive(), cols: z.number().int().positive(), @@ -13,6 +19,9 @@ export const TerminalStreamOpcode = { Resize: 0x03, Snapshot: 0x04, Restore: 0x05, + ReplayBegin: 0x06, + ReplayData: 0x07, + ReplayEnd: 0x08, } as const; export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode]; @@ -58,6 +67,9 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Input || value === TerminalStreamOpcode.Resize || value === TerminalStreamOpcode.Snapshot || + value === TerminalStreamOpcode.ReplayBegin || + value === TerminalStreamOpcode.ReplayData || + value === TerminalStreamOpcode.ReplayEnd || value === TerminalStreamOpcode.Restore ); } diff --git a/packages/protocol/src/chunked-transport.test.ts b/packages/protocol/src/chunked-transport.test.ts new file mode 100644 index 0000000000..3979b48885 --- /dev/null +++ b/packages/protocol/src/chunked-transport.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, it } from "vitest"; +import { ChunkedTransport } from "./chunked-transport.js"; + +describe("chunked transport", () => { + it("delivers a large UTF-8 logical message once, preserving order", async () => { + const delivered: (string | Uint8Array)[] = []; + const receiver = new ChunkedTransport({ + frameBytes: 17, + send: () => {}, + deliver: (v) => delivered.push(v), + }); + receiver.enabled = true; + const frames: (string | Uint8Array)[] = []; + const sender = new ChunkedTransport({ + frameBytes: 17, + send: (v) => { + frames.push(v); + receiver.receive(v); + }, + deliver: () => {}, + }); + sender.enabled = true; + const text = "🌍".repeat(100); + await sender.send(text); + expect(delivered).toEqual([text]); + expect(frames.length).toBeGreaterThan(1); + expect(frames.every((v) => v instanceof Uint8Array && v.byteLength <= 17)).toBe(true); + }); +}); + +it("preserves binary kind and leaves legacy frames unchanged", async () => { + const delivered: (string | Uint8Array)[] = []; + const receiver = new ChunkedTransport({ + frameBytes: 8, + send: () => {}, + deliver: (v) => delivered.push(v), + }); + const frames: (string | Uint8Array)[] = []; + const sender = new ChunkedTransport({ + frameBytes: 8, + send: (v) => { + frames.push(v); + receiver.receive(v); + }, + deliver: () => {}, + }); + const data = new Uint8Array(40).fill(5); + await sender.send(data); + expect(frames).toEqual([data]); + frames.length = 0; + sender.enabled = receiver.enabled = true; + await sender.send(data); + expect(frames).toHaveLength(10); + expect(delivered).toEqual([data, data]); +}); + +it("rejects malformed sequences and drops incomplete transfers on close", () => { + const delivered: unknown[] = []; + const receiver = new ChunkedTransport({ + frameBytes: 8, + send: () => {}, + deliver: (v) => delivered.push(v), + }); + receiver.enabled = true; + receiver.receive(new Uint8Array([0x31, 1, 0, 1, 65])); + expect(() => receiver.receive("interrupted")).toThrow("Interrupted"); + receiver.receive(new Uint8Array([0x31, 1, 0, 2, 66])); + expect(delivered).toEqual([]); +}); + +it("waits for drain before the next chunk and preserves logical ordering", async () => { + const frames: (string | Uint8Array)[] = []; + let drain!: () => void; + const sender = new ChunkedTransport({ + frameBytes: 8, + send: (v) => { + frames.push(v); + if (frames.length === 1) + return new Promise((resolve) => { + drain = resolve; + }); + }, + deliver: () => {}, + }); + sender.enabled = true; + const first = sender.send("abcdefghij"); + const next = new Uint8Array([1, 2, 3, 4]); + const second = sender.send(next); + await Promise.resolve(); + expect(frames).toHaveLength(1); + expect(sender.queuedByteLength).toBe(4); + drain(); + await Promise.all([first, second]); + expect(frames).toHaveLength(4); + expect(frames[3]).toEqual(next); + expect(sender.queuedByteLength).toBe(0); +}); + +it.each([ + [0x31, 2, 0, 3, 65], + [0x31, 1, 2, 3, 65], + [0x31, 1, 0, 4, 65], + [0x31, 1, 0, 2, 65], + [0x31, 1, 0, 1], +])("rejects malformed chunk metadata %j", (...bytes) => { + const receiver = new ChunkedTransport({ + frameBytes: 8, + send: () => {}, + deliver: () => { + throw new Error("Unexpected delivery"); + }, + }); + receiver.enabled = true; + expect(() => receiver.receive(new Uint8Array(bytes))).toThrow(); +}); + +it("reconnect discards partial state and requires negotiation again", () => { + const delivered: unknown[] = []; + const create = () => + new ChunkedTransport({ frameBytes: 8, send: () => {}, deliver: (v) => delivered.push(v) }); + const old = create(); + old.enabled = true; + old.receive(new Uint8Array([0x31, 1, 0, 1, 65])); + old.close(); + old.receive(new Uint8Array([0x31, 1, 0, 2, 66])); + const fresh = create(); + expect(fresh.enabled).toBe(false); + fresh.enabled = true; + fresh.receive(new Uint8Array([0x31, 1, 0, 3, 67])); + expect(delivered).toEqual(["C"]); +}); + +it("bounds logical messages waiting behind a blocked writer", async () => { + let drain!: () => void; + const transport = new ChunkedTransport({ + frameBytes: 32 * 1024 * 1024, + send: () => + new Promise((resolve) => { + drain = resolve; + }), + deliver: () => {}, + }); + const first = transport.send(new Uint8Array(100 * 1024 * 1024)); + const overflow = transport.send("one more byte"); + try { + await expect(overflow).rejects.toThrow("queue"); + } finally { + drain(); + await first; + } +}); + +it("requests drain for chunks while ordinary frames keep the immediate write path", async () => { + const waits: boolean[] = []; + const transport = new ChunkedTransport({ + frameBytes: 8, + send: (_frame, wait) => { + waits.push(wait); + }, + deliver: () => {}, + }); + transport.enabled = true; + await transport.send("small"); + await transport.send("abcdefghij"); + expect(waits).toEqual([false, true, true, true]); +}); + +it("charges queued ASCII messages by their UTF-8 bytes", async () => { + let drain!: () => void; + let blocked = true; + const transport = new ChunkedTransport({ + frameBytes: 32 * 1024 * 1024, + send: () => { + if (blocked) { + blocked = false; + return new Promise((resolve) => { + drain = resolve; + }); + } + }, + deliver: () => {}, + }); + transport.enabled = true; + const first = transport.send(new Uint8Array([1])); + const text = "x".repeat(10 * 1024 * 1024); + const queued = Array.from({ length: 4 }, () => transport.send(text)); + const settled = Promise.allSettled(queued); + expect(transport.queuedByteLength).toBe(40 * 1024 * 1024); + drain(); + await first; + expect((await settled).map((result) => result.status)).toEqual([ + "fulfilled", + "fulfilled", + "fulfilled", + "fulfilled", + ]); +}); + +it("notifies the socket owner when an awaited send overflows the queue", async () => { + let drain!: () => void; + const failures: unknown[] = []; + const transport = new ChunkedTransport({ + frameBytes: 32 * 1024 * 1024, + send: () => + new Promise((resolve) => { + drain = resolve; + }), + deliver: () => {}, + onFailure: (error) => { + failures.push(error); + }, + }); + const first = transport.send(new Uint8Array(100 * 1024 * 1024)); + const overflow = transport.send("extra"); + await expect(overflow).rejects.toThrow("queue"); + drain(); + await first; + expect(failures).toHaveLength(1); +}); + +it("bounds retained reassembly memory for many one-byte chunks", async () => { + const { spawnSync } = await import("node:child_process"); + // Exercise the built package as a Node consumer; CI builds it before protocol tests. + const entry = new URL("../dist/chunked-transport.js", import.meta.url).href; + const result = spawnSync( + process.execPath, + [ + "--expose-gc", + "--input-type=module", + "-e", + ` + import { ChunkedTransport } from ${JSON.stringify(entry)}; + let delivered = 0; + const receiver = new ChunkedTransport({ frameBytes: 8, send() {}, deliver(frame) { delivered = frame.byteLength; } }); + receiver.enabled = true; + global.gc(); + const before = process.memoryUsage().heapUsed; + receiver.receive(new Uint8Array([0x31, 1, 1, 1, 65])); + for (let index = 0; index < 300000; index++) receiver.receive(new Uint8Array([0x31, 1, 1, 0, 65])); + global.gc(); + const growth = process.memoryUsage().heapUsed - before; + receiver.receive(new Uint8Array([0x31, 1, 1, 2, 66])); + console.log(JSON.stringify({ growth, delivered })); + `, + ], + { encoding: "utf8", timeout: 15000 }, + ); + expect(result.status, result.stderr).toBe(0); + const observed = JSON.parse(result.stdout); + expect(observed.delivered).toBe(300002); + expect(observed.growth).toBeLessThan(8 * 1024 * 1024); +}, 20000); + +it.each(["ASCII", "中", "🌍", "\ud800", "\udc00", "\ud800x"])( + "accounts for queued UTF-8 text %j exactly", + async (text) => { + let drain!: () => void; + let blocked = true; + const transport = new ChunkedTransport({ + frameBytes: 1024, + send: () => { + if (blocked) { + blocked = false; + return new Promise((resolve) => { + drain = resolve; + }); + } + }, + deliver: () => {}, + }); + const first = transport.send(new Uint8Array([1])); + const second = transport.send(text); + expect(transport.queuedByteLength).toBe(new TextEncoder().encode(text).byteLength); + drain(); + await Promise.all([first, second]); + }, +); + +it("notifies the owner once when an accepted writer fails", async () => { + const failures: unknown[] = []; + const error = new Error("write failed"); + const transport = new ChunkedTransport({ + frameBytes: 1024, + deliver: () => {}, + onFailure: (failure) => { + failures.push(failure); + }, + send: () => Promise.reject(error), + }); + await expect(transport.send("hello", { waitForDrain: true })).rejects.toThrow("write failed"); + expect(failures).toEqual([error]); + expect(() => transport.send("again")).toThrow("Transport closed"); + expect(failures).toEqual([error]); +}); + +it.each([false, true])( + "a pre-write rejection preserves the sender (chunked=%s)", + async (chunked) => { + const failures: unknown[] = []; + let reject = true; + const transport = new ChunkedTransport({ + frameBytes: 8, + deliver: () => {}, + onFailure: (error) => { + failures.push(error); + }, + send: () => { + if (reject) throw new Error("capacity"); + }, + }); + transport.enabled = chunked; + expect(() => transport.send("abcdefghij")).toThrow("capacity"); + expect(failures).toEqual([]); + reject = false; + await transport.send("sibling"); + }, +); + +it("a queued pre-write rejection does not poison the next logical send", async () => { + let drain!: () => void; + const failures: unknown[] = []; + const delivered: string[] = []; + const transport = new ChunkedTransport({ + frameBytes: 1024, + deliver: () => {}, + onFailure: (error) => { + failures.push(error); + }, + send: (frame) => { + if (frame === "blocked") + return new Promise((resolve) => { + drain = resolve; + }); + if (frame === "reject") throw new Error("capacity"); + delivered.push(frame as string); + }, + }); + const first = transport.send("blocked"); + const rejected = transport.send("reject"); + const sibling = transport.send("sibling"); + const results = Promise.allSettled([first, rejected, sibling]); + drain(); + expect((await results).map((result) => result.status)).toEqual([ + "fulfilled", + "rejected", + "fulfilled", + ]); + expect(delivered).toEqual(["sibling"]); + expect(failures).toEqual([]); +}); + +it.each([false, true])("delivery errors leave framing usable (chunked=%s)", async (chunked) => { + let fail = true; + const delivered: (string | Uint8Array)[] = []; + const transport = new ChunkedTransport({ + frameBytes: 64, + send: () => {}, + deliver: (frame) => { + if (fail) throw new Error("listener failed"); + delivered.push(frame); + }, + }); + transport.enabled = chunked; + const frame = chunked ? new Uint8Array([0x31, 1, 0, 3, 65]) : "A"; + expect(() => transport.receive(frame)).toThrow("listener failed"); + fail = false; + transport.receive(frame); + expect(delivered).toEqual(["A"]); + await transport.send("still connected"); +}); + +it("unpaced transports retain ordinary outbound admission and accept inbound chunks", async () => { + const frames: unknown[] = []; + const delivered: unknown[] = []; + const transport = new ChunkedTransport({ + frameBytes: 8, + supportsDrain: false, + send: (frame) => { + frames.push(frame); + if (typeof frame === "string" && frame.length > 8) throw new Error("single frame limit"); + }, + deliver: (frame) => { + delivered.push(frame); + }, + }); + transport.enabled = true; + expect(() => transport.send("too large for one frame")).toThrow("single frame limit"); + expect(frames).toEqual(["too large for one frame"]); + transport.receive(new Uint8Array([0x31, 1, 0, 3, 65])); + expect(delivered).toEqual(["A"]); + await transport.send("small"); +}); diff --git a/packages/protocol/src/chunked-transport.ts b/packages/protocol/src/chunked-transport.ts new file mode 100644 index 0000000000..a32d68424f --- /dev/null +++ b/packages/protocol/src/chunked-transport.ts @@ -0,0 +1,214 @@ +import { MAX_WEBSOCKET_MESSAGE_BYTES } from "./websocket-message-limit.js"; + +type Frame = string | Uint8Array; + +/** A malformed wire frame, distinct from a consumer delivery failure. */ +export class ChunkedTransportProtocolError extends Error {} +// Reserved outside the application binary opcode space. Version, kind, flags follow. +const OPCODE = 0x31; +const HEADER = 4; + +/** Connection-scoped ordered framing, below serialization and routing, above encryption. */ +export class ChunkedTransport { + enabled = false; + private assembly: Uint8Array | undefined; + private size = 0; + private kind = 0; + private closed = false; + private pending: Promise | undefined; + private queuedBytes = 0; + private activeBytes = 0; + + constructor( + private readonly io: { + frameBytes: number; + supportsDrain?: boolean; + send: (frame: Frame, waitForDrain: boolean) => void | Promise; + deliver: (frame: Frame) => void; + onFailure?: (error: unknown) => void; + }, + ) { + if (io.frameBytes <= HEADER) throw new Error("Invalid transport frame budget"); + } + + /** Bytes waiting behind the active write; physical buffering owns the active frame. */ + get queuedByteLength(): number { + return this.queuedBytes - this.activeBytes; + } + get isWriting(): boolean { + return this.pending !== undefined; + } + + send(frame: Frame, options: { waitForDrain?: boolean; byteLength?: number } = {}): Promise { + const byteLength = options.byteLength ?? frameByteLength(frame); + if (byteLength > MAX_WEBSOCKET_MESSAGE_BYTES) throw new Error("Logical message too large"); + const chunk = + this.enabled && this.io.supportsDrain !== false && byteLength > this.io.frameBytes; + if (this.closed) throw new Error("Transport closed"); + const write = () => { + if (this.closed) throw new Error("Transport closed"); + this.activeBytes = byteLength; + // A synchronous rejection did not admit a frame. Only accepted writes can + // fail the connection; queued admission failures still belong to their sender. + const result = chunk + ? this.sendChunks( + typeof frame === "string" ? new TextEncoder().encode(frame) : frame, + typeof frame === "string", + ) + : this.io.send(frame, options.waitForDrain === true); + if (!result) return; + return result.catch((error) => { + this.fail(error); + throw error; + }); + }; + if (this.queuedBytes + byteLength > MAX_WEBSOCKET_MESSAGE_BYTES) { + const error = new Error("Logical output queue exceeded its byte limit"); + this.fail(error); + const rejected = Promise.reject(error); + void rejected.catch(() => {}); + return rejected; + } + this.queuedBytes += byteLength; + let result: void | Promise; + try { + result = this.pending ? this.pending.then(write) : write(); + } catch (error) { + this.queuedBytes -= byteLength; + this.activeBytes = 0; + throw error; + } + if (!result) { + this.queuedBytes -= byteLength; + this.activeBytes = 0; + return Promise.resolve(); + } + const completion = Promise.resolve(result).finally(() => { + this.queuedBytes -= byteLength; + this.activeBytes = 0; + }); + const tail = completion.then( + () => undefined, + () => undefined, + ); + this.pending = tail; + void tail.then(() => { + if (this.pending === tail) this.pending = undefined; + return undefined; + }); + return completion; + } + + private sendChunks(bytes: Uint8Array, text: boolean): Promise { + const capacity = this.io.frameBytes - HEADER; + const writeChunk = (offset: number) => { + if (this.closed) throw new Error("Transport closed"); + const end = Math.min(bytes.byteLength, offset + capacity); + const wire = new Uint8Array(HEADER + end - offset); + wire.set([ + OPCODE, + 1, + text ? 0 : 1, + (offset === 0 ? 1 : 0) | (end === bytes.byteLength ? 2 : 0), + ]); + wire.set(bytes.subarray(offset, end), HEADER); + return this.io.send(wire, true); + }; + const first = writeChunk(0); + return (async () => { + await first; + for (let offset = capacity; offset < bytes.byteLength; offset += capacity) + await writeChunk(offset); + })(); + } + + receive(frame: Frame): void { + if (this.closed) return; + let message: Frame | undefined; + try { + message = this.assemble(frame); + } catch (error) { + this.close(); + throw new ChunkedTransportProtocolError( + error instanceof Error ? error.message : String(error), + { cause: error }, + ); + } + if (message !== undefined) this.io.deliver(message); + } + + private assemble(frame: Frame): Frame | undefined { + if (typeof frame === "string" || frame[0] !== OPCODE) { + if (this.assembly) throw new Error("Interrupted chunked message"); + return frame; + } + if ( + !this.enabled || + frame.length <= HEADER || + frame[1] !== 1 || + frame[2]! > 1 || + (frame[3]! & ~3) !== 0 + ) + throw new Error("Invalid chunk header"); + const start = (frame[3]! & 1) !== 0; + const end = (frame[3]! & 2) !== 0; + if (start === !!this.assembly) throw new Error("Invalid chunk sequence"); + if (start) { + this.assembly = new Uint8Array(0); + this.kind = frame[2]!; + this.size = 0; + } + if (frame[2] !== this.kind) throw new Error("Chunk kind changed"); + this.size += frame.length - HEADER; + if (this.size > MAX_WEBSOCKET_MESSAGE_BYTES) throw new Error("Logical message too large"); + const payload = frame.subarray(HEADER); + if (this.assembly!.byteLength < this.size) { + const capacity = Math.min( + MAX_WEBSOCKET_MESSAGE_BYTES, + Math.max(this.size, this.assembly!.byteLength * 2, 16384), + ); + const grown = new Uint8Array(capacity); + grown.set(this.assembly!.subarray(0, this.size - payload.byteLength)); + this.assembly = grown; + } + this.assembly!.set(payload, this.size - payload.byteLength); + if (!end) return; + const data = this.assembly!.subarray(0, this.size); + this.assembly = undefined; + this.size = 0; + return this.kind === 0 ? new TextDecoder("utf-8", { fatal: true }).decode(data) : data.slice(); + } + + private fail(error: unknown): void { + if (this.closed) return; + this.close(); + this.io.onFailure?.(error); + } + + close(): void { + this.closed = true; + this.enabled = false; + this.assembly = undefined; + this.size = 0; + } +} + +function frameByteLength(frame: Frame): number { + if (typeof frame !== "string") return frame.byteLength; + let bytes = 0; + for (let index = 0; index < frame.length; index++) { + const code = frame.charCodeAt(index); + if (code <= 0x7f) bytes++; + else if (code <= 0x7ff) bytes += 2; + else if ( + code >= 0xd800 && + code <= 0xdbff && + frame.charCodeAt(index + 1) >= 0xdc00 && + frame.charCodeAt(index + 1) <= 0xdfff + ) { + bytes += 4; + index++; + } else bytes += 3; + } + return bytes; +} diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index a35852cc06..3ae02671e7 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,4 +1,6 @@ export const CLIENT_CAPS = { + terminalChunkedReplay: "terminal_chunked_replay", + binaryChunkingV1: "binary_chunking_v1", helloRejection: "hello_rejection", // COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09. ownedSubscriptions: "owned_subscriptions", diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index f266b65988..47e91493fe 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -23,6 +23,29 @@ describe("plugin protocol compatibility", () => { expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); }, ); + it.each([ + { type: "plugin.catalog.get.request", requestId: "catalog" }, + { + type: "plugin.catalog.get.request", + requestId: "catalog", + cachedClientBundles: [{ id: "example", clientBundleHash: "a".repeat(64) }], + }, + ])("parses plugin catalog requests %j", (request) => { + expect(SessionInboundMessageSchema.parse(request)).toEqual(request); + }); + it("parses plugin catalogs that leave out a client bundle the client already holds", () => { + const message = { + type: "plugin.catalog.get.response", + payload: { + requestId: "catalog", + plugins: [ + { id: "cached", clientBundleHash: "a".repeat(64) }, + { id: "changed", clientBundle: "bundle", clientBundleHash: "b".repeat(64) }, + ], + }, + }; + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }); it("preserves manifest metadata in plugin list responses", () => { const message = { type: "plugin.list.response", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4beaf0a0b2..18654bf337 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -82,6 +82,16 @@ import { ScheduleRunOnceResponseSchema, ScheduleUpdateResponseSchema, } from "./schedule/rpc-schemas.js"; +import { + SpeechLocalModelsDeleteRequestSchema, + SpeechLocalModelsDeleteResponseSchema, + SpeechLocalModelsDownloadRequestSchema, + SpeechLocalModelsDownloadResponseSchema, + SpeechLocalModelsListRequestSchema, + SpeechLocalModelsListResponseSchema, + SpeechSettingsPatchSchema, + SpeechSettingsSchema, +} from "./speech/rpc-schemas.js"; import { LoopRunRequestSchema, LoopListRequestSchema, @@ -219,17 +229,28 @@ export const MutableDaemonConfigSchema = z .optional(), app: z.object({ baseUrl: z.string() }).optional(), catalogRefreshTimeoutMs: z.number().int().positive().optional(), + // COMPAT(usageSetting): added in v0.14, remove after 2027-05-01. + usage: z.object({ enabled: z.boolean() }).optional(), browserTools: MutableBrowserToolsConfigSchema.default({ enabled: false }), providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }), autoArchiveAfterMerge: z.boolean().default(false), enableTerminalAgentHooks: z.boolean().default(false), + // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported. + // Persisted host-wide value, applied live by the daemon. + idleRuntimeCleanup: z.boolean().optional(), + // COMPAT(restartRecovery): added in v0.14.0, keep optional for older hosts. + resumeOnStartup: z.boolean().optional(), + resumePrompt: z.string().optional(), appendSystemPrompt: z.string().default(""), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), + // COMPAT(speechSettings): added in v0.12.0, optional because older daemons omit it. + features: SpeechSettingsSchema.optional(), + envOverrides: z.record(z.string(), z.string()).optional(), }) .passthrough(); @@ -237,6 +258,7 @@ export const MutableDaemonConfigPatchSchema = z .object({ relay: MutableRelayConfigSchema.partial().optional(), mcp: z.object({ injectIntoAgents: z.boolean().optional() }).passthrough().optional(), + usage: z.object({ enabled: z.boolean() }).optional(), browserTools: MutableBrowserToolsConfigSchema.partial().optional(), providers: z .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) @@ -245,11 +267,15 @@ export const MutableDaemonConfigPatchSchema = z metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(), autoArchiveAfterMerge: z.boolean().optional(), enableTerminalAgentHooks: z.boolean().optional(), + idleRuntimeCleanup: z.boolean().optional(), + resumeOnStartup: z.boolean().optional(), + resumePrompt: z.string().optional(), appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), + features: SpeechSettingsPatchSchema.optional(), }) .partial() .passthrough(); @@ -615,7 +641,9 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discri subAgentType: z.string().optional(), description: z.string().optional(), childSessionId: z.string().optional(), - log: z.string(), + // COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. A sub-agent card is a + // launch marker; apps before v0.14.1 required `log`, so daemons still send it empty. + log: z.string().optional(), // Compat cruft for clients <= 0.1.65-beta.3 that required this field. Producers still // emit `[]`; nothing reads it. Drop the field (and the `[]` emissions) once those // clients are no longer in the field. @@ -1452,6 +1480,10 @@ export const DiagnosticsRequestSchema = z.object({ export const PluginCatalogGetRequestSchema = z.object({ type: z.literal("plugin.catalog.get.request"), requestId: z.string(), + /** Client bundles the client already holds; the daemon leaves out each one whose hash matches. */ + cachedClientBundles: z + .array(z.object({ id: PluginIdSchema, clientBundleHash: z.string() })) + .optional(), }); export const PluginListRequestSchema = z.object({ @@ -1874,11 +1906,17 @@ export const RestartServerRequestMessageSchema = z.object({ type: z.literal("restart_server_request"), reason: z.string().optional(), requestId: z.string(), + // Restart keeps terminals unless this is true. Daemons that predate + // `features.terminalsSurviveRestart` ignore it and close terminals anyway. + killTerminals: z.boolean().optional(), }); export const ShutdownServerRequestMessageSchema = z.object({ type: z.literal("shutdown_server_request"), requestId: z.string(), + // Shutdown closes terminals unless this is true. Gate on + // `features.terminalsSurviveRestart`: older daemons drop the field. + keepTerminals: z.boolean().optional(), }); export const DaemonUpdateRequestMessageSchema = z.object({ @@ -1892,6 +1930,8 @@ export const AgentTimelineCursorSchema = z.object({ }); export const FetchAgentTimelineRequestMessageSchema = z.object({ + // Keep available turns whole for tail/before pages; after catch-up stays incremental. + wholeTurns: z.boolean().optional(), type: z.literal("fetch_agent_timeline_request"), agentId: z.string(), requestId: z.string(), @@ -3436,6 +3476,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ChatWaitRequestSchema, ScheduleCreateRequestSchema, ScheduleListRequestSchema, + SpeechLocalModelsListRequestSchema, + SpeechLocalModelsDownloadRequestSchema, + SpeechLocalModelsDeleteRequestSchema, ScheduleInspectRequestSchema, ScheduleLogsRequestSchema, SchedulePauseRequestSchema, @@ -3635,6 +3678,8 @@ export const ServerInfoStatusPayloadSchema = z providersSnapshot: z.boolean().optional(), connectedHosts: z.boolean().optional(), hostMultiplexing: z.boolean().optional(), + // COMPAT(usageSetting): added in v0.14, remove after 2027-05-01. + usageSetting: z.boolean().optional(), usageSources: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. providersSnapshotCwd: z.boolean().optional(), @@ -3670,6 +3715,8 @@ export const ServerInfoStatusPayloadSchema = z daemonStatusRpc: z.boolean().optional(), // COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14. daemonConfigReload: z.boolean().optional(), + // COMPAT(speechSettings): added in v0.12.0, remove gate after 2027-04-18. + speechSettings: z.boolean().optional(), // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. relayConfig: z.boolean().optional(), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. @@ -3694,12 +3741,18 @@ export const ServerInfoStatusPayloadSchema = z pluginTimelineItems: z.boolean().optional(), // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: z.boolean().optional(), + // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26. + idleRuntimeCleanupSetting: z.boolean().optional(), + // COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28. + resumeOnStartupSetting: z.boolean().optional(), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": z.boolean().optional(), // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. "terminal-input-mode-replay": z.boolean().optional(), // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. "terminal-size-ownership": z.boolean().optional(), + // COMPAT(terminalGraphemeWidths): added in v0.15.0, remove gate after 2027-05-01. + "terminal-grapheme-widths": z.boolean().optional(), // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: z.boolean().optional(), // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. @@ -3732,6 +3785,8 @@ export const ServerInfoStatusPayloadSchema = z daemonDiagnostics: z.boolean().optional(), // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. daemonSelfUpdate: z.boolean().optional(), + // COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21. + terminalsSurviveRestart: z.boolean().optional(), // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: z.boolean().optional(), // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. @@ -3740,6 +3795,7 @@ export const ServerInfoStatusPayloadSchema = z providerSubagents: z.boolean().optional(), // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field. projectedSubagentTimeline: z.boolean().optional(), + wholeTurnTimeline: 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. @@ -3780,6 +3836,7 @@ export const ServerInfoStatusPayloadSchema = z selectiveAgentTimeline: z.boolean().optional(), explicitEventSubscriptions: z.boolean().optional(), ownedSubscriptions: z.boolean().optional(), + binaryChunkingV1: z.boolean().optional(), workspaceSetupObservation: z.boolean().optional(), workspaceSortTimes: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. @@ -5093,6 +5150,22 @@ export const DaemonGetStatusResponseSchema = z.object({ error: z.string().nullable().optional(), }), ), + // Processes a terminal cleanup on this host (a kill restart or stop) could not confirm + // gone; empty when every cleanup finished. Daemons without terminalsSurviveRestart have + // no such cleanup and omit it. + terminalCleanupUnconfirmed: z + .array( + z.object({ + pid: z.number(), + role: z.string(), + reason: z.string(), + detail: z.string(), + }), + ) + .optional(), + // Set when this host's terminals are held by a terminal worker from a newer Paseo, which + // this daemon leaves running but cannot drive: why, and what the user can do. + terminalsUnavailable: z.string().optional(), }) .passthrough(), }); @@ -6729,7 +6802,10 @@ export const PluginCatalogGetResponseSchema = z.object({ plugins: z.array( z.object({ id: PluginIdSchema, - clientBundle: z.string(), + /** Left out only when the request listed this plugin with the same `clientBundleHash`. */ + clientBundle: z.string().optional(), + /** SHA-256 of `clientBundle`, hex encoded. */ + clientBundleHash: z.string().optional(), requirements: PluginRequirementsSchema.optional(), }), ), @@ -7146,6 +7222,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ChatWaitResponseSchema, ScheduleCreateResponseSchema, ScheduleListResponseSchema, + SpeechLocalModelsListResponseSchema, + SpeechLocalModelsDownloadResponseSchema, + SpeechLocalModelsDeleteResponseSchema, ScheduleInspectResponseSchema, ScheduleLogsResponseSchema, SchedulePauseResponseSchema, diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index d3b3ce26df..abc89b06aa 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; import { + MutableDaemonConfigSchema, + MutableDaemonConfigPatchSchema, UsageReportEntrySchema, AgentSnapshotPayloadSchema, AgentTimelineItemPayloadSchema, @@ -198,8 +200,8 @@ describe("wire schema compatibility", () => { }); }); - test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => { - const parsed = LegacySubAgentToolCallSchema.parse({ + test("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => { + const card = { type: "tool_call", callId: "call-sub-agent-1", name: "Task", @@ -210,12 +212,26 @@ describe("wire schema compatibility", () => { subAgentType: "Explore", description: "Inspect repository structure", childSessionId: "child-session-1", - log: "[Read] README.md", + log: "", actions: [], }, - }); + } as const; - expect(parsed.detail.actions).toEqual([]); + const parsed = LegacySubAgentToolCallSchema.parse(JSON.parse(JSON.stringify(card))); + expect(parsed.detail).toMatchObject({ log: "", actions: [] }); + const { log: _log, ...withoutLog } = card.detail; + expect(LegacySubAgentToolCallSchema.safeParse({ ...card, detail: withoutLog }).success).toBe( + false, + ); + expect(AgentTimelineItemPayloadSchema.parse({ ...card, detail: withoutLog })).toMatchObject({ + detail: { type: "sub_agent", description: "Inspect repository structure" }, + }); + expect( + AgentTimelineItemPayloadSchema.parse({ + ...card, + detail: { ...card.detail, log: "[Read] README.md" }, + }), + ).toMatchObject({ detail: { log: "[Read] README.md" } }); }); test("old clients parse agent snapshots with rewind capabilities", () => { @@ -525,3 +541,50 @@ test("released setup clients parse empty combined logs and retain command output }).detail.log, ).toBe("old daemon combined output"); }); + +test("local STT language survives wire reads and patches", () => { + const patch = { + features: { + dictation: { stt: { provider: "local", language: "fr" } }, + voiceMode: { stt: { provider: "local", language: null } }, + }, + }; + expect(MutableDaemonConfigPatchSchema.parse(patch)).toEqual(patch); +}); + +test("speech config reads remain compatible with clients before features and future providers", () => { + const oldSchema = MutableDaemonConfigSchema.omit({ features: true, envOverrides: true }); + const oldRead = oldSchema.parse({ + providers: {}, + relay: { enabled: false }, + mcp: { enabled: true, injectIntoAgents: true }, + }); + const read = { + ...oldRead, + features: { + dictation: { enabled: true, stt: { provider: "local", language: "fr" } }, + voiceMode: { + enabled: false, + stt: { provider: "future", model: "future-model", region: "eu" }, + tts: { provider: "openai", hasApiKey: false }, + }, + }, + envOverrides: { "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER" }, + }; + expect(oldSchema.parse(read)).toEqual(read); + expect(MutableDaemonConfigSchema.parse(oldRead)).toEqual(oldRead); + expect(MutableDaemonConfigSchema.parse(read).features?.dictation.stt).toEqual( + read.features.dictation.stt, + ); + expect(MutableDaemonConfigSchema.parse(read).features?.voiceMode.stt).toEqual( + read.features.voiceMode.stt, + ); + const oldPatchSchema = MutableDaemonConfigPatchSchema.omit({ features: true }); + const oldPatch = oldPatchSchema.parse({ relay: { enabled: true } }); + expect(MutableDaemonConfigPatchSchema.parse(oldPatch)).toEqual(oldPatch); + expect( + MutableDaemonConfigPatchSchema.parse({ + features: { voiceMode: { stt: { provider: "future", region: "eu" } } }, + }).features?.voiceMode?.stt, + ).toEqual({ provider: "future", region: "eu" }); +}); diff --git a/packages/protocol/src/paseo-tool-presentation/catalog.ts b/packages/protocol/src/paseo-tool-presentation/catalog.ts index 0c35b128b7..4844b7ceb7 100644 --- a/packages/protocol/src/paseo-tool-presentation/catalog.ts +++ b/packages/protocol/src/paseo-tool-presentation/catalog.ts @@ -19,6 +19,7 @@ export interface PaseoToolDetailSpec { export interface PaseoToolEntry { label: string; + completedLabel: string; context?: PaseoToolContext; detail?: PaseoToolDetailSpec; } @@ -123,14 +124,24 @@ function baseName(value: unknown): string | undefined { return value.split(/[\\/]/).findLast((segment) => segment.length > 0); } -function browser(label: string, context?: PaseoToolContext): PaseoToolEntry { - return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } }; +function browser( + label: string, + completedLabel: string, + context?: PaseoToolContext, +): PaseoToolEntry { + return { + label, + completedLabel, + ...(context ? { context } : {}), + detail: { inputOrder: BROWSER_FIELDS }, + }; } export const PASEO_TOOL_CATALOG = { // Agents create_agent: { label: "Create agent", + completedLabel: "Created agent", context: field("title", "initialPrompt"), detail: { promptField: "initialPrompt", @@ -140,6 +151,7 @@ export const PASEO_TOOL_CATALOG = { }, send_agent_prompt: { label: "Send message", + completedLabel: "Sent message", context: field("prompt"), detail: { promptField: "prompt", @@ -149,42 +161,55 @@ export const PASEO_TOOL_CATALOG = { }, get_agent_status: { label: "Check agent", + completedLabel: "Checked agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, get_agent_activity: { label: "Read agent activity", + completedLabel: "Read agent activity", context: targetAgent(), detail: { inputOrder: ["agentId", "limit"] }, }, list_agents: { label: "List agents", + completedLabel: "Listed agents", detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] }, }, update_agent: { label: "Update agent", + completedLabel: "Updated agent", context: fieldOrTargetAgent("name"), detail: { inputOrder: AGENT_FIELDS }, }, set_agent_mode: { label: "Set agent mode", + completedLabel: "Set agent mode", context: field("modeId"), detail: { inputOrder: ["agentId", "modeId"] }, }, cancel_agent: { label: "Cancel agent", + completedLabel: "Canceled agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, archive_agent: { label: "Archive agent", + completedLabel: "Archived agent", context: targetAgent(), detail: { inputOrder: ["agentId"] }, }, - kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } }, - list_pending_permissions: { label: "List permissions" }, + kill_agent: { + label: "Kill agent", + completedLabel: "Killed agent", + context: targetAgent(), + detail: { inputOrder: ["agentId"] }, + }, + list_pending_permissions: { label: "List permissions", completedLabel: "Listed permissions" }, respond_to_permission: { label: "Respond to permission", + completedLabel: "Responded to permission", context: targetAgent(), detail: { inputOrder: ["agentId", "requestId", "response"] }, }, @@ -192,32 +217,41 @@ export const PASEO_TOOL_CATALOG = { // Workspaces and scripts create_workspace: { label: "Create workspace", + completedLabel: "Created workspace", context: workspaceContext(), detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] }, }, - list_hosts: { label: "List hosts" }, - list_workspaces: { label: "List workspaces" }, + list_hosts: { label: "List hosts", completedLabel: "Listed hosts" }, + list_workspaces: { label: "List workspaces", completedLabel: "Listed workspaces" }, rename_workspace: { label: "Rename workspace", + completedLabel: "Renamed workspace", context: field("title"), detail: { inputOrder: ["title", "workspaceId"] }, }, archive_workspace: { label: "Archive workspace", + completedLabel: "Archived workspace", context: field("workspaceId"), detail: { inputOrder: ["workspaceId"], outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"], }, }, - list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } }, + list_workspace_scripts: { + label: "List scripts", + completedLabel: "Listed scripts", + detail: { inputOrder: ["workspaceId"] }, + }, start_workspace_script: { label: "Start script", + completedLabel: "Started script", context: field("scriptName"), detail: { inputOrder: ["workspaceId", "scriptName"] }, }, stop_workspace_script: { label: "Stop script", + completedLabel: "Stopped script", context: field("scriptName"), detail: { inputOrder: ["workspaceId", "scriptName"] }, }, @@ -225,22 +259,30 @@ export const PASEO_TOOL_CATALOG = { // Terminals create_terminal: { label: "Create terminal", + completedLabel: "Created terminal", context: field("name", "command"), detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] }, }, - list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } }, + list_terminals: { + label: "List terminals", + completedLabel: "Listed terminals", + detail: { inputOrder: ["cwd", "all"] }, + }, capture_terminal: { label: "Read terminal", + completedLabel: "Read terminal", context: field("terminalId"), detail: { inputOrder: ["terminalId", "lines"] }, }, send_terminal_keys: { label: "Send keys", + completedLabel: "Sent keys", context: field("keys"), detail: { inputOrder: ["terminalId", "keys", "literal"] }, }, kill_terminal: { label: "Kill terminal", + completedLabel: "Killed terminal", context: field("terminalId"), detail: { inputOrder: ["terminalId"] }, }, @@ -248,6 +290,7 @@ export const PASEO_TOOL_CATALOG = { // Schedules and heartbeats create_schedule: { label: "Create schedule", + completedLabel: "Created schedule", context: field("name", "cron"), detail: { promptField: "prompt", @@ -257,6 +300,7 @@ export const PASEO_TOOL_CATALOG = { }, create_heartbeat: { label: "Create heartbeat", + completedLabel: "Created heartbeat", context: field("name", "cron"), detail: { promptField: "prompt", @@ -266,79 +310,93 @@ export const PASEO_TOOL_CATALOG = { }, update_schedule: { label: "Update schedule", + completedLabel: "Updated schedule", context: field("name", "id"), detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS }, }, - list_schedules: { label: "List schedules" }, + list_schedules: { label: "List schedules", completedLabel: "Listed schedules" }, inspect_schedule: { label: "Inspect schedule", + completedLabel: "Inspected schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, schedule_logs: { label: "Read schedule logs", + completedLabel: "Read schedule logs", context: field("id"), detail: { inputOrder: ["id"] }, }, run_schedule_once: { label: "Run schedule", + completedLabel: "Ran schedule", + context: field("id"), + detail: { inputOrder: ["id"] }, + }, + pause_schedule: { + label: "Pause schedule", + completedLabel: "Paused schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, - pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } }, resume_schedule: { label: "Resume schedule", + completedLabel: "Resumed schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, delete_schedule: { label: "Delete schedule", + completedLabel: "Deleted schedule", context: field("id"), detail: { inputOrder: ["id"] }, }, delete_heartbeat: { label: "Delete heartbeat", + completedLabel: "Deleted heartbeat", context: field("id"), detail: { inputOrder: ["id"] }, }, // Providers - list_providers: { label: "List providers" }, + list_providers: { label: "List providers", completedLabel: "Listed providers" }, list_models: { label: "List models", + completedLabel: "Listed models", context: field("provider"), detail: { inputOrder: ["provider"] }, }, - list_profiles: { label: "List profiles" }, + list_profiles: { label: "List profiles", completedLabel: "Listed profiles" }, inspect_provider: { label: "Inspect provider", + completedLabel: "Inspected provider", context: field("provider"), detail: { inputOrder: ["provider", "cwd", "settings"] }, }, // Browser - browser_list_tabs: browser("List browser tabs"), - browser_new_tab: browser("Open browser tab", field("url")), - browser_close_tab: browser("Close browser tab", field("browserId")), - browser_navigate: browser("Navigate", field("url")), - browser_back: browser("Go back"), - browser_forward: browser("Go forward"), - browser_reload: browser("Reload page"), - browser_snapshot: browser("Snapshot page"), - browser_screenshot: browser("Take screenshot"), - browser_click: browser("Click element", field("ref")), - browser_hover: browser("Hover element", field("ref")), - browser_drag: browser("Drag element", field("sourceRef")), - browser_fill: browser("Fill field", field("ref")), - browser_select: browser("Select option", field("value", "ref")), - browser_type: browser("Type text", field("text")), - browser_keypress: browser("Press key", field("key")), - browser_upload: browser("Upload files", field("filePaths")), - browser_wait: browser("Wait for page", field("text", "url")), - browser_scroll: browser("Scroll page"), - browser_resize: browser("Resize browser", viewportContext()), - browser_logs: browser("Read browser logs"), - browser_evaluate: browser("Run JavaScript", field("function")), + browser_list_tabs: browser("List browser tabs", "Listed browser tabs"), + browser_new_tab: browser("Open browser tab", "Opened browser tab", field("url")), + browser_close_tab: browser("Close browser tab", "Closed browser tab", field("browserId")), + browser_navigate: browser("Navigate", "Navigated", field("url")), + browser_back: browser("Go back", "Went back"), + browser_forward: browser("Go forward", "Went forward"), + browser_reload: browser("Reload page", "Reloaded page"), + browser_snapshot: browser("Snapshot page", "Snapshotted page"), + browser_screenshot: browser("Take screenshot", "Took screenshot"), + browser_click: browser("Click element", "Clicked element", field("ref")), + browser_hover: browser("Hover element", "Hovered element", field("ref")), + browser_drag: browser("Drag element", "Dragged element", field("sourceRef")), + browser_fill: browser("Fill field", "Filled field", field("ref")), + browser_select: browser("Select option", "Selected option", field("value", "ref")), + browser_type: browser("Type text", "Typed text", field("text")), + browser_keypress: browser("Press key", "Pressed key", field("key")), + browser_upload: browser("Upload files", "Uploaded files", field("filePaths")), + browser_wait: browser("Wait for page", "Waited for page", field("text", "url")), + browser_scroll: browser("Scroll page", "Scrolled page"), + browser_resize: browser("Resize browser", "Resized browser", viewportContext()), + browser_logs: browser("Read browser logs", "Read browser logs"), + browser_evaluate: browser("Run JavaScript", "Ran JavaScript", field("function")), } satisfies Record; export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG; diff --git a/packages/protocol/src/paseo-tool-presentation/index.ts b/packages/protocol/src/paseo-tool-presentation/index.ts index c04606a231..e5395a9e62 100644 --- a/packages/protocol/src/paseo-tool-presentation/index.ts +++ b/packages/protocol/src/paseo-tool-presentation/index.ts @@ -28,6 +28,8 @@ export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly Pase /** Returns null for tools outside the Paseo namespace. */ export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null { + // Canonical details own internal lifecycle rows such as worktree setup. + if (input.detail.type !== "unknown") return null; const leafName = paseoLeafName(input.toolName); if (!leafName) return null; const entry = getPaseoToolEntry(leafName); @@ -39,6 +41,13 @@ export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDi return { displayName: entry.label, ...(summary ? { summary } : {}) }; } +/** A semantic action label for summaries, sharing the tool presentation catalog. */ +export function getPaseoToolActionLabel(toolName: string): string | null { + const leafName = paseoLeafName(toolName); + if (!leafName) return null; + return getPaseoToolEntry(leafName)?.completedLabel ?? humanizeLeafName(leafName); +} + /** The agent a Paseo call acts on, so callers can resolve its title. */ export function getPaseoToolTargetAgentId( toolName: string, @@ -49,7 +58,11 @@ export function getPaseoToolTargetAgentId( } function paseoLeafName(toolName: string): string | null { - return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null; + if (isPaseoToolName(toolName)) return getPaseoToolLeafName(toolName); + const normalized = toolName.trim().toLowerCase(); + return normalized.startsWith("paseo_") && !normalized.startsWith("paseo__") + ? normalized.slice("paseo_".length) + : null; } function readToolInput(detail: ToolCallDetail): Readonly> { diff --git a/packages/protocol/src/speech/rpc-schemas.ts b/packages/protocol/src/speech/rpc-schemas.ts new file mode 100644 index 0000000000..437560c7e8 --- /dev/null +++ b/packages/protocol/src/speech/rpc-schemas.ts @@ -0,0 +1,202 @@ +import { z } from "zod"; + +// Per-site settings mirror config.json. Unknown providers remain parseable by older +// clients; the feature UI decides whether it knows how to edit them. +const ModelFields = { model: z.string().optional() }; +const OpenAiReadFields = { + ...ModelFields, + hasApiKey: z.boolean(), + baseUrl: z.string().optional(), +}; +const SttReadFields = { + language: z.string().optional(), + confidenceThreshold: z.number().optional(), +}; +const LocalTtsFields = { speakerId: z.number().int().optional(), speed: z.number().optional() }; + +export const SpeechSttSettingsSchema = z.union([ + z + .object({ provider: z.literal("local"), ...ModelFields, language: z.string().optional() }) + .passthrough(), + z.object({ provider: z.literal("openai"), ...OpenAiReadFields, ...SttReadFields }).passthrough(), + z.object({ provider: z.string(), ...ModelFields }).passthrough(), +]); +export const SpeechTtsSettingsSchema = z.union([ + z.object({ provider: z.literal("local"), ...ModelFields, ...LocalTtsFields }).passthrough(), + z + .object({ provider: z.literal("openai"), ...OpenAiReadFields, voice: z.string().optional() }) + .passthrough(), + z.object({ provider: z.string(), ...ModelFields }).passthrough(), +]); + +export const SpeechSettingsSchema = z + .object({ + dictation: z.object({ enabled: z.boolean(), stt: SpeechSttSettingsSchema }).passthrough(), + voiceMode: z + .object({ + enabled: z.boolean(), + stt: SpeechSttSettingsSchema, + tts: SpeechTtsSettingsSchema, + llm: z + .object({ provider: z.string().optional(), model: z.string().optional() }) + .passthrough() + .optional(), + turnDetection: z.object({ provider: z.string().optional() }).passthrough().optional(), + }) + .passthrough(), + }) + .passthrough(); + +const ModelPatchFields = { model: z.string().nullable().optional() }; +const OpenAiPatchFields = { + ...ModelPatchFields, + apiKey: z.string().nullable().optional(), + baseUrl: z.string().nullable().optional(), +}; +const SttPatchFields = { + language: z.string().nullable().optional(), + confidenceThreshold: z.number().nullable().optional(), +}; +const LocalTtsPatchFields = { + speakerId: z.number().int().nullable().optional(), + speed: z.number().nullable().optional(), +}; +const SpeechSttSettingsPatchSchema = z.union([ + z + .object({ + provider: z.literal("local"), + ...ModelPatchFields, + language: z.string().nullable().optional(), + }) + .passthrough(), + z + .object({ provider: z.literal("openai"), ...OpenAiPatchFields, ...SttPatchFields }) + .passthrough(), + z.object({ provider: z.string().nullable() }).passthrough(), + z + .object({ provider: z.undefined().optional(), ...OpenAiPatchFields, ...SttPatchFields }) + .passthrough(), +]); +const SpeechTtsSettingsPatchSchema = z.union([ + z + .object({ provider: z.literal("local"), ...ModelPatchFields, ...LocalTtsPatchFields }) + .passthrough(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiPatchFields, + voice: z.string().nullable().optional(), + }) + .passthrough(), + z.object({ provider: z.string().nullable() }).passthrough(), + z + .object({ + provider: z.undefined().optional(), + ...OpenAiPatchFields, + ...LocalTtsPatchFields, + voice: z.string().nullable().optional(), + }) + .passthrough(), +]); +export const SpeechSettingsPatchSchema = z + .object({ + dictation: z + .object({ + enabled: z.boolean().nullable().optional(), + stt: SpeechSttSettingsPatchSchema.optional(), + }) + .passthrough() + .optional(), + voiceMode: z + .object({ + enabled: z.boolean().nullable().optional(), + stt: SpeechSttSettingsPatchSchema.optional(), + tts: SpeechTtsSettingsPatchSchema.optional(), + llm: z + .object({ + provider: z.string().nullable().optional(), + model: z.string().nullable().optional(), + }) + .passthrough() + .optional(), + turnDetection: z + .object({ provider: z.string().nullable().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +export type SpeechSettings = z.infer; +export type SpeechSettingsPatch = z.infer; + +// --------------------------------------------------------------------------- +// Local speech models: catalog, install state, download, delete. +// --------------------------------------------------------------------------- + +export const LocalSpeechModelSchema = z + .object({ + id: z.string(), + // "stt" or "tts". + kind: z.string(), + label: z.string(), + description: z.string(), + languages: z.array(z.string()), + downloadSizeBytes: z.number(), + isDefault: z.boolean(), + // Selected by an enabled feature; the daemon keeps it installed. + inUse: z.boolean(), + // "installed" | "not_installed" | "downloading" | "extracting" | "failed". + status: z.string(), + receivedBytes: z.number().optional(), + totalBytes: z.number().optional(), + error: z.string().optional(), + }) + .passthrough(); + +export type LocalSpeechModel = z.infer; + +export const SpeechLocalModelsListRequestSchema = z.object({ + type: z.literal("speech.local_models.list.request"), + requestId: z.string(), +}); + +export const SpeechLocalModelsDownloadRequestSchema = z.object({ + type: z.literal("speech.local_models.download.request"), + requestId: z.string(), + modelId: z.string(), +}); + +export const SpeechLocalModelsDeleteRequestSchema = z.object({ + type: z.literal("speech.local_models.delete.request"), + requestId: z.string(), + modelId: z.string(), +}); + +export const SpeechLocalModelsListResponseSchema = z.object({ + type: z.literal("speech.local_models.list.response"), + payload: z.object({ + requestId: z.string(), + models: z.array(LocalSpeechModelSchema), + error: z.string().nullable(), + }), +}); + +// Resolves once the download starts; poll the list for progress. +export const SpeechLocalModelsDownloadResponseSchema = z.object({ + type: z.literal("speech.local_models.download.response"), + payload: z.object({ + requestId: z.string(), + error: z.string().nullable(), + }), +}); + +export const SpeechLocalModelsDeleteResponseSchema = z.object({ + type: z.literal("speech.local_models.delete.response"), + payload: z.object({ + requestId: z.string(), + error: z.string().nullable(), + }), +}); diff --git a/packages/protocol/src/terminal-snapshot.test.ts b/packages/protocol/src/terminal-snapshot.test.ts deleted file mode 100644 index 96d82a9205..0000000000 --- a/packages/protocol/src/terminal-snapshot.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; -import type { TerminalState } from "./messages"; - -function cells(text: string): TerminalState["grid"][number] { - return [...text].map((char) => ({ char })); -} - -describe("renderTerminalSnapshotToAnsi", () => { - it("renders soft-wrapped rows as one contiguous logical line when wrap flags are present", () => { - // The server soft-wrapped one logical line "ABCDEFGHIJKLMNOP" at 10 cols into - // two grid rows. gridWrapped[0] = true marks row 0 as continuing into row 1. - const state: TerminalState = { - rows: 2, - cols: 10, - scrollback: [], - scrollbackWrapped: [], - grid: [cells("ABCDEFGHIJ"), cells("KLMNOP")], - gridWrapped: [true, false], - cursor: { row: 1, col: 6 }, - }; - - const ansi = renderTerminalSnapshotToAnsi(state); - - // The rows must arrive unbroken so xterm re-wraps them itself (and can later - // reflow them) — no hard newline injected between "...IJ" and "KL...". - expect(ansi).toContain("ABCDEFGHIJKLMNOP"); - // Auto-wrap must stay enabled; disabling it (ESC[?7l) is what makes xterm mark - // the rows non-wrapped and refuse to reflow them on resize. - expect(ansi).not.toContain("[?7l"); - }); - - it("falls back to verbatim per-row replay when wrap flags are absent (old daemon)", () => { - // No gridWrapped/scrollbackWrapped: the client cannot tell soft-wraps from hard - // newlines, so it must keep today's exact behaviour rather than guess. - const state: TerminalState = { - rows: 2, - cols: 10, - scrollback: [], - grid: [cells("ABCDEFGHIJ"), cells("KLMNOP")], - cursor: { row: 1, col: 6 }, - }; - - const ansi = renderTerminalSnapshotToAnsi(state); - - expect(ansi).toContain("[?7l"); - expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP"); - }); -}); - -describe("renderTerminalSnapshotToAnsi wide chars", () => { - it("skips wide-char continuation cells so the glyph keeps its two columns", () => { - // The daemon captures "통" as the glyph followed by an empty continuation - // cell. Emitting anything for that cell pushes the rest of the row right. - const state: TerminalState = { - rows: 1, - cols: 10, - scrollback: [], - grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]], - cursor: { row: 0, col: 5 }, - }; - - expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!"); - }); - - it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => { - // Old daemons already collapsed the continuation to " ". The serializer - // cannot tell that apart from a real space, so it must not guess. - const state: TerminalState = { - rows: 1, - cols: 10, - scrollback: [], - grid: [cells("통 합 !")], - cursor: { row: 0, col: 6 }, - }; - - expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !"); - }); -}); diff --git a/packages/protocol/src/terminal-snapshot.ts b/packages/protocol/src/terminal-snapshot.ts deleted file mode 100644 index 1860067573..0000000000 --- a/packages/protocol/src/terminal-snapshot.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { TerminalCell, TerminalState } from "./messages.js"; - -interface TerminalStyle { - fg: number | undefined; - bg: number | undefined; - fgMode: number | undefined; - bgMode: number | undefined; - bold: boolean; - italic: boolean; - underline: boolean; - dim: boolean; - inverse: boolean; - strikethrough: boolean; -} - -const DEFAULT_STYLE: TerminalStyle = { - fg: undefined, - bg: undefined, - fgMode: undefined, - bgMode: undefined, - bold: false, - italic: false, - underline: false, - dim: false, - inverse: false, - strikethrough: false, -}; - -export function renderTerminalSnapshotToAnsi(state: TerminalState): string { - const rows = [...state.scrollback, ...state.grid]; - const wrapFlags = [...(state.scrollbackWrapped ?? []), ...(state.gridWrapped ?? [])]; - // Soft-wrapped lines can only be re-wrapped on resize when we know which rows - // were continuations. With that per-row flag we replay each logical line as one - // unbroken run (autowrap on) so xterm marks the continuations wrapped and reflows - // them. Without it (old daemon) we keep the verbatim per-row replay: autowrap off - // plus a hard newline per row. - const hasWrapInfo = wrapFlags.length === rows.length; - const lines: string[] = hasWrapInfo ? [] : ["\u001b[?7l"]; - - for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { - const row = rows[rowIndex] ?? []; - const continuesToNextRow = hasWrapInfo && wrapFlags[rowIndex] === true; - // A continuation row must fill the full width so the next row first cell - // triggers xterm auto-wrap, which is what marks the row wrapped/reflowable. - lines.push(renderTerminalRow(row, continuesToNextRow ? state.cols : undefined)); - if (rowIndex < rows.length - 1 && !continuesToNextRow) { - lines.push("\r\n"); - } - } - - lines.push("\u001b[0m"); - const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor); - if (cursorPresentationAnsi) { - lines.push(cursorPresentationAnsi); - } - lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`); - lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h"); - if (!hasWrapInfo) { - lines.push("\u001b[?7h"); - } - return lines.join(""); -} - -function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null { - if (!cursor.style) { - return null; - } - - const cursorStyleCode = (() => { - if (cursor.style === "block") { - return cursor.blink === false ? 2 : 1; - } - if (cursor.style === "underline") { - return cursor.blink === false ? 4 : 3; - } - return cursor.blink === false ? 6 : 5; - })(); - - return `\u001b[${cursorStyleCode} q`; -} - -function renderTerminalRow(row: TerminalCell[], padToCols?: number): string { - const output: string[] = []; - const contentLength = getTerminalRowLength(row); - const length = padToCols !== undefined ? Math.max(contentLength, padToCols) : contentLength; - let previousStyle = DEFAULT_STYLE; - - for (let index = 0; index < length; index += 1) { - const cell = row[index] ?? { char: " " }; - // An empty char marks a wide-char continuation cell. The glyph in the - // previous cell already advances the cursor two columns, so emitting - // anything here would shift the rest of the row one column right. - if (cell.char === "") { - continue; - } - const nextStyle = getTerminalStyle(cell); - if (!terminalStylesEqual(previousStyle, nextStyle)) { - output.push(styleToAnsi(nextStyle)); - previousStyle = nextStyle; - } - output.push(cell.char || " "); - } - - if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) { - output.push("\u001b[0m"); - } - - return output.join(""); -} - -function getTerminalRowLength(row: TerminalCell[]): number { - for (let index = row.length - 1; index >= 0; index -= 1) { - const cell = row[index]; - if (!cell) { - continue; - } - if (cell.char !== " ") { - return index + 1; - } - if ( - cell.fg !== undefined || - cell.bg !== undefined || - cell.fgMode !== undefined || - cell.bgMode !== undefined || - cell.bold || - cell.italic || - cell.underline || - cell.dim || - cell.inverse || - cell.strikethrough - ) { - return index + 1; - } - } - return 0; -} - -function getTerminalStyle(cell: TerminalCell): TerminalStyle { - return { - fg: cell.fg, - bg: cell.bg, - fgMode: cell.fgMode, - bgMode: cell.bgMode, - bold: Boolean(cell.bold), - italic: Boolean(cell.italic), - underline: Boolean(cell.underline), - dim: Boolean(cell.dim), - inverse: Boolean(cell.inverse), - strikethrough: Boolean(cell.strikethrough), - }; -} - -function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean { - return ( - left.fg === right.fg && - left.bg === right.bg && - left.fgMode === right.fgMode && - left.bgMode === right.bgMode && - left.bold === right.bold && - left.italic === right.italic && - left.underline === right.underline && - left.dim === right.dim && - left.inverse === right.inverse && - left.strikethrough === right.strikethrough - ); -} - -function styleToAnsi(style: TerminalStyle): string { - const codes = ["0"]; - - if (style.bold) { - codes.push("1"); - } - if (style.dim) { - codes.push("2"); - } - if (style.italic) { - codes.push("3"); - } - if (style.underline) { - codes.push("4"); - } - if (style.inverse) { - codes.push("7"); - } - if (style.strikethrough) { - codes.push("9"); - } - - if (style.fg !== undefined && style.fgMode !== undefined) { - codes.push(...colorToSgr(style.fgMode, style.fg, false)); - } - - if (style.bg !== undefined && style.bgMode !== undefined) { - codes.push(...colorToSgr(style.bgMode, style.bg, true)); - } - - return `\u001b[${codes.join(";")}m`; -} - -function colorToSgr(mode: number, value: number, background: boolean): string[] { - if (mode === 1) { - if (value >= 8) { - return [String((background ? 100 : 90) + (value - 8))]; - } - return [String((background ? 40 : 30) + value)]; - } - - if (mode === 2) { - return [background ? "48" : "38", "5", String(value)]; - } - - if (mode === 3) { - return [ - background ? "48" : "38", - "2", - String((value >> 16) & 0xff), - String((value >> 8) & 0xff), - String(value & 0xff), - ]; - } - - return []; -} diff --git a/packages/protocol/src/timeline-turn.ts b/packages/protocol/src/timeline-turn.ts new file mode 100644 index 0000000000..f509787317 --- /dev/null +++ b/packages/protocol/src/timeline-turn.ts @@ -0,0 +1,24 @@ +/** Canonical run identity wins over trigger-shaped rows steered into that run. */ +export function continuesTimelineTurn( + previousTurnId: string | undefined, + nextTurnId: string | undefined, + nextIsTrigger: boolean, +): boolean { + if (previousTurnId !== undefined && nextTurnId !== undefined) { + return previousTurnId === nextTurnId; + } + // Missing IDs cannot recover historical batching or steering. Each visible + // trigger deterministically starts a turn in both paging and presentation. + return !nextIsTrigger; +} + +export function isTimelineTurnTrigger(item: { + type: string; + name?: string; + agentMessage?: unknown; +}): boolean { + return ( + item.type === "user_message" || + (item.type === "tool_call" && (item.agentMessage !== undefined || item.name === "heartbeat")) + ); +} diff --git a/packages/relay/package.json b/packages/relay/package.json index 4f0aec8a03..86bde64a75 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.13.0", + "version": "0.15.0-beta.1", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index dbba1c4aa8..191aee2945 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.13.0", + "version": "0.15.0-beta.1", "description": "Paseo backend server", "files": [ "dist/server", @@ -73,7 +73,7 @@ "clean": "node ../../scripts/clean-package-dist.mjs", "build": "npm run build:lib && npm run build:scripts", "build:clean": "npm run clean && npm run build", - "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).some((segment)=>segment==='node_modules'||segment==='test')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"", + "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node scripts/build-terminal-worker.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).some((segment)=>segment==='node_modules'||segment==='test')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui", "start": "node dist/scripts/supervisor-entrypoint.js", @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", @@ -107,16 +107,18 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.13.0", - "@getpaseo/highlight": "0.13.0", - "@getpaseo/plugin": "0.13.0", - "@getpaseo/protocol": "0.13.0", - "@getpaseo/relay": "0.13.0", + "@getpaseo/client": "0.15.0-beta.1", + "@getpaseo/highlight": "0.15.0-beta.1", + "@getpaseo/plugin": "0.15.0-beta.1", + "@getpaseo/protocol": "0.15.0-beta.1", + "@getpaseo/relay": "0.15.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", "@opencode/client": "2.0.10", - "@xterm/headless": "^6.0.0", + "@xterm/addon-serialize": "0.14.0", + "@xterm/addon-unicode-graphemes": "0.5.0-beta.330", + "@xterm/headless": "6.1.0-beta.334", "ai": "5.0.78", "ajv": "^8.20.0", "bcryptjs": "^3.0.3", @@ -124,6 +126,7 @@ "esbuild": "^0.25.12", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", + "https-proxy-agent": "^7.0.6", "lru-cache": "^11.5.1", "markdown-it": "^10.0.0", "mnemonic-id": "^3.2.7", @@ -141,6 +144,7 @@ "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "typescript": "^5.9.3", + "undici": "^7.24.8", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", diff --git a/packages/server/scripts/build-opencode-bridge-plugin.mjs b/packages/server/scripts/build-opencode-bridge-plugin.mjs index 69f1e774f0..33cd3402ce 100644 --- a/packages/server/scripts/build-opencode-bridge-plugin.mjs +++ b/packages/server/scripts/build-opencode-bridge-plugin.mjs @@ -1,4 +1,4 @@ -import { mkdir } from "node:fs/promises"; +import { copyFile, mkdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -27,4 +27,10 @@ for (const directory of ["", "v2/"]) { target: "es2022", outfile: outputPath, }); + if (directory === "v2/") { + await copyFile( + new URL("../src/server/agent/providers/opencode/v2/state.mjs", import.meta.url), + path.join(path.dirname(outputPath), "state.mjs"), + ); + } } diff --git a/packages/server/scripts/build-terminal-worker.mjs b/packages/server/scripts/build-terminal-worker.mjs new file mode 100644 index 0000000000..3a5b290fc3 --- /dev/null +++ b/packages/server/scripts/build-terminal-worker.mjs @@ -0,0 +1,55 @@ +// Bundles the terminal worker into one file with its shell integration beside it. The daemon +// copies this directory (plus node-pty) into $PASEO_HOME/terminal-worker/ and launches the worker +// from the copy, so a worker that outlives an upgrade never loads files from the installation +// that replaced the one it came from. See docs/terminal-worker.md. +// +// Usage: node scripts/build-terminal-worker.mjs [--outdir ] +// Default output: dist/server/terminal-worker/ + +import { cp, mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { build } from "esbuild"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); + +export const TERMINAL_WORKER_BUNDLE_NAME = "terminal-worker.mjs"; + +export async function buildTerminalWorkerBundle({ outDir }) { + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + await build({ + entryPoints: [path.join(packageRoot, "src/terminal/terminal-worker-process.ts")], + bundle: true, + format: "esm", + platform: "node", + target: "node20", + // Loaded from the copy's own node_modules/ at run time. + external: ["node-pty"], + // Bundled CommonJS dependencies call require() for Node built-ins. + banner: { + js: 'import { createRequire as __terminalWorkerCreateRequire } from "node:module"; const require = __terminalWorkerCreateRequire(import.meta.url);', + }, + outfile: path.join(outDir, TERMINAL_WORKER_BUNDLE_NAME), + logLevel: "warning", + }); + // terminal.ts resolves the zsh integration relative to its own module, which is the bundle. + await cp( + path.join(packageRoot, "src/terminal/shell-integration"), + path.join(outDir, "shell-integration"), + { recursive: true }, + ); +} + +function readOutDir(argv) { + const index = argv.indexOf("--outdir"); + if (index === -1) return path.join(packageRoot, "dist/server/terminal-worker"); + const value = argv[index + 1]; + if (!value) throw new Error("--outdir requires a directory"); + return path.resolve(value); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await buildTerminalWorkerBundle({ outDir: readOutDir(process.argv.slice(2)) }); +} diff --git a/packages/server/scripts/reproduce-codex-history-string-limit.mts b/packages/server/scripts/reproduce-codex-history-string-limit.mts new file mode 100644 index 0000000000..b0f49e4faf --- /dev/null +++ b/packages/server/scripts/reproduce-codex-history-string-limit.mts @@ -0,0 +1,108 @@ +// Opt-in diagnostic for #3905. Run from the repository root: +// node --import tsx packages/server/scripts/reproduce-codex-history-string-limit.mts 520 [legacy|paginated] +// Requires Codex 0.148+ on PATH and approximately 2 GiB of available memory. +// `paginated` migrates the synthetic rollout with `codex migrate-rollouts` first. +import { execFileSync, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, open, rm } from "node:fs/promises"; +import path from "node:path"; +import pino from "pino"; +import { CodexAppServerAgentSession } from "../src/server/agent/providers/codex-app-server-agent.js"; + +const megabytes = Number(process.argv[2] ?? "520"); +if (!Number.isSafeInteger(megabytes) || megabytes < 1 || megabytes > 600) { + throw new Error("Expected an integer size between 1 and 600 MiB"); +} +const historyMode = process.argv[3] ?? "legacy"; +if (historyMode !== "legacy" && historyMode !== "paginated") { + throw new Error("Expected history mode legacy or paginated"); +} +const root = await mkdtemp(path.join(process.cwd(), ".codex-history-repro-")); +const home = path.join(root, "codex-home"); +const now = new Date(); +const timestamp = now.toISOString(); +const threadId = randomUUID(); +const turnId = randomUUID(); +const sessions = path.join(home, "sessions", ...timestamp.slice(0, 10).split("-")); +await mkdir(sessions, { recursive: true }); +const rollout = await open( + path.join(sessions, `rollout-${timestamp.slice(0, 19).replaceAll(":", "-")}-${threadId}.jsonl`), + "w", +); +async function write(type: string, payload: unknown) { + await rollout.write(`${JSON.stringify({ timestamp, type, payload })}\n`); +} +await write("session_meta", { + id: threadId, + timestamp, + cwd: root, + originator: "codex_cli_rs", + cli_version: "0.159.0", + source: "cli", + model_provider: "openai", + history_mode: "legacy", +}); +await write("event_msg", { type: "task_started", turn_id: turnId, model_context_window: 200000 }); +await write("event_msg", { + type: "user_message", + message: "Synthetic oversized history", + images: [], + local_images: [], + text_elements: [], +}); +for (let index = 0; index < megabytes; index++) { + await write("event_msg", { + type: "agent_message", + message: "x".repeat(1024 * 1024), + phase: "commentary", + }); +} +await write("event_msg", { type: "task_complete", turn_id: turnId, last_agent_message: "done" }); +await rollout.close(); +if (historyMode === "paginated") { + execFileSync("codex", ["migrate-rollouts", "--apply", "--thread", threadId], { + env: { ...process.env, CODEX_HOME: home }, + stdio: "ignore", + }); +} +console.log( + `Opening synthetic ${megabytes} MiB ${historyMode} history through the real Codex app-server`, +); +const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: root }, + { sessionId: threadId }, + pino({ level: "silent" }), + async () => + spawn( + "codex", + ["app-server", "--disable", "plugins", "--disable", "apps", "--disable", "remote_plugin"], + { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env, CODEX_HOME: home }, + }, + ), + { codexHome: home }, + false, + false, + false, + undefined, + "history", +); +try { + await session.connect(); + let count = 0; + for await (const event of session.streamHistory()) { + if (event.type !== "timeline") continue; + count++; + if (event.item.type === "notification") console.log(event.item.message); + } + console.log(`History loaded: ${count} timeline rows`); +} catch (error) { + // A recoverable session error is acceptable for the crash-isolation assertion. + console.log( + `History rejected without crashing: ${error instanceof Error ? error.message : String(error)}`, + ); +} finally { + await session.close(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); +} diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index ee3746accf..7d0e7f50aa 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -12,6 +12,7 @@ import { resolvePaseoHome } from "../src/server/paseo-home.js"; import { daemonLogPath } from "../src/server/daemon-instance.js"; import { PRIVATE_FILE_MODE } from "../src/server/private-files.js"; import { loadPersistedConfig } from "../src/server/persisted-config.js"; +import { reapTerminalWorkers } from "../src/terminal/terminal-worker-reaper.js"; import { runSupervisor } from "./supervisor.js"; import { resolveSupervisorLogFile } from "./supervisor-log-config.js"; @@ -175,6 +176,10 @@ async function main(): Promise { onWorkerExit: () => updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }), onSupervisorExit: releaseLock, + cleanupTerminals: async (log) => { + const result = await reapTerminalWorkers({ paseoHome, log }); + log("Terminal worker cleanup result", { ...result }); + }, }); requestSupervisorShutdown = supervisor.requestShutdown; } diff --git a/packages/server/scripts/supervisor.terminal-cleanup.test.ts b/packages/server/scripts/supervisor.terminal-cleanup.test.ts new file mode 100644 index 0000000000..224a0788d3 --- /dev/null +++ b/packages/server/scripts/supervisor.terminal-cleanup.test.ts @@ -0,0 +1,180 @@ +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 { describe, expect, test } from "vitest"; + +// A real supervisor with scripted workers. Each worker generation logs what it sees to an +// events file; `cleanupTerminals` logs "cleanup". The order of events is the contract: a kill +// intent cleans up before the next worker starts, a keep intent or a crash never cleans up. + +async function runSupervisorWith(workerScript: string, timeoutMs = 8_000) { + const home = await mkdtemp(path.join(tmpdir(), "paseo-terminal-cleanup-")); + 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, + ` + import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; + const counter = ${JSON.stringify(path.join(home, "generation"))}; + const generation = existsSync(counter) ? Number(readFileSync(counter, "utf8")) + 1 : 1; + writeFileSync(counter, String(generation)); + const record = (event) => + appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify(event) + "\\n"); + record({ started: generation }); + ${workerScript} + `, + ); + await writeFile( + runnerPath, + ` + import { appendFileSync } from "node:fs"; + import { runSupervisor } from ${JSON.stringify(supervisor)}; + runSupervisor({ + name: "TerminalCleanupTest", startupMessage: "starting", restartOnCrash: true, + resolveWorkerEntry: () => ${JSON.stringify(workerPath)}, workerArgs: [], workerExecArgv: [], + cleanupTerminals: async () => { + appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify("cleanup") + "\\n"); + }, + }); + `, + ); + 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)); + }, timeoutMs); + child.once("error", reject); + child.once("close", (exitCode) => { + clearTimeout(timer); + resolve(exitCode); + }); + }); + const events = (await readFile(eventsPath, "utf8").catch(() => "")) + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as unknown); + return { code, output, events }; + } finally { + await rm(home, { recursive: true, force: true }); + } +} + +// Generation 1 asks for `firstIntent`; generation 2 (if any) stops with `secondIntent`. +function scriptedWorker(firstIntent: string, secondIntent = "") { + return ` + process.on("message", (message) => { + if (message.type !== "paseo:graceful-shutdown") return; + record({ graceful: generation, keepTerminals: message.keepTerminals }); + process.exit(0); + }); + process.send({ type: "paseo:ready", listen: "endpoint-" + generation, serverId: "srv" }); + if (generation === 1) { ${firstIntent} } + else { ${secondIntent} } + `; +} + +const stopKeeping = `process.send({ type: "paseo:shutdown", reason: "test", keepTerminals: true });`; + +describe("supervisor terminal cleanup", () => { + test("a keep restart passes keepTerminals and never cleans up", async () => { + const result = await runSupervisorWith( + scriptedWorker( + `process.send({ type: "paseo:restart", reason: "test", keepTerminals: true });`, + stopKeeping, + ), + ); + expect(result.code, result.output).toBe(0); + expect(result.events).toEqual([ + { started: 1 }, + { graceful: 1, keepTerminals: true }, + { started: 2 }, + { graceful: 2, keepTerminals: true }, + ]); + }); + + test("a kill restart cleans up after the worker exits and before the respawn", async () => { + const result = await runSupervisorWith( + scriptedWorker( + `process.send({ type: "paseo:restart", reason: "test", keepTerminals: false });`, + stopKeeping, + ), + ); + expect(result.code, result.output).toBe(0); + expect(result.events).toEqual([ + { started: 1 }, + { graceful: 1, keepTerminals: false }, + "cleanup", + { started: 2 }, + { graceful: 2, keepTerminals: true }, + ]); + }); + + test("a kill restart whose worker hangs cleans up after the forced kill, before the respawn", async () => { + const result = await runSupervisorWith( + ` + process.on("message", (message) => { + if (message.type !== "paseo:graceful-shutdown") return; + record({ graceful: generation, keepTerminals: message.keepTerminals }); + if (generation === 2) process.exit(0); + }); + process.send({ type: "paseo:ready", listen: "endpoint-" + generation, serverId: "srv" }); + if (generation === 1) { + process.send({ type: "paseo:restart", reason: "test", keepTerminals: false }); + setInterval(() => {}, 1000); + } else { + ${stopKeeping} + } + `, + 20_000, + ); + expect(result.code, result.output).toBe(0); + expect(result.events).toEqual([ + { started: 1 }, + { graceful: 1, keepTerminals: false }, + "cleanup", + { started: 2 }, + { graceful: 2, keepTerminals: true }, + ]); + }, 30_000); + + test("a crash restart keeps terminals", async () => { + const result = await runSupervisorWith(scriptedWorker(`process.exit(1);`, stopKeeping)); + expect(result.code, result.output).toBe(0); + expect(result.events).toEqual([ + { started: 1 }, + { started: 2 }, + { graceful: 2, keepTerminals: true }, + ]); + }); + + test("a kill stop cleans up when the supervisor exits", async () => { + const result = await runSupervisorWith( + scriptedWorker( + `process.send({ type: "paseo:shutdown", reason: "test", keepTerminals: false });`, + ), + ); + expect(result.code, result.output).toBe(0); + expect(result.events).toEqual([ + { started: 1 }, + { graceful: 1, keepTerminals: false }, + "cleanup", + ]); + }); +}); diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index ec56822619..fac28946d5 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -21,6 +21,7 @@ type WorkerLifecycleMessage = | { type: "paseo:shutdown"; reason?: string; + keepTerminals: boolean; } | { type: "paseo:ready"; @@ -30,6 +31,7 @@ type WorkerLifecycleMessage = | { type: "paseo:restart"; reason?: string; + keepTerminals: boolean; }; interface SupervisorHeartbeatMessage { @@ -39,8 +41,11 @@ interface SupervisorHeartbeatMessage { interface SupervisorGracefulShutdownMessage { type: "paseo:graceful-shutdown"; reason: string; + keepTerminals: boolean; } +export type SupervisorLifecycleLog = (message: string, fields?: Record) => void; + interface SupervisorOptions { name: string; startupMessage: string; @@ -57,11 +62,15 @@ interface SupervisorOptions { onWorkerExit?: () => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; + // Closes every terminal the worker left. Runs after a kill-intent restart or stop once the + // worker has exited (also after a forced kill), before a respawn, and on any supervisor + // exit except a keep-terminals stop. A crash restart keeps terminals and skips it. + cleanupTerminals?: (log: SupervisorLifecycleLog) => Promise; logFile?: SupervisorLogFileOptions; } export interface SupervisorController { - requestShutdown(reason: string): void; + requestShutdown(reason: string, options?: { keepTerminals?: boolean }): void; } function describeExit(code: number | null, signal: NodeJS.Signals | null): string { @@ -74,10 +83,11 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { } const type = (msg as { type?: unknown }).type; if (type === "paseo:shutdown") { - const reason = (msg as { reason?: unknown }).reason; + const { reason, keepTerminals } = msg as { reason?: unknown; keepTerminals?: unknown }; return { type: "paseo:shutdown", ...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}), + keepTerminals: keepTerminals === true, }; } if (type === "paseo:ready") { @@ -91,10 +101,11 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { return { type: "paseo:ready", listen, serverId }; } if (type === "paseo:restart") { - const reason = (msg as { reason?: unknown }).reason; + const { reason, keepTerminals } = msg as { reason?: unknown; keepTerminals?: unknown }; return { type: "paseo:restart", ...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}), + keepTerminals: keepTerminals === true, }; } return null; @@ -185,6 +196,9 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController let child: ChildProcess | null = null; let restarting = false; let shuttingDown = false; + // The resolved terminal decision of the pending restart or stop. It survives the force-kill + // path, so a hung kill restart still closes terminals before the respawn. + let pendingKeepTerminals = false; let exiting = false; let lifecycleFailed = false; let publication = Promise.resolve(); @@ -217,12 +231,31 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController const closeLogStream = (): Promise => durableLog.close(); - const exitSupervisor = (code: number): void => { + const runTerminalCleanup = async (when: string): Promise => { + if (!options.cleanupTerminals) { + return; + } + writeLifecycleLog("Closing terminals", { when }); + try { + await options.cleanupTerminals(writeLifecycleLog); + writeLifecycleLog("Terminal cleanup finished", { when }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`Terminal cleanup not confirmed (${when}): ${message}`); + } + }; + + const exitSupervisor = (code: number, exitOptions: { keepTerminals?: boolean } = {}): void => { if (exiting) { return; } exiting = true; - Promise.resolve(options.onSupervisorExit?.()) + const cleanup = + exitOptions.keepTerminals === true + ? Promise.resolve() + : runTerminalCleanup("supervisor_exit"); + cleanup + .then(() => options.onSupervisorExit?.()) .catch((error) => { const message = error instanceof Error ? error.message : String(error); log(`Supervisor exit cleanup failed: ${message}`); @@ -357,14 +390,20 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController if (lifecycleMessage.type === "paseo:shutdown") { const reason = lifecycleMessage.reason ?? "worker_requested_shutdown"; - writeLifecycleLog("Worker requested shutdown", { reason }); - requestShutdown(reason); + writeLifecycleLog("Worker requested shutdown", { + reason, + keepTerminals: lifecycleMessage.keepTerminals, + }); + requestShutdown(reason, { keepTerminals: lifecycleMessage.keepTerminals }); return; } const reason = lifecycleMessage.reason ?? "worker_requested_restart"; - writeLifecycleLog("Worker requested restart", { reason }); - requestRestart(reason); + writeLifecycleLog("Worker requested restart", { + reason, + keepTerminals: lifecycleMessage.keepTerminals, + }); + requestRestart(reason, lifecycleMessage.keepTerminals); }); child.on("error", (error) => { @@ -395,7 +434,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController if (shuttingDown) { log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); - exitSupervisor(0); + exitSupervisor(0, { keepTerminals: pendingKeepTerminals }); return; } @@ -404,13 +443,25 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); if (restarting || crashed) { + const closeTerminals = restarting && !pendingKeepTerminals; restarting = false; + pendingKeepTerminals = false; log( - crashed + crashed && !closeTerminals ? `Worker crashed (${exitDescriptor}). Restarting worker...` : `Worker exited (${exitDescriptor}). Restarting worker...`, ); - spawnWorker(); + if (!closeTerminals) { + spawnWorker(); + return; + } + // The replacement daemon must never adopt terminals the user asked to close. + void runTerminalCleanup("before_respawn").then(() => { + if (!shuttingDown) { + spawnWorker(); + } + return; + }); return; } @@ -429,9 +480,11 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController const message: SupervisorGracefulShutdownMessage = { type: "paseo:graceful-shutdown", reason, + keepTerminals: pendingKeepTerminals, }; writeLifecycleLog("Supervisor requesting graceful worker shutdown", { reason, + keepTerminals: pendingKeepTerminals, supervisorPid: process.pid, workerPid: currentChild.pid ?? null, }); @@ -455,28 +508,33 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController }); }; - const requestRestart = (reason: string) => { + const requestRestart = (reason: string, keepTerminals: boolean) => { if (!child || restarting || shuttingDown) { return; } restarting = true; - writeLifecycleLog("Restart requested", { reason }); + pendingKeepTerminals = keepTerminals; + writeLifecycleLog("Restart requested", { reason, keepTerminals }); log(`${reason}. Stopping worker for restart...`); requestWorkerShutdown(reason); scheduleForceKill(reason); }; - const requestShutdown = (reason: string) => { + const requestShutdown = (reason: string, shutdownOptions: { keepTerminals?: boolean } = {}) => { if (shuttingDown) { return; } shuttingDown = true; restarting = false; - writeLifecycleLog("Supervisor shutdown requested", { reason }); + pendingKeepTerminals = shutdownOptions.keepTerminals === true; + writeLifecycleLog("Supervisor shutdown requested", { + reason, + keepTerminals: pendingKeepTerminals, + }); log(`${reason}. Stopping worker...`); if (!child) { void publication.then(() => { - exitSupervisor(lifecycleFailed ? 1 : 0); + exitSupervisor(lifecycleFailed ? 1 : 0, { keepTerminals: pendingKeepTerminals }); return; }); return; diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index 97bb8af3a3..064dcec64e 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -1,17 +1,10 @@ -import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import { delimiter, resolve } from "node:path"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { execCommand } from "../utils/spawn.js"; +import which from "which"; import { windowsExecutableResolution } from "./windows.js"; -type Which = ( - command: string, - options: { all: true; path?: string; pathExt?: string }, -) => Promise; - -const require = createRequire(import.meta.url); -const which = require("which") as Which; const PROBE_TIMEOUT_MS = 2000; function hasPathSeparator(value: string): boolean { diff --git a/packages/server/src/executable-resolution/which.d.ts b/packages/server/src/executable-resolution/which.d.ts new file mode 100644 index 0000000000..e7fd869f25 --- /dev/null +++ b/packages/server/src/executable-resolution/which.d.ts @@ -0,0 +1,10 @@ +// `which` ships no types. Imported statically (not through createRequire) so the terminal +// worker bundle includes it: the worker runs from a copy that has only node-pty beside it. +// Listed in the tsconfig includes, since nothing imports a declaration file. +declare module "which" { + function which( + command: string, + options: { all: true; path?: string; pathExt?: string }, + ): Promise; + export default which; +} diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index c98bbae54d..4e9ce49528 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -247,7 +247,7 @@ second line'`, ]); }); - it("keeps nested sub-agent logs beside the sub-agent that produced them", () => { + it("lists each sub-agent as its launch marker", () => { const timeline: AgentTimelineItem[] = [ { type: "assistant_message", text: "Before first child." }, toolCallItem({ @@ -257,7 +257,7 @@ second line'`, type: "sub_agent", subAgentType: "Child one", description: "First investigation", - log: "[Assistant] First child result.", + log: "", }, }), { type: "assistant_message", text: "Between children." }, @@ -268,7 +268,7 @@ second line'`, type: "sub_agent", subAgentType: "Child two", description: "Second investigation", - log: "[Assistant] Second child result.", + log: "", }, }), { type: "assistant_message", text: "After second child." }, @@ -279,10 +279,8 @@ second line'`, expect(result.split("\n")).toEqual([ "[Assistant] Before first child.", "[Child one] First investigation", - "[Assistant] First child result.", "[Assistant] Between children.", "[Child two] Second investigation", - "[Assistant] Second child result.", "[Assistant] After second child.", ]); }); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 488867c657..2a7cbff5e6 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -164,9 +164,6 @@ function curateProjectedActivityEntries( break; } entries.push(formatToolCallEntry(item, options)); - if (item.detail.type === "sub_agent" && item.detail.log.trim()) { - entries.push(activityEntry(item.detail.log.trim())); - } break; } case "todo": diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index 761f1008bd..ba444a394d 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -135,6 +135,7 @@ export async function ensureAgentLoaded( labels: record.labels, workspaceId: record.workspaceId, owner: record.owner, + env: record.launchEnv, }); deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); } 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 9dc194fa11..c423264869 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 @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager, type AgentManagerEvent } from "./agent-manager.js"; import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS } from "./agent-stream-coalescer.js"; +import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; import { projectTimelineRows } from "./timeline-projection.js"; import type { @@ -31,7 +32,6 @@ import type { const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS; const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0); -const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024; const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: false, @@ -400,10 +400,7 @@ describe("target coalesced behavior", () => { try { const { agentId, session } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); session.pushEvent(timelineEvent(toolCall({ status: "completed", output }))); await waitForSessionEventQueue(); @@ -425,10 +422,7 @@ describe("target coalesced behavior", () => { try { const { agentId } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output })); @@ -449,10 +443,7 @@ describe("target coalesced behavior", () => { try { const { agentId, session } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]); await harness.manager.hydrateTimelineFromProvider(agentId, { force: true }); @@ -470,10 +461,7 @@ describe("target coalesced behavior", () => { try { const { agentId } = await createManagedSession(harness); const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "completed", - output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH), - }); + const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output })); await harness.manager.emitLiveTimelineItem( agentId, @@ -495,10 +483,9 @@ describe("target coalesced behavior", () => { try { const { agentId, session } = await createManagedSession(harness); const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`; - const expectedItem = toolCall({ - status: "failed", - error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) }, - }); + const expectedItem = limitAgentTimelineItemContent( + toolCall({ status: "failed", error: { content } }), + ); session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } }))); await waitForSessionEventQueue(); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c01d477004..ccf29b17b0 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,3 +1,4 @@ +import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js"; import { prepareAgentMessage, formatAgentMessage, @@ -20,6 +21,7 @@ import { } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; +import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import { toAgentPayload } from "./agent-projections.js"; import { projectTimelineRows } from "./timeline-projection.js"; import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; @@ -879,6 +881,66 @@ test("retries provider history hydration after a stream failure", async () => { } }); +test("notification batches share the run they start or join", async () => { + const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const batch = (prefix: string) => + ["one", "two"] + .map((id) => + formatAgentMessage({ + id: `${prefix}-${id}`, + source: { kind: "agent-notification", agentId: "worker", event: "finished" }, + text: "Worker finished", + }), + ) + .join("\n\n"); + const consume = async (prompt: string, clientMessageId: string) => { + for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) { + } + }; + try { + const first = consume("human", "human"); + await manager.waitForAgentRunStart(agent.id); + await expect( + manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }), + ).resolves.toEqual({ status: "accepted" }); + session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" }); + await first; + const second = consume(batch("idle"), "idle-one"); + await manager.waitForAgentRunStart(agent.id); + session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" }); + await second; + expect( + manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({ + turnId: row.turnId, + id: row.item.type === "tool_call" ? row.item.callId : row.item.type, + })), + ).toEqual([ + { turnId: "active-turn-1", id: "user_message" }, + { turnId: "active-turn-1", id: "paseo-agent-message:joined-one" }, + { turnId: "active-turn-1", id: "paseo-agent-message:joined-two" }, + { turnId: "active-turn-2", id: "paseo-agent-message:idle-one" }, + { turnId: "active-turn-2", id: "paseo-agent-message:idle-two" }, + ]); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unavailable steer interrupts once and starts one replacement turn", async () => { const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() }); session.steerResult = "unavailable"; @@ -895,6 +957,131 @@ test("unavailable steer interrupts once and starts one replacement turn", async } }); +test("steers a send that arrives while the previous turn is still starting", async () => { + const startHeld = deferred(); + class SlowStartSteeringSession extends SteeringTestSession { + override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + await startHeld.promise; + return await super.startTurn(prompt); + } + } + const session = new SlowStartSteeringSession({ + provider: "codex", + cwd: process.cwd(), + }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const steerOptions = (clientMessageId: string) => ({ + replaceRunning: true, + activeTurnBehavior: "steer" as const, + runOptions: { clientMessageId }, + }); + await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client")); + const second = startAgentRun( + manager, + agent.id, + "SECOND", + logger, + steerOptions("second-client"), + ); + + startHeld.resolve(); + await expect(second).resolves.toEqual({ disposition: "steered" }); + + expect(session.startPrompts).toEqual(["FIRST"]); + expect(session.interruptCount).toBe(0); + expect( + manager + .fetchTimeline(agent.id, { limit: 0 }) + .rows.filter((row) => row.item.type === "user_message") + .map((row) => ({ + text: row.item.type === "user_message" ? row.item.text : null, + turnId: row.turnId, + })), + ).toEqual([ + { text: "FIRST", turnId: "active-turn-1" }, + { text: "SECOND", turnId: "active-turn-1" }, + ]); + } finally { + startHeld.resolve(); + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("starts its own turn when the previous turn fails to start under a steer send", async () => { + const startHeld = deferred(); + class FailingFirstStartSession extends SteeringTestSession { + override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + if (prompt === "FIRST") { + this.startPrompts.push(prompt); + await startHeld.promise; + throw new Error("first start failed"); + } + return await super.startTurn(prompt); + } + } + const session = new FailingFirstStartSession({ + provider: "codex", + cwd: process.cwd(), + }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const steerOptions = (clientMessageId: string) => ({ + replaceRunning: true, + activeTurnBehavior: "steer" as const, + runOptions: { clientMessageId }, + }); + await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client")); + const second = startAgentRun( + manager, + agent.id, + "SECOND", + logger, + steerOptions("second-client"), + ); + + startHeld.resolve(); + await expect(second).resolves.toEqual({ disposition: "turn_started" }); + await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"])); + expect(session.interruptCount).toBe(0); + } finally { + startHeld.resolve(); + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("orders an accepted steer before output emitted while acknowledgement is pending", async () => { const entered = deferred(); const release = deferred(); @@ -1033,6 +1220,74 @@ test("orders buffered pre-steer output before an immediately accepted steer", as } }); +test("a steer the provider names without echoing it can be rewound to", async () => { + // Claude takes a steered message into the running turn without echoing it back as a user + // message, so the id it minted for the message reaches the manager only through the admission. + class SilentSteeringSession extends SteeringTestSession { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsRewindFiles: true, + }; + rewindMessageIds: string[] = []; + + override async steerActiveTurn( + _prompt: AgentPromptInput, + options: import("./agent-sdk-types.js").SteerActiveTurnOptions, + ): Promise { + if (options.expectedTurnId !== `active-turn-${this.startCount}`) { + return { status: "unavailable" }; + } + return { status: "accepted", providerMessageId: `provider-${options.clientMessageId}` }; + } + + override async revertFiles({ messageId }: { messageId: string }): Promise { + this.rewindMessageIds.push(messageId); + } + } + const session = new SilentSteeringSession({ provider: "codex", cwd: process.cwd() }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-rewind-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const run = manager.streamAgent(agent.id, "initial"); + void (async () => { + for await (const _event of run) { + } + })(); + await manager.waitForAgentRunStart(agent.id); + + await expect( + manager.steerAgentRun(agent.id, "steered", { clientMessageId: "steer-client" }), + ).resolves.toMatchObject({ status: "accepted" }); + const row = manager + .fetchTimeline(agent.id, { limit: 0 }) + .rows.find((candidate) => candidate.item.type === "user_message"); + expect(row).toMatchObject({ + providerMessageId: "provider-steer-client", + item: { text: "steered", clientMessageId: "steer-client" }, + }); + + await manager.rewind(agent.id, "steer-client", "files"); + expect(session.rewindMessageIds).toEqual(["provider-steer-client"]); + } finally { + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("orders a concurrent replacement after a pending accepted steer", async () => { const entered = deferred(); const release = deferred(); @@ -2868,6 +3123,92 @@ test("createAgent passes daemon launch env through the provider launch context", }); }); +test.each([ + { provider: "codex", hasHandle: true }, + { provider: "opencode", hasHandle: true }, + { provider: "codex", hasHandle: false }, + { provider: "opencode", hasHandle: false }, +])( + "$provider preserves explicit launch env across reloads and storage-backed resumes (handle: $hasHandle)", + async ({ provider, hasHandle }) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-")); + const storagePath = join(workdir, "agents"); + class LaunchEnvClient extends TestAgentClient { + lastLaunchContext: AgentLaunchContext | undefined; + + override async createSession( + config: AgentSessionConfig, + context?: AgentLaunchContext, + ): Promise { + this.lastLaunchContext = context; + const session = await super.createSession(config); + session.describePersistence = () => ({ provider, sessionId: session.id }); + return session; + } + + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + context?: AgentLaunchContext, + ): Promise { + this.lastLaunchContext = context; + const session = await super.resumeSession(handle, config, context); + session.describePersistence = () => ({ provider, sessionId: handle.sessionId }); + return session; + } + } + + const client = new LaunchEnvClient(provider); + let storage = new AgentStorage(storagePath, logger); + let manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger }); + const env = { XDG_DATA_HOME: join(workdir, "agent-data"), CUSTOM_EMPTY: "" }; + const expectedEnv = { ...env }; + const agent = await manager.createAgent({ provider, cwd: workdir }, undefined, { + workspaceId: undefined, + env, + }); + onTestFinished(async () => { + await manager.closeAgent(agent.id); + await manager.flush(); + rmSync(workdir, { recursive: true, force: true }); + }); + env.XDG_DATA_HOME = "caller-mutated"; + + await manager.closeAgent(agent.id); + await manager.flush(); + + if (!hasHandle) { + const record = await storage.get(agent.id); + if (!record) throw new Error("Created agent was not persisted"); + await storage.upsert({ ...record, persistence: null }); + } + + // Fresh storage and manager instances must read the environment from disk. + for (let restart = 0; restart < 2; restart += 1) { + storage = new AgentStorage(storagePath, logger); + manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger }); + const resumed = await ensureAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + expect(client.lastLaunchContext).toEqual({ + agentId: agent.id, + env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir }, + }); + await manager.reloadAgentSession(agent.id); + expect(client.lastLaunchContext).toEqual({ + agentId: agent.id, + env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir }, + }); + expect(toAgentPayload(resumed)).not.toHaveProperty("launchEnv"); + expect(toAgentPayload(resumed).persistence?.metadata).not.toHaveProperty("launchEnv"); + await manager.closeAgent(agent.id); + await manager.flush(); + } + }, +); + test("createAgent passes persistSession to provider create options", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -4320,7 +4661,7 @@ test("importProviderSession imports the selected session without listing and pub expect(manager.getTimeline(imported.id)).toEqual([ { type: "user_message", text: "Trace provider imports" }, { type: "assistant_message", text: "Done" }, - { + limitAgentTimelineItemContent({ type: "tool_call", callId: "large-shell-result", name: "shell", @@ -4329,10 +4670,10 @@ test("importProviderSession imports the selected session without listing and pub detail: { type: "shell", command: "print output", - output: "x".repeat(64 * 1024), + output: "x".repeat(1024 * 1024), exitCode: 0, }, - }, + }), ]); expect(manager.listProviderSubagents(imported.id)).toEqual([ expect.objectContaining({ id: "thread-child", title: "Imported child", status: "completed" }), @@ -7746,7 +8087,7 @@ test("workspace ownership does not turn an ordinary agent into a disposable help expect(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]); expect(await storage.get(agentId)).not.toBeNull(); // Workspace ownership preserves ordinary agent lifecycle hooks. - expect(beforeHookCalls).toContain("agent.create"); + expect(beforeHookCalls).toContain("agent.create.before"); }); test("global subscribers receive internal agent events only when they opt in", async () => { @@ -7787,6 +8128,60 @@ test("global subscribers receive internal agent events only when they opt in", a unsubscribeOptedIn(); }); +test("agent removal notifications respect scoped subscriptions and internal visibility", () => { + const manager = new AgentManager({ clients: {}, logger }); + const removedId = "00000000-0000-4000-8000-000000000901"; + const internalId = "00000000-0000-4000-8000-000000000902"; + const otherId = "00000000-0000-4000-8000-000000000903"; + const publicEvents: AgentManagerEvent[] = []; + const allEvents: AgentManagerEvent[] = []; + const scopedEvents: AgentManagerEvent[] = []; + const internalEvents: AgentManagerEvent[] = []; + const unrelatedEvents: AgentManagerEvent[] = []; + const releases = [ + manager.subscribe((event) => publicEvents.push(event), { replayState: false }), + manager.subscribe((event) => allEvents.push(event), { + replayState: false, + includeInternal: true, + }), + manager.subscribe((event) => scopedEvents.push(event), { + replayState: false, + agentId: removedId, + }), + manager.subscribe((event) => internalEvents.push(event), { + replayState: false, + agentId: internalId, + }), + manager.subscribe((event) => unrelatedEvents.push(event), { + replayState: false, + agentId: otherId, + }), + ]; + try { + manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" }); + manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true }); + const publicRemoval = { + type: "agent_removed", + agentId: removedId, + workspaceId: "workspace", + internal: false, + }; + const internalRemoval = { + type: "agent_removed", + agentId: internalId, + workspaceId: "workspace", + internal: true, + }; + expect(publicEvents).toEqual([publicRemoval]); + expect(allEvents).toEqual([publicRemoval, internalRemoval]); + expect(scopedEvents).toEqual([publicRemoval]); + expect(internalEvents).toEqual([internalRemoval]); + expect(unrelatedEvents).toEqual([]); + } finally { + for (const release of releases) release(); + } +}); + test("getAgent returns internal agents by ID", async () => { const internalAgentId = "00000000-0000-4000-8000-000000000107"; const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); @@ -10007,6 +10402,47 @@ test("closeAgent persists one final closed snapshot", async () => { } }); +test("idle runtime cleanup honors a provider veto and resumes the saved conversation", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-cleanup-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let safe = false; + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + const session = await super.createSession(config); + session.canStopRuntime = async () => safe; + return session; + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const collect = runtimeCleanup(manager); + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const now = new Date(Date.now() + 61 * 60_000); + expect(await collect({ now })).toEqual({ collected: [], failures: [] }); + safe = true; + expect(await collect({ now })).toEqual({ collected: [], failures: [] }); + const later = new Date(now.getTime() + 61 * 60_000); + expect((await collect({ now: later })).collected).toEqual([ + { agentId: agent.id, provider: "codex", sessionId: agent.persistence?.sessionId }, + ]); + expect(manager.getAgent(agent.id)).toBeNull(); + const resumed = await ensureAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + expect(resumed.id).toBe(agent.id); + expect(resumed.persistence).toEqual(agent.persistence); + await manager.runAgent(agent.id, "Continue the saved conversation"); + } finally { + await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("idle agents remain resident until an explicit lifecycle action closes them", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-")); let closeCount = 0; @@ -10951,12 +11387,12 @@ test("tryRunOutOfBand gives providers the command another agent sent, not its en try { const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" }); - expect(manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true); + expect(await manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true); expect(seenPrompts).toEqual(["/handled"]); // A human who pastes an envelope gets it quoted, and it stays a plain prompt. const pasted = prepareAgentMessage(String(fromAgent.prompt)); - expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false); + expect(await manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false); expect(seenPrompts.at(-1)).toBe(pasted.prompt); } finally { await manager.closeAgent(agent.id); @@ -11299,7 +11735,7 @@ test("listImportableSessions searches every provider result before global rankin ]); }); -test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => { +test("system-injected prompts appear as trigger tools in the live timeline", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -11328,13 +11764,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the await manager.runAgent(snapshot.id, { text: "do something" }); const timeline = manager.getTimeline(snapshot.id); + expect(timeline).toContainEqual( + expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }), + ); const userMessages = timeline.filter((item) => item.type === "user_message"); expect(userMessages).toHaveLength(1); expect(userMessages[0].text).toBe("plain user message"); }); -test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => { +test("system-injected prompts appear as trigger tools during history replay", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -11369,6 +11808,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); const timeline = manager.getTimeline(snapshot.id); + expect(timeline).toContainEqual( + expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }), + ); const userMessages = timeline.filter((item) => item.type === "user_message"); expect(userMessages).toHaveLength(1); @@ -11584,10 +12026,7 @@ test("commits startup notices once on create and after restored history", async exitCode: 0, }, }; - const limitedToolOutput: AgentTimelineItem = { - ...toolOutput, - detail: { type: "shell", command: "print output", output: "x".repeat(64 * 1024), exitCode: 0 }, - }; + const limitedToolOutput = limitAgentTimelineItemContent(toolOutput); class NotifyingSession extends TestAgentSession { readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }]; override async *streamHistory(): AsyncGenerator { @@ -11964,15 +12403,15 @@ test.each([ const session = client.sessions[0]!; session.pushEvent({ type: "turn_started", provider: "codex", turnId: "hook-turn" }); await expect - .poll(() => events.filter(({ name }) => name === "agent.turn_started").length) + .poll(() => events.filter(({ name }) => name === "agent.turn.before").length) .toBe(1); expect(timelineStore.itemReads).toBe(readsBeforeDispatch); session.pushEvent(terminalEvent); await expect - .poll(() => events.filter(({ name }) => name === "agent.turn_ended")) + .poll(() => events.filter(({ name }) => name === "agent.turn.after")) .toEqual([ { - name: "agent.turn_ended", + name: "agent.turn.after", event: { agent: { id: agent.id, @@ -12083,3 +12522,176 @@ test("reads the last durable assistant response without loading a stored-only ch ); expect(manager.getAgent("stored-child")).toBeNull(); }); + +function runtimeCleanup(manager: AgentManager) { + let now = Date.now(); + let protectedCaller = (_agentId: string) => false; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + runtimes: () => manager.getRuntimeCleanupCandidates(), + logger, + now: () => now, + isProtected: (agentId) => protectedCaller(agentId), + schedule: () => () => {}, + }); + onTestFinished(() => cleanup.close()); + return (options: { now: Date; isProtected?: (agentId: string) => boolean }) => { + now = options.now.getTime(); + protectedCaller = options.isProtected ?? (() => false); + return cleanup.sweep(); + }; +} + +function idleCleanupHarness() { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-protection-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const commands: AgentPromptInput[] = []; + const prompts: AgentPromptInput[] = []; + const recordPrompts = (session: AgentSession) => { + session.tryHandleOutOfBand = (prompt) => + prompt === "/maintenance" + ? { + run: async () => { + commands.push(prompt); + }, + } + : null; + const start = session.startTurn.bind(session); + session.startTurn = (prompt, options) => { + prompts.push(prompt); + return start(prompt, options); + }; + return session; + }; + const sessions: AgentSession[] = []; + const probes: Array<(() => Promise) | undefined> = []; + const closeEntered = deferred(); + let closeGate: Promise | undefined; + let closes = 0; + const client = new (class extends TestAgentClient { + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + const session = await super.resumeSession(handle, config); + session.describePersistence = () => handle; + return recordPrompts(session); + } + override async createSession(config: AgentSessionConfig): Promise { + const index = sessions.length; + const session: AgentSession = await super.createSession(config); + session.canStopRuntime = async () => probes[index]?.() ?? false; + session.close = async () => { + closes++; + closeEntered.resolve(); + await closeGate; + }; + sessions.push(session); + return recordPrompts(session); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + onTestFinished(async () => { + closeGate = undefined; + await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + }); + return { + commands, + sweep: runtimeCleanup(manager), + manager, + storage, + sessions, + probes, + prompts, + closeEntered, + get closes() { + return closes; + }, + holdClose: (gate: Promise) => { + closeGate = gate; + }, + create: (labels?: Record) => + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + labels, + }), + later: (minutes: number) => new Date(Date.now() + minutes * 60_000), + }; +} + +test("idle cleanup rechecks new prompt activity after an asynchronous safety probe", async () => { + const h = idleCleanupHarness(); + const agent = await h.create(); + const entered = deferred(); + const probe = deferred(); + h.probes[0] = () => { + entered.resolve(); + return probe.promise; + }; + const sweep = h.sweep({ now: h.later(61) }); + await entered.promise; + await h.manager.runAgent(agent.id, "arrived during safety query"); + probe.resolve(true); + expect((await sweep).collected).toEqual([]); + expect(h.closes).toBe(0); +}); + +test("idle cleanup retains a parent whose idle managed child owns background work", async () => { + const h = idleCleanupHarness(); + const parent = await h.create(); + await h.create({ [PARENT_AGENT_ID_LABEL]: parent.id }); + h.probes[0] = async () => true; + h.probes[1] = async () => false; + expect((await h.sweep({ now: h.later(61) })).collected).toEqual([]); + expect(h.closes).toBe(0); +}); + +test("idle cleanup rechecks queued deliveries admitted during the safety probe", async () => { + const h = idleCleanupHarness(); + await h.create(); + let queued = false; + h.probes[0] = async () => { + queued = true; + return true; + }; + expect((await h.sweep({ now: h.later(61), isProtected: () => queued })).collected).toEqual([]); + expect(h.closes).toBe(0); +}); + +test("a prompt racing idle cleanup waits for close and resumes exactly once", async () => { + const h = idleCleanupHarness(); + const agent = await h.create(); + h.probes[0] = async () => true; + const gate = deferred(); + h.holdClose(gate.promise); + const sweep = h.sweep({ now: h.later(61) }); + await h.closeEntered.promise; + const run = h.manager.runAgent(agent.id, "arrived while closing"); + gate.resolve(); + expect((await sweep).collected).toHaveLength(1); + const result = await run; + expect(result.sessionId).toBe(agent.persistence?.sessionId); + expect(h.closes).toBe(1); + expect(h.prompts).toEqual(["arrived while closing"]); +}); + +test("an out-of-band command racing idle cleanup waits for the resumed runtime", async () => { + const h = idleCleanupHarness(); + const agent = await h.create(); + h.probes[0] = async () => true; + const gate = deferred(); + h.holdClose(gate.promise); + const sweep = h.sweep({ now: h.later(61) }); + await h.closeEntered.promise; + const command = startAgentRun(h.manager, agent.id, "/maintenance", logger); + await new Promise((resolve) => setImmediate(resolve)); + const deliveredWhileClosing = [...h.commands]; + gate.resolve(); + await sweep; + expect(deliveredWhileClosing).toEqual([]); + expect(await command).toEqual({ disposition: "out_of_band" }); + expect(h.commands).toEqual(["/maintenance"]); + expect(h.prompts).toEqual([]); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index b39fe782b8..94250e5cdd 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,3 +1,7 @@ +import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js"; +import type { AgentToolResult } from "@getpaseo/plugin/server"; +import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js"; +import { ensureAgentLoaded } from "./agent-loading.js"; import { projectTimelineRows } from "./timeline-projection.js"; import type { PluginLifecycle } from "../plugins/lifecycle/index.js"; import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js"; @@ -81,7 +85,11 @@ import { import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js"; import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; -import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; +import { + PASEO_MCP_SERVER_NAME, + stripInternalPaseoMcpServer, + withRuntimePaseoMcpServer, +} from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js"; import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js"; @@ -228,6 +236,7 @@ export type { export type AgentManagerEvent = | { type: "agent_state"; agent: ManagedAgent } + | { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean } | { type: "provider_subagent"; event: ProviderSubagentStoreEvent } | { type: "timeline_replacement"; agentId: string; epoch: string } | { @@ -331,6 +340,7 @@ export interface AgentManagerOptions { terminalManager?: TerminalManager | null; mcpBaseUrl?: string; mcpAuthToken?: string; + pluginAgentTools?: PluginAgentToolsRuntime; paseoToolsEnabled?: boolean; paseoToolCatalogFactory?: PaseoToolCatalogFactory; resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined; @@ -412,6 +422,8 @@ interface ManagedAgentBase { owner?: AgentOwner; capabilities: AgentCapabilityFlags; config: AgentSessionConfig; + /** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */ + launchEnv?: Record; runtimeInfo?: AgentRuntimeInfo; createdAt: Date; updatedAt: Date; @@ -554,18 +566,20 @@ interface AgentMetadataPatch { const SYSTEM_ERROR_PREFIX = "[System Error]"; -function attachPersistenceCwd( +function normalizePersistenceForConfig( handle: AgentPersistenceHandle | null, - cwd: string, + config: AgentSessionConfig, ): AgentPersistenceHandle | null { if (!handle) { return null; } + const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {}; return { ...handle, metadata: { - ...handle.metadata, - cwd, + ...metadata, + cwd: config.cwd, + ...(config.mcpServers ? { mcpServers: config.mcpServers } : {}), }, }; } @@ -758,10 +772,16 @@ export class AgentManager { private readonly agentRegistrationTasks = new Set>(); private readonly inFlightAgentCloses = new Map>(); private readonly reloadedSessionCloses = new WeakMap>(); + private readonly runtimeCleanupIdentities = new WeakMap(); + private readonly collectingRuntimes = new Set(); private readonly lifecycleMutationTails = new Map>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; private readonly mcpAuthToken: string | null; + private sessionOpenGeneration = 0; + private readonly sessionOpenGenerations = new Map(); + private readonly attachedPluginTools = new Map(); + private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined; private paseoToolsEnabled = true; private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null; private readonly paseoToolPolicies = new Map(); @@ -779,6 +799,7 @@ export class AgentManager { constructor(options: AgentManagerOptions) { this.pluginLifecycle = options.pluginLifecycle; + this.pluginAgentTools = options.pluginAgentTools; this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore(); this.idFactory = options.idFactory ?? (() => randomUUID()); this.registry = options?.registry; @@ -883,6 +904,62 @@ export class AgentManager { this.paseoToolCatalogFactory = factory; } + invalidatePluginSessions(): void { + this.sessionOpenGeneration++; + } + + async reloadStalePluginSession(agentId: string): Promise { + if (!this.needsPluginSessionReload(agentId)) return; + await this.trackAgentRegistrationOperation( + this.runLifecycleMutation(agentId, async () => { + if (!this.needsPluginSessionReload(agentId)) return; + await this.reloadAgentSessionInternal(agentId); + }), + ); + } + + private needsPluginSessionReload(agentId: string): boolean { + const agent = this.getAgent(agentId); + return ( + agent !== null && + agent.lifecycle !== "closed" && + this.sessionOpenGenerations.get(agentId) !== this.sessionOpenGeneration && + !this.hasInFlightRun(agentId) + ); + } + + getPluginTools(agentId: string): readonly AttachedAgentTool[] { + return (this.attachedPluginTools.get(agentId) ?? []).filter((tool) => + this.pluginAgentTools?.isToolPluginAvailable(tool.pluginId, tool.pluginInstanceId), + ); + } + + async executePluginTool( + tool: AttachedAgentTool, + agentId: string, + input: unknown, + signal?: AbortSignal, + ): Promise { + const agent = this.getAgent(agentId); + if (!agent || agent.lifecycle === "closed") throw new Error(`Agent is not live: ${agentId}`); + if (!this.pluginAgentTools) throw new Error("Plugin tools are unavailable"); + const record = await this.registry?.get(agentId); + return this.pluginAgentTools.callTool( + tool.pluginId, + tool.pluginInstanceId, + describeHookAgent({ ...agent, title: record?.title ?? agent.config.title }), + tool.name, + input, + signal, + ); + } + + private detachPluginTools(agentId: string): void { + this.attachedPluginTools.delete(agentId); + this.sessionOpenGenerations.delete(agentId); + this.pluginAgentTools?.detachTools(agentId); + } + getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined { return this.paseoToolPolicies.get(agentId); } @@ -1284,7 +1361,7 @@ export class AgentManager { this.assertAcceptingAgentRegistrations(); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); if (this.pluginLifecycle && !config.internal) { - const request = await this.pluginLifecycle.before("agent.create", { + const request = await this.pluginLifecycle.before("agent.create.before", { config, env: options.env, }); @@ -1308,21 +1385,28 @@ export class AgentManager { storedConfig.cwd, paseoToolPolicy, options?.env, - { reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null }, + { + reason: "create", + purpose: "interactive", + workspaceId: options.workspaceId ?? null, + title: options.initialTitle ?? storedConfig.title ?? null, + labels: options.labels ?? {}, + }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); const agent = await this.registerSession(session, storedConfig, resolvedAgentId, { labels: options.labels, initialTitle: options.initialTitle, workspaceId: options.workspaceId, owner: options.owner, + launchEnv: options.env ? { ...options.env } : undefined, historyPrimed: true, }); if (!agent.internal) { - this.pluginLifecycle?.emit("agent.created", { + this.pluginLifecycle?.emit("agent.create.after", { agent: describeHookAgent({ ...agent, title: agent.config.title }), }); } @@ -1407,10 +1491,11 @@ export class AgentManager { : resumeOptions; const purpose = currentResumeOptions?.purpose ?? "interactive"; + const launchEnv = record?.launchEnv; const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( mergedConfig, resolvedAgentId, - { purpose }, + { purpose, env: launchEnv }, ); const client = this.requireClient(handle.provider); const available = await client.isAvailable(); @@ -1425,11 +1510,13 @@ export class AgentManager { client, storedConfig.cwd, paseoToolPolicy, - undefined, + launchEnv, { reason: "resume", purpose, workspaceId: options?.workspaceId ?? null, + title: record?.title ?? mergedConfig.title ?? null, + labels: record?.labels ?? options?.labels ?? {}, }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); @@ -1439,9 +1526,10 @@ export class AgentManager { launchContext, currentResumeOptions, ); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, + launchEnv, persistence: handle, restoring: true, }); @@ -1487,7 +1575,12 @@ export class AgentManager { storedConfig.cwd, paseoToolPolicy, undefined, - { reason: "import", purpose: "interactive", workspaceId: input.workspaceId }, + { + reason: "import", + purpose: "interactive", + workspaceId: input.workspaceId, + labels: input.labels ?? {}, + }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( @@ -1497,6 +1590,7 @@ export class AgentManager { }, { config: providerLaunchConfig, storedConfig, launchContext }, ); + await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext); let handedToRegistration = false; try { const importedConfig = await this.normalizeConfig( @@ -1573,6 +1667,7 @@ export class AgentManager { const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( refreshConfig, agentId, + { env: existing.launchEnv }, ); const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId); const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId); @@ -1581,15 +1676,21 @@ export class AgentManager { client, storedConfig.cwd, paseoToolPolicy, - undefined, - { reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId }, + existing.launchEnv, + { + reason: "refresh", + purpose: "interactive", + workspaceId: existing.workspaceId, + title: await this.resolveInitialPersistedTitle(agentId, existing.config, null), + labels: existing.labels, + }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); if ( - Object.keys(storedConfig.mcpServers ?? {}).length > 0 && + Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 && existing.session.capabilities.supportsMcpServers !== true ) { - throw new Error(`Provider '${provider}' does not support MCP servers`); + throw this.unsupportedMcpError(providerLaunchConfig, launchContext); } let session: AgentSession | undefined; @@ -1608,7 +1709,7 @@ export class AgentManager { session = handle ? await client.resumeSession(handle, providerLaunchConfig, launchContext) : await client.createSession(providerLaunchConfig, launchContext); - await this.requireExternalMcpSupport(session, storedConfig); + await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); this.assertAcceptingAgentRegistrations(); if (rehydrateFromDisk) { @@ -1626,6 +1727,7 @@ export class AgentManager { labels: existing.labels, workspaceId: existing.workspaceId, owner: existing.owner, + launchEnv: existing.launchEnv, createdAt: existing.createdAt, updatedAt: existing.updatedAt, lastUserMessageAt: existing.lastUserMessageAt, @@ -1646,6 +1748,7 @@ export class AgentManager { throw error; } finally { if (!handedToRegistration) { + this.detachPluginTools(agentId); if (hadPreviousPaseoToolPolicy) { this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy); } else { @@ -1747,6 +1850,64 @@ export class AgentManager { }); } + /** Capture runtime capabilities without handing lifecycle ownership to the collector. */ + getRuntimeCleanupCandidates(): RuntimeCleanupCandidate[] { + if (!this.registry) return []; + const candidates: RuntimeCleanupCandidate[] = []; + for (const agent of this.agents.values()) { + const updatedAt = agent.updatedAt; + let identity = this.runtimeCleanupIdentities.get(agent.session); + if (!identity) { + identity = Object.freeze({}); + this.runtimeCleanupIdentities.set(agent.session, identity); + } + const isEligible = () => + this.agents.get(agent.id) === agent && + agent.updatedAt === updatedAt && + this.runtimeHasNoPendingWork(agent); + candidates.push({ + identity, + agentId: agent.id, + parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined, + provider: agent.provider, + sessionId: agent.persistence?.sessionId ?? null, + lastActivityAt: updatedAt.getTime(), + isEligible, + canStopRuntime: agent.session.canStopRuntime?.bind(agent.session), + closeIfEligible: async (revalidate) => { + if (!agent.persistence?.sessionId) return false; + // Block prompt admission before entering the close lane. A skipped close also + // releases this barrier, so the waiting prompt keeps the existing runtime. + this.collectingRuntimes.add(agent.id); + try { + return await this.runLifecycleMutation(agent.id, () => + this.closeAgentRuntime(agent.id, () => isEligible() && revalidate()), + ); + } finally { + this.collectingRuntimes.delete(agent.id); + } + }, + }); + } + return candidates; + } + + private runtimeHasNoPendingWork(agent: LiveManagedAgent): boolean { + return ( + agent.lifecycle === "idle" && + !agent.internal && + agent.activeForegroundTurnId === null && + !this.runs.hasRun(agent.id) && + !agent.pendingReplacement && + agent.pendingPermissions.size === 0 && + agent.inFlightPermissionResponses.size === 0 && + (this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) && + !this.foregroundMutationTails.has(agent.id) && + !this.sessionEventTails.has(agent.id) && + !this.providerSubagents.list(agent.id).some((child) => child.status === "running") + ); + } + closeAgent(agentId: string): Promise { const existing = this.inFlightAgentCloses.get(agentId); if (existing) { @@ -1767,7 +1928,7 @@ export class AgentManager { return close; } - private async closeAgentRuntime(agentId: string): Promise { + private async closeAgentRuntime(agentId: string, canClose?: () => boolean): Promise { const agent = this.requireAgent(agentId); this.logger.trace( { @@ -1782,6 +1943,7 @@ export class AgentManager { "agent.manager.close.start", ); await this.drainSessionEvents(agentId); + if (canClose && !canClose()) return false; // 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(); @@ -1794,6 +1956,7 @@ export class AgentManager { } catch (error) { persistError = error; } + this.detachPluginTools(agentId); this.emitClosedAgent(closedAgent, { persist: false }); this.logger.trace( { @@ -1807,6 +1970,7 @@ export class AgentManager { if (persistError !== undefined) { throw persistError; } + return true; } private cancelRunningProviderSubagents(parentAgentId: string): void { @@ -1958,7 +2122,7 @@ export class AgentManager { const archivedRecord = buildArchivedAgentRecord(record, options); await this.requireRegistry().upsert(archivedRecord); if (!record.archivedAt && !record.internal) { - this.pluginLifecycle?.emit("agent.archived", { + this.pluginLifecycle?.emit("agent.archive.after", { agent: describeHookAgent(archivedRecord), archivedAt: archivedRecord.archivedAt, }); @@ -2459,7 +2623,19 @@ export class AgentManager { * emitted by the handler flow through dispatchStream so they persist and * broadcast like normal timeline events. */ - tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean { + async tryRunOutOfBand( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): Promise { + if (this.collectingRuntimes.has(agentId) || !this.agents.has(agentId)) { + await this.waitForAgentClose(agentId); + await ensureAgentLoaded(agentId, { + agentManager: this, + agentStorage: this.requireRegistry(), + logger: this.logger, + }); + } const agent = this.requireSessionAgent(agentId); // Providers parse slash commands from the raw text, so a command another agent sent // must reach them without its sender envelope. @@ -2470,6 +2646,7 @@ export class AgentManager { if (!handler) { return false; } + this.touchUpdatedAt(agent); if (options?.clientMessageId) { this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted"); this.emitState(agent); @@ -2489,7 +2666,7 @@ export class AgentManager { } this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() }); }; - void (async () => { + void this.runForegroundMutation(agentId, async () => { try { await handler.run({ emit: dispatch }); } catch (error) { @@ -2500,7 +2677,7 @@ export class AgentManager { item: { type: "assistant_message", text: `[Error] ${text}` }, }); } - })(); + }); return true; } @@ -2558,7 +2735,7 @@ export class AgentManager { throw error; } if (isStaleProviderSessionError(error)) { - pendingRun.start = { status: "failed", error: error.message }; + this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message }); agent.pendingReplacement = false; if (!agent.activeForegroundTurnId) agent.lifecycle = "idle"; this.runs.settleForegroundRun(agentId, pendingRun.token); @@ -2566,29 +2743,49 @@ export class AgentManager { } agent.pendingReplacement = false; const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; - pendingRun.start = { status: "failed", error: errorMsg }; - // A terminal rejection belongs after the submitted prompt even though no provider turn exists. - if (options?.clientMessageId) { - this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", { - messageId: options.clientMessageId, + this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg }); + try { + // A terminal rejection belongs after the submitted prompt even though no provider turn exists. + if (options?.clientMessageId) { + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", { + messageId: options.clientMessageId, + }); + } + await this.handleStreamEvent(agent, { + type: "turn_failed", + provider: agent.provider, + error: errorMsg, }); + this.finalizeForegroundTurn(agent); + } finally { + // A steer waiting on this start is released only once the run is cleared. + this.runs.settleForegroundRun(agentId, pendingRun.token); } - await this.handleStreamEvent(agent, { - type: "turn_failed", - provider: agent.provider, - error: errorMsg, - }); - this.finalizeForegroundTurn(agent); - this.runs.settleForegroundRun(agentId, pendingRun.token); throw error; } } + private async *streamAfterRuntimeCollection( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): AsyncGenerator { + await this.waitForAgentClose(agentId); + await ensureAgentLoaded(agentId, { + agentManager: this, + agentStorage: this.requireRegistry(), + logger: this.logger, + }); + yield* this.streamAgent(agentId, prompt, options); + } + streamAgent( agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions, ): AsyncGenerator { + if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry)) + return this.streamAfterRuntimeCollection(agentId, prompt, options); const existingAgent = this.requireSessionAgent(agentId); this.logger.trace( { @@ -2640,7 +2837,7 @@ export class AgentManager { agent.pendingReplacement = false; } const turnStartedAt = new Date(); - pendingRun.start = { status: "started", turnId }; + this.runs.settleForegroundStart(pendingRun, { status: "started", turnId }); agent.activeForegroundTurnId = turnId; this.openActiveTurn(agent, turnId, turnStartedAt); agent.lifecycle = "running"; @@ -2743,7 +2940,10 @@ export class AgentManager { ? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId } : null); if (persistenceHandle) { - mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd); + mutableAgent.persistence = normalizePersistenceForConfig( + persistenceHandle, + mutableAgent.config, + ); } this.logger.trace( { @@ -2829,7 +3029,13 @@ export class AgentManager { expectedTurnId, }); if (admission.status === "accepted") { - await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId); + await this.recordAcceptedSteer( + agent, + prompt, + options?.clientMessageId, + expectedTurnId, + admission.providerMessageId, + ); } return admission; }); @@ -2846,6 +3052,9 @@ export class AgentManager { prompt: AgentPromptInput, options?: AgentSteerOptions, ): Promise { + // A turn that is still starting has no id to steer yet. Replacing it would drop its + // prompt, so wait until it is accepted (or fails) and steer the turn it became. + await this.runs.waitForForegroundStart(agentId); const agent = this.requireSessionAgent(agentId); const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId; if (!expectedTurnId) { @@ -2859,7 +3068,13 @@ export class AgentManager { expectedTurnId, }); if (admission.status === "accepted") { - await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId); + await this.recordAcceptedSteer( + agent, + prompt, + options?.clientMessageId, + expectedTurnId, + admission.providerMessageId, + ); } return admission; }) @@ -2964,6 +3179,7 @@ export class AgentManager { prompt: AgentPromptInput, clientMessageId: string | undefined, expectedTurnId: string, + providerMessageId: string | undefined, ): Promise { if (!clientMessageId) { return; @@ -2971,6 +3187,7 @@ export class AgentManager { this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", { messageId: clientMessageId, turnId: expectedTurnId, + ...(providerMessageId ? { providerMessageId } : {}), }); this.emitState(agent); } @@ -3302,6 +3519,10 @@ export class AgentManager { "agent.rewind.complete", ); } catch (error) { + // Native rewind can commit a fork before history refresh fails. Recovery + // must resume that latest handle rather than the abandoned conversation. + this.refreshSessionPersistence(agent); + await this.persistSnapshot(agent); this.logger.warn( { err: error, agentId, provider: agent.provider, messageId, mode }, "agent.rewind.failed", @@ -3317,6 +3538,15 @@ export class AgentManager { await this.deleteCommittedTimeline(agentId); } + notifyAgentRemoved(agent: Pick): void { + this.dispatch({ + type: "agent_removed", + agentId: agent.id, + workspaceId: agent.workspaceId, + internal: agent.internal === true, + }); + } + async deleteCommittedTimeline(agentId: string): Promise { await this.durableTimelineStore?.deleteAgent(agentId); } @@ -3575,6 +3805,7 @@ export class AgentManager { publishWhenReady?: boolean; workspaceId?: string; owner?: AgentOwner; + launchEnv?: Record; }, ): Promise { let registered = false; @@ -3687,16 +3918,23 @@ export class AgentManager { private async requireExternalMcpSupport( session: AgentSession, - storedConfig: AgentSessionConfig, + launchConfig: AgentSessionConfig, + launchContext: AgentLaunchContext, ): Promise { if ( - Object.keys(storedConfig.mcpServers ?? {}).length === 0 || + Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 || session.capabilities.supportsMcpServers === true ) { return; } await this.closeUnregisteredSession(session); - throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`); + throw this.unsupportedMcpError(launchConfig, launchContext); + } + + private unsupportedMcpError(config: AgentSessionConfig, context: AgentLaunchContext): Error { + const owners = [...new Set(Object.values(context.mcpServerOwners ?? {}))]; + const source = owners.length ? ` (plugin ${owners.join(", ")})` : ""; + return new Error(`Provider '${config.provider}' does not support MCP servers${source}`); } private async initializeAgentTimelineForRegister(params: { @@ -3753,6 +3991,7 @@ export class AgentManager { persistence?: AgentPersistenceHandle; workspaceId?: string; owner?: AgentOwner; + launchEnv?: Record; } | undefined; }): ActiveManagedAgent { @@ -3763,6 +4002,7 @@ export class AgentManager { cwd: config.cwd, workspaceId: options?.workspaceId, owner: options?.owner, + launchEnv: options?.launchEnv, session, capabilities: session.capabilities, config, @@ -3782,9 +4022,9 @@ export class AgentManager { foregroundTurnWaiters: new Set(), finalizedForegroundTurnIds: new Set(), unsubscribeSession: null, - persistence: attachPersistenceCwd( + persistence: normalizePersistenceForConfig( options?.persistence ?? session.describePersistence(), - config.cwd, + config, ), historyPrimed: options?.historyPrimed ?? durableTimelineHasRows, lastUserMessageAt: options?.lastUserMessageAt ?? null, @@ -3847,6 +4087,7 @@ export class AgentManager { private discardRetainedAgentState(agentId: string): void { this.timelineStore.delete(agentId); this.paseoToolPolicies.delete(agentId); + this.detachPluginTools(agentId); for (const event of this.providerSubagents.deleteParent(agentId)) { this.dispatch({ type: "provider_subagent", event }); } @@ -3855,7 +4096,7 @@ export class AgentManager { private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { this.emitState(agent, options); if (!agent.internal) { - this.pluginLifecycle?.emit("agent.closed", { + this.pluginLifecycle?.emit("agent.close.after", { agent: describeHookAgent({ ...agent, title: agent.config.title }), }); } @@ -4087,9 +4328,9 @@ export class AgentManager { newInfo.modeId !== agent.runtimeInfo?.modeId; agent.runtimeInfo = newInfo; if (!agent.persistence && newInfo.sessionId) { - agent.persistence = attachPersistenceCwd( + agent.persistence = normalizePersistenceForConfig( { provider: agent.provider, sessionId: newInfo.sessionId }, - agent.cwd, + agent.config, ); } // Emit state if runtimeInfo changed so clients get the updated model @@ -4447,9 +4688,9 @@ export class AgentManager { case "model_changed": agent.runtimeInfo = event.runtimeInfo; if (!agent.persistence && event.runtimeInfo.sessionId) { - agent.persistence = attachPersistenceCwd( + agent.persistence = normalizePersistenceForConfig( { provider: agent.provider, sessionId: event.runtimeInfo.sessionId }, - agent.cwd, + agent.config, ); } this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId); @@ -4523,7 +4764,7 @@ export class AgentManager { private refreshSessionPersistence(agent: ActiveManagedAgent): void { const handle = agent.session.describePersistence(); if (handle) { - agent.persistence = attachPersistenceCwd(handle, agent.cwd); + agent.persistence = normalizePersistenceForConfig(handle, agent.config); } } @@ -5163,7 +5404,7 @@ export class AgentManager { for (const subscriber of this.subscribers) { if ( subscriber.agentId && - event.type === "agent_stream" && + (event.type === "agent_stream" || event.type === "agent_removed") && subscriber.agentId !== event.agentId ) { continue; @@ -5199,6 +5440,7 @@ export class AgentManager { private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean { if (event.type === "agent_state") return event.agent.internal === true; + if (event.type === "agent_removed") return event.internal; if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true; if (event.type !== "provider_subagent") return false; const parentAgentId = @@ -5333,10 +5575,17 @@ export class AgentManager { reason: PluginSessionOpenRequest["reason"]; purpose: PluginSessionOpenRequest["purpose"]; workspaceId?: string | null; + title?: string | null; + labels?: Record; }, ): Promise { + // A plugin changing during this open must still make the resulting session stale. + const generation = this.sessionOpenGeneration; + this.detachPluginTools(agentId); + let mcpServers: AgentSessionConfig["mcpServers"]; + let mcpServerOwners: Record | undefined; if (this.pluginLifecycle) { - const request: PluginSessionOpenRequest = { + const request = { agentId, provider: client.provider, cwd, @@ -5344,29 +5593,49 @@ export class AgentManager { reason: opening?.reason ?? "resume", purpose: opening?.purpose ?? "interactive", env: { ...env }, + title: opening?.title ?? null, + labels: opening?.labels ?? {}, + tools: [], + mcpServers: {}, }; - const transformed = await this.pluginLifecycle.before("agent.session_open", request); + const transformed = await this.pluginLifecycle.before("agent.open.before", request); env = transformed.env; + if (request.purpose === "interactive") { + this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]); + mcpServers = transformed.mcpServers; + mcpServerOwners = transformed.mcpServerOwners; + } } const context: AgentLaunchContext = { agentId, + mcpServers, + mcpServerOwners, env: { ...env, PASEO_AGENT_ID: agentId, PASEO_AGENT_CWD: cwd, }, }; - if ( + const useNativeTools = this.paseoToolsEnabled && isPaseoToolPolicyEnabled(paseoToolPolicy) && - client.capabilities.supportsNativePaseoTools && - this.paseoToolCatalogFactory + client.capabilities.supportsNativePaseoTools; + if ( + this.paseoToolCatalogFactory && + (useNativeTools || this.getPluginTools(agentId).length > 0) ) { - context.paseoTools = await this.paseoToolCatalogFactory({ - callerAgentId: agentId, - paseoToolPolicy, - }); + try { + const catalog = await this.paseoToolCatalogFactory({ + callerAgentId: agentId, + paseoToolPolicy, + }); + if (useNativeTools) context.paseoTools = catalog; + } catch (error) { + this.detachPluginTools(agentId); + throw error; + } } + this.sessionOpenGenerations.set(agentId, generation); return context; } @@ -5374,7 +5643,26 @@ export class AgentManager { launchConfig: AgentSessionConfig, launchContext: AgentLaunchContext, ): AgentSessionConfig { - return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig; + const savedServerNames = new Set( + Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}), + ); + for (const name of Object.keys(launchContext.mcpServers ?? {})) { + const owner = launchContext.mcpServerOwners?.[name]; + if (name === PASEO_MCP_SERVER_NAME || savedServerNames.has(name)) { + const existingOwner = savedServerNames.has(name) + ? "agent saved configuration" + : "Paseo internal MCP server"; + throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`); + } + } + const merged = + Object.keys(launchContext.mcpServers ?? {}).length > 0 + ? { + ...launchConfig, + mcpServers: { ...launchConfig.mcpServers, ...launchContext.mcpServers }, + } + : launchConfig; + return launchContext.paseoTools ? stripInternalPaseoMcpServer(merged) : merged; } private async requireAvailableClient(options: { provider: AgentProvider }): Promise { diff --git a/packages/server/src/server/agent/agent-messages/delivery.test.ts b/packages/server/src/server/agent/agent-messages/delivery.test.ts index 8cd480c1fa..bbe3d4fb3d 100644 --- a/packages/server/src/server/agent/agent-messages/delivery.test.ts +++ b/packages/server/src/server/agent/agent-messages/delivery.test.ts @@ -283,3 +283,33 @@ test("batches retain each sender's attachment context and image order", async () )?.map((message) => message.source?.agentId), ).toEqual(["one", "two"]); }); + +test("runtime safety sees queued messages until provider dispatch settles", async () => { + const clock = createClock(); + let release!: () => void; + const dispatched = new Promise((resolve) => { + release = resolve; + }); + const delivery = createAgentMessageDelivery({ + schedule: clock.schedule, + dispatch: async () => { + await dispatched; + return "steered"; + }, + onError: (error) => { + throw error; + }, + }); + expect(delivery.hasPending("parent")).toBe(false); + delivery.enqueue({ + recipientId: "parent", + prompt: "finished", + source: { kind: "agent-message", agentId: "child" }, + }); + expect(delivery.hasPending("parent")).toBe(true); + clock.advance(2000); + expect(delivery.hasPending("parent")).toBe(true); + release(); + await delivery.close(); + expect(delivery.hasPending("parent")).toBe(false); +}); diff --git a/packages/server/src/server/agent/agent-messages/delivery.ts b/packages/server/src/server/agent/agent-messages/delivery.ts index e9313903d2..f26d83ec50 100644 --- a/packages/server/src/server/agent/agent-messages/delivery.ts +++ b/packages/server/src/server/agent/agent-messages/delivery.ts @@ -43,6 +43,8 @@ interface Dependencies { } export interface AgentMessageDelivery { enqueue(message: Message): Receipt; + /** Queued messages and provider dispatches still owned by this recipient. */ + hasPending(recipientId: string): boolean; /** Stop admission, flush pending messages, and await provider dispatch acknowledgments. */ close(): Promise; /** Existing completion waits include agent messages admitted before the wait. */ @@ -138,6 +140,7 @@ export function createAgentMessageDelivery(dependencies: Dependencies): AgentMes } return { + hasPending: (recipientId) => pending.has(recipientId) || dispatching.has(recipientId), enqueue(message) { if (closed) throw new Error("Agent message delivery is closed"); let resolve!: Admitted["resolve"]; diff --git a/packages/server/src/server/agent/agent-messages/envelope.ts b/packages/server/src/server/agent/agent-messages/envelope.ts index 2144d6ae02..a0aa880fce 100644 --- a/packages/server/src/server/agent/agent-messages/envelope.ts +++ b/packages/server/src/server/agent/agent-messages/envelope.ts @@ -1,5 +1,5 @@ import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { z } from "zod"; import type { AgentPromptContentBlock, @@ -175,9 +175,18 @@ function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null if (item.type !== "user_message") return item; const message = parseAgentMessage(item.text); if (!message) { - // COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once - // legacy notification history is retired. Schedules still use this envelope. - return isSystemInjectedEnvelope(item.text) ? null : item; + // Schedule bodies include their run ID, keeping identity stable across acceptance + // and provider replay even when the provider assigns its own message ID. + if (!isSystemInjectedEnvelope(item.text)) return item; + const text = item.text.slice("\n".length, -"\n".length); + return { + type: "tool_call", + callId: `paseo-system:${createHash("sha256").update(item.text).digest("hex")}`, + name: "heartbeat", + status: "completed", + error: null, + detail: { type: "plain_text", text, icon: "sparkles" }, + }; } const { source } = message; if (!source) { diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 7d154e9c14..b388143d8e 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -95,6 +95,7 @@ export function toStoredAgentRecord( lastStatus: agent.lifecycle, lastModeId: agent.currentModeId ?? config?.modeId ?? null, config: config ?? null, + launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined, runtimeInfo, features: normalizeFeatures(agent.features), persistence, diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 00e70756de..2c150bd2e1 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -357,3 +357,21 @@ test("a human pasting an agent envelope stays a human message through provider r clientMessageId: "human-submission", }); }); + +test("schedule prompts remain visible as completed tool calls on replay", () => { + const text = formatSystemNotificationPrompt("Heartbeat: check progress"); + expect(projectAgentMessages({ type: "user_message", messageId: "fire-1", text })).toEqual([ + expect.objectContaining({ + type: "tool_call", + status: "completed", + detail: { type: "plain_text", text: "Heartbeat: check progress", icon: "sparkles" }, + }), + ]); +}); + +test("schedule trigger identity survives the provider assigning a message ID", () => { + const text = formatSystemNotificationPrompt("Schedule fired (id=heartbeat, run=run-1)."); + expect(projectAgentMessages({ type: "user_message", text })).toEqual( + projectAgentMessages({ type: "user_message", text, messageId: "provider-message" }), + ); +}); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index a8fd2ebd4b..dde5c02bbd 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -15,6 +15,7 @@ export type AgentRunController = Pick< | "getAgent" | "tryRunOutOfBand" | "hasInFlightRun" + | "reloadStalePluginSession" | "replaceAgentRun" | "steerOrReplaceActiveTurn" | "streamAgent" @@ -110,11 +111,12 @@ export async function startAgentRun( // Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an // in-flight turn — replaceAgentRun would interrupt the running turn. The // intercept lives at this layer so it covers every prompt entrypoint. - if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { + if (await agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { options?.onDispatch?.("out_of_band"); return { disposition: "out_of_band" }; } try { + await agentManager.reloadStalePluginSession(agentId); return await startAgentRunInner(agentManager, agentId, prompt, logger, options); } catch (error) { if (!isStaleProviderSessionError(error)) throw error; diff --git a/packages/server/src/server/agent/agent-run-state.test.ts b/packages/server/src/server/agent/agent-run-state.test.ts new file mode 100644 index 0000000000..29e09a5f94 --- /dev/null +++ b/packages/server/src/server/agent/agent-run-state.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; + +import { AgentRunState } from "./agent-run-state.js"; + +function track(promise: Promise): { readonly resolved: boolean } { + const state = { resolved: false }; + void (async () => { + await promise; + state.resolved = true; + })(); + return state; +} + +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +describe("waitForForegroundStart", () => { + test("resolves once the starting turn has an id", async () => { + const runs = new AgentRunState(); + const run = runs.createPendingRun("agent"); + const wait = track(runs.waitForForegroundStart("agent")); + + await flushMicrotasks(); + expect(wait.resolved).toBe(false); + + runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" }); + await flushMicrotasks(); + expect(wait.resolved).toBe(true); + }); + + test("waits for a failed start's run to be cleared", async () => { + const runs = new AgentRunState(); + const run = runs.createPendingRun("agent"); + const wait = track(runs.waitForForegroundStart("agent")); + + runs.settleForegroundStart(run, { status: "failed", error: "start failed" }); + await flushMicrotasks(); + expect(wait.resolved).toBe(false); + expect(runs.hasPendingRun("agent")).toBe(true); + + runs.settleForegroundRun("agent", run.token); + await flushMicrotasks(); + expect(wait.resolved).toBe(true); + }); + + test("resolves at once when no turn is starting", async () => { + const runs = new AgentRunState(); + await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/agent-run-state.ts b/packages/server/src/server/agent/agent-run-state.ts index ad1eb0af34..0ea85f6d81 100644 --- a/packages/server/src/server/agent/agent-run-state.ts +++ b/packages/server/src/server/agent/agent-run-state.ts @@ -10,14 +10,18 @@ export interface ForegroundTurnWaiter { resolveSettled: () => void; } +type ForegroundRunStart = + | { status: "pending" } + | { status: "started"; turnId: string } + | { status: "failed"; error: string }; + export interface PendingForegroundRun { token: string; kind: "foreground"; stagedEvents: AgentStreamEvent[]; - start: - | { status: "pending" } - | { status: "started"; turnId: string } - | { status: "failed"; error: string }; + start: ForegroundRunStart; + turnStartedPromise: Promise; + resolveTurnStarted: () => void; settled: boolean; settledPromise: Promise; resolveSettled: () => void; @@ -54,6 +58,28 @@ export class AgentRunState { return run?.kind === "foreground" ? run : null; } + settleForegroundStart( + run: PendingForegroundRun, + start: Exclude, + ): void { + run.start = start; + if (start.status === "started") { + run.resolveTurnStarted(); + } + } + + /** + * Resolves once the agent's foreground run has a turn id or has ended. A failed start + * resolves only once its run is cleared, so callers never see a failed run still tracked. + */ + async waitForForegroundStart(agentId: string): Promise { + const run = this.getPendingRun(agentId); + if (run?.start.status !== "pending") { + return; + } + await Promise.race([run.turnStartedPromise, run.settledPromise]); + } + hasPendingRun(agentId: string): boolean { return this.getPendingRun(agentId) !== null; } @@ -277,10 +303,16 @@ export class ForegroundTurnStream { } function createPendingForegroundRun(): PendingForegroundRun { + let resolveTurnStarted!: () => void; + const turnStartedPromise = new Promise((resolvePromise) => { + resolveTurnStarted = resolvePromise; + }); return { ...createTrackedRunState(), kind: "foreground", start: { status: "pending" }, + turnStartedPromise, + resolveTurnStarted, stagedEvents: [], }; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 59fb6022e9..903bbe5c99 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -208,7 +208,11 @@ export interface AgentSteerOptions extends AgentRunOptions { clearPendingPermissions?: boolean; } -export type SteerResult = { status: "accepted" } | { status: "unavailable" }; +// providerMessageId names the steered message in the provider's own history, when the provider +// mints one. Rewinding to the message needs it. +export type SteerResult = + | { status: "accepted"; providerMessageId?: string } + | { status: "unavailable" }; export interface SteerActiveTurnOptions extends AgentSteerOptions { expectedTurnId: string; @@ -313,7 +317,10 @@ export type ToolCallDetail = subAgentType?: string; description?: string; childSessionId?: string; - log: string; + // COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. Apps before v0.14.1 + // require `log`; every producer sends it empty and carries the same tag. Optional here because + // protocol timeline items, where `log` is optional, flow into this type. + log?: string; actions?: Array<{ index: number; toolName: string; @@ -616,6 +623,9 @@ export interface AgentSessionConfig { } export interface AgentLaunchContext { + /** Launch-only plugin MCP servers and their owners; never persisted. */ + mcpServers?: Record; + mcpServerOwners?: Record; agentId?: string; env?: Record; /** @@ -687,6 +697,7 @@ export interface AgentSession { requestId: string, response: AgentPermissionResponse, ): Promise; + /** Null until a new provider process could resume this session; see docs/providers.md. */ describePersistence(): AgentPersistenceHandle | null; /** * Resolve once every foreground turn that predates this call can no longer run or become active. @@ -694,6 +705,10 @@ export interface AgentSession { * still uncertain. */ interrupt(): Promise; + /** True only when releasing this runtime loses no background work or pending replies. + * An optional additional veto: missing methods use daemon baseline eligibility; + * implemented checks that reject or return false keep the runtime resident. */ + canStopRuntime?(): Promise; /** Release live runtime resources without archiving or deleting the durable native session. */ close(): Promise; listCommands?(): Promise; diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index be94a31d49..a097bd7f82 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -54,8 +54,10 @@ const STORED_AGENT_SCHEMA = z.object({ title: z.string().nullable().optional(), labels: z.record(z.string(), z.string()).default({}), lastStatus: AgentStatusSchema.default("closed"), + resumeOnStartup: z.boolean().optional(), lastModeId: z.string().nullable().optional(), config: SERIALIZABLE_CONFIG_SCHEMA, + launchEnv: z.record(z.string(), z.string()).optional(), runtimeInfo: z .object({ provider: z.string(), @@ -259,6 +261,8 @@ export class AgentStorage { if (existing && existing.archivedAt !== undefined) { record.archivedAt = existing.archivedAt; } + record.resumeOnStartup = + existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error"; return record; }); } diff --git a/packages/server/src/server/agent/agent-timeline-content.test.ts b/packages/server/src/server/agent/agent-timeline-content.test.ts index 1ccb78c67a..1461d0aeef 100644 --- a/packages/server/src/server/agent/agent-timeline-content.test.ts +++ b/packages/server/src/server/agent/agent-timeline-content.test.ts @@ -1,12 +1,30 @@ +import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages"; import { describe, expect, test } from "vitest"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; +function serializedBytes(item: AgentTimelineItem): number { + return Buffer.byteLength(JSON.stringify(item), "utf8"); +} + describe("agent timeline content", () => { + test("returns an item within budget unchanged", () => { + const item: AgentTimelineItem = { + type: "tool_call", + callId: "small", + name: "Bash", + status: "completed", + error: null, + detail: { type: "shell", command: "ls", output: "x".repeat(60 * 1024) }, + }; + expect(limitAgentTimelineItemContent(item)).toBe(item); + }); + test.each([false, true])( - "preserves the exact UTF-16 prefix for oversized shell output (failed=%s)", + "cuts oversized shell output and keeps short fields whole (failed=%s)", (failed) => { - const text = "x".repeat(64 * 1024 - 1) + "🙂"; + const text = "x".repeat(1024 * 1024) + "🙂"; const item = limitAgentTimelineItemContent({ type: "tool_call", callId: "large-shell", @@ -15,45 +33,116 @@ describe("agent timeline content", () => { detail: { type: "shell", command: "echo large", output: text }, error: failed ? { content: text } : null, }); - expect(item).toEqual({ - type: "tool_call", + expect(item).toMatchObject({ callId: "large-shell", name: "Bash", - status: failed ? "failed" : "completed", - detail: { type: "shell", command: "echo large", output: text.slice(0, 64 * 1024) }, - error: failed ? { content: text.slice(0, 64 * 1024) } : null, + detail: { type: "shell", command: "echo large" }, }); + if (item.type !== "tool_call" || item.detail.type !== "shell") throw new Error("shape"); + expect(item.detail.output).toMatch(/^x+…\[truncated \d{3}\.\d KB\]$/); + expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); }, ); - test("marks truncated plain-text tool content within the content budget", () => { - const oversizedInput = "x".repeat(64 * 1024 + 1); - + test("replaces an oversized data URL in an unknown tool result with a marker", () => { + const url = `data:image/jpeg;base64,${"A".repeat(14 * 1024 * 1024)}`; const item = limitAgentTimelineItemContent({ type: "tool_call", - callId: "terminal-session-4242", - name: "terminal", + callId: "screenshot", + name: "node_repl.js", status: "completed", error: null, detail: { - type: "plain_text", - text: oversizedInput, - icon: "square_terminal", + type: "unknown", + input: { index: 1 }, + output: { content: [{ type: "image", url }], note: "captured" }, }, }); - - expect(limitAgentTimelineItemContent(item)).toEqual(item); expect(item).toEqual({ type: "tool_call", - callId: "terminal-session-4242", - name: "terminal", + callId: "screenshot", + name: "node_repl.js", status: "completed", error: null, detail: { - type: "plain_text", - text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]", - icon: "square_terminal", + type: "unknown", + input: { index: 1 }, + output: { content: [{ type: "image", url: "…[truncated 14.0 MB]" }], note: "captured" }, }, }); }); + + test("gives non-tool items a 256 KiB budget", () => { + const text = "a".repeat(10 * 1024 * 1024); + const item = limitAgentTimelineItemContent({ type: "assistant_message", text }); + if (item.type !== "assistant_message") throw new Error("shape"); + expect(item.text).toMatch(/^a+…\[truncated 9\.\d MB\]$/); + expect(item.text.length).toBeGreaterThan(200 * 1024); + expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024); + }); + + test("bounds an item whose structure alone exceeds the budget", () => { + const item = limitAgentTimelineItemContent({ + type: "tool_call", + callId: "many", + name: "query", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: Array.from({ length: 1_000_000 }, () => 1) }, + }); + if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape"); + const output = item.detail.output as unknown[]; + expect(output.length).toBeLessThan(1_000_000); + expect(output.every((value) => value === 1)).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); + }); + + test("keeps short strings whole and limits idempotently when an item has many strings", () => { + const callId = "3f2b8c1e-9a4d-4e6f-8b2a-1c3d5e7f9a0b"; + const item = limitAgentTimelineItemContent({ + type: "tool_call", + callId, + name: "mcp__search__files", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { query: "useWorkspaceSomethingVeryDescriptive" }, + output: Array.from( + { length: 10_000 }, + (_, index) => `${String(index).padStart(5, "0")}${"p".repeat(28)}`, + ), + }, + }); + if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape"); + expect(item.callId).toBe(callId); + expect(item.detail.input).toEqual({ query: "useWorkspaceSomethingVeryDescriptive" }); + const output = item.detail.output as string[]; + expect(output.length).toBeLessThan(10_000); + expect(output.every((path) => path.length === 33)).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024); + expect(limitAgentTimelineItemContent(item)).toBe(item); + }); + + test("keeps a long typed array valid against the wire schema", () => { + const item = limitAgentTimelineItemContent({ + type: "todo", + items: Array.from({ length: 20_000 }, (_, index) => ({ + text: `Task ${index}`, + completed: false, + })), + }); + if (item.type !== "todo") throw new Error("shape"); + expect(item.items.length).toBeLessThan(20_000); + expect(AgentTimelineItemPayloadSchema.safeParse(item).success).toBe(true); + expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024); + }); + + test("is stable on an already-limited item", () => { + const item = limitAgentTimelineItemContent({ + type: "reasoning", + text: "r".repeat(1024 * 1024), + }); + expect(limitAgentTimelineItemContent(item)).toBe(item); + }); }); diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts index 003239b283..5117073e14 100644 --- a/packages/server/src/server/agent/agent-timeline-content.ts +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -1,9 +1,19 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; -const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024; export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024; +// One timeline fetch sends its entries in one WebSocket message, so every item must stay small. +// Budgets count UTF-16 units plus a fixed cost per node; neither is exact JSON bytes. They bound +// long strings and long arrays, not wide objects (every entry is kept) or multi-byte text. +const TOOL_CALL_BUDGET = 64 * 1024; +const ITEM_BUDGET = 256 * 1024; +const NODE_COST = 8; +const MARKER_COST = 32; +// Ids, names, paths and queries stay whole, so an item keeps its identity across passes. +const SHORT_STRING = 1024; +const MAX_DEPTH = 64; + export function assertPluginTimelineDataSize(data: JsonValue): void { const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8"); if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) { @@ -17,66 +27,119 @@ function retainTextPrefix(text: string, length: number): string { return Buffer.from(text.slice(0, length), "utf16le").toString("utf16le"); } -function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem { - if ( - item.type !== "tool_call" || - item.detail.type !== "shell" || - item.status !== "failed" || - typeof item.error !== "object" || - item.error === null || - !("content" in item.error) || - typeof item.error.content !== "string" || - item.error.content.length <= TOOL_CALL_CONTENT_MAX_LENGTH - ) { - return item; - } - return { - ...item, - error: { - ...item.error, - content: retainTextPrefix(item.error.content, TOOL_CALL_CONTENT_MAX_LENGTH), - }, - }; +function formatSize(units: number): string { + if (units < 1024) return `${units} B`; + if (units < 1024 * 1024) return `${(units / 1024).toFixed(1)} KB`; + return `${(units / (1024 * 1024)).toFixed(1)} MB`; } -function limitPlainText(item: AgentTimelineItem): AgentTimelineItem { - if ( - item.type !== "tool_call" || - item.detail.type !== "plain_text" || - typeof item.detail.text !== "string" || - item.detail.text.length <= TOOL_CALL_CONTENT_MAX_LENGTH - ) { - return item; - } - const notice = "\n\n[Content truncated]"; - return { - ...item, - detail: { - ...item.detail, - text: - retainTextPrefix(item.detail.text, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice, - }, - }; +function truncatedMarker(units: number): string { + return `…[truncated ${formatSize(units)}]`; } +interface ItemSize { + nodeCost: number; + stringLengths: number[]; + tooDeep: boolean; +} + +function measure(value: unknown, depth: number, size: ItemSize): void { + size.nodeCost += NODE_COST; + if (depth > MAX_DEPTH) { + size.tooDeep = true; + } else if (typeof value === "string") { + size.stringLengths.push(value.length); + } else if (Array.isArray(value)) { + for (const element of value) measure(element, depth + 1, size); + } else if (typeof value === "object" && value !== null) { + for (const [key, child] of Object.entries(value)) { + size.nodeCost += key.length; + measure(child, depth + 1, size); + } + } +} + +/** The largest per-string length that keeps all strings, once cut, within `available`. */ +function findStringCap(lengths: readonly number[], available: number): number { + let low = 0; + let high = 0; + for (const length of lengths) high = Math.max(high, length); + while (low < high) { + const cap = Math.ceil((low + high) / 2); + let total = 0; + for (const length of lengths) total += Math.min(length, cap); + if (total <= available) low = cap; + else high = cap - 1; + } + return low; +} + +interface CutState { + stringCap: number; + remaining: number; +} + +function cutString(text: string, cap: number): string { + if (text.length <= cap) return text; + // A partial data URL is useless to every reader, so drop it whole. + if (text.startsWith("data:")) return truncatedMarker(text.length); + return retainTextPrefix(text, cap) + truncatedMarker(text.length - cap); +} + +function cut(value: unknown, depth: number, state: CutState): unknown { + state.remaining -= NODE_COST; + if (depth > MAX_DEPTH) return "…[truncated]"; + if (typeof value === "string") { + const text = cutString(value, state.stringCap); + state.remaining -= text.length; + return text; + } + if (Array.isArray(value)) { + let changed = false; + const output: unknown[] = []; + for (const element of value) { + const remainingBefore = state.remaining; + const next = cut(element, depth + 1, state); + if (state.remaining < 0) { + // Drop this and later elements. A marker element would break typed arrays. + state.remaining = remainingBefore; + changed = true; + break; + } + changed ||= next !== element; + output.push(next); + } + return changed ? output : value; + } + if (typeof value === "object" && value !== null) { + let changed = false; + const output: Record = {}; + for (const [key, child] of Object.entries(value)) { + state.remaining -= key.length; + const next = cut(child, depth + 1, state); + changed ||= next !== child; + output[key] = next; + } + return changed ? output : value; + } + return value; +} + +/** Returns the item itself when it fits its budget; otherwise a copy with its largest strings cut. */ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem { - item = limitFailedShellError(item); - item = limitPlainText(item); - if ( - item.type !== "tool_call" || - item.detail.type !== "shell" || - typeof item.detail.output !== "string" - ) { - return item; - } - if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) { - return item; - } - return { - ...item, - detail: { - ...item.detail, - output: retainTextPrefix(item.detail.output, TOOL_CALL_CONTENT_MAX_LENGTH), - }, - }; + const budget = item.type === "tool_call" ? TOOL_CALL_BUDGET : ITEM_BUDGET; + const size: ItemSize = { nodeCost: 0, stringLengths: [], tooDeep: false }; + measure(item, 0, size); + let stringTotal = 0; + for (const length of size.stringLengths) stringTotal += length; + if (!size.tooDeep && size.nodeCost + stringTotal <= budget) return item; + + // Strings get what structure leaves, but at least half the budget, less a margin for JSON + // escaping. Arrays drop the elements that do not fit, which bounds items whose short strings + // or structure alone are too big. + const markerCost = + MARKER_COST * size.stringLengths.filter((length) => length > SHORT_STRING).length; + const available = (budget * 15) / 16 - Math.min(size.nodeCost, budget / 2) - markerCost; + const stringCap = Math.max(SHORT_STRING, findStringCap(size.stringLengths, available)); + return cut(item, 0, { stringCap, remaining: budget }) as AgentTimelineItem; } 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 639b24a904..9ca1ab37b9 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -17,6 +17,7 @@ export interface AgentTimelineCursor { export type AgentTimelineFetchDirection = "tail" | "before" | "after"; export interface AgentTimelineFetchOptions { + wholeTurns?: boolean; direction?: AgentTimelineFetchDirection; cursor?: AgentTimelineCursor; /** 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 895aec2684..9ec4df4d55 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -1,7 +1,45 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; describe("InMemoryAgentTimelineStore", () => { + it("resets stale forward cursors to complete available turns", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent", { epoch: "current" }); + for (let index = 0; index < 60; index++) + store.append( + "agent", + { + type: "tool_call", + callId: `call-${index}`, + name: "shell", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + { turnId: "run" }, + ); + const page = store.fetch("agent", { + direction: "after", + cursor: { epoch: "old", seq: 10 }, + limit: 40, + wholeTurns: true, + }); + expect(page.rows).toHaveLength(60); + expect(page).toMatchObject({ + reset: true, + staleCursor: true, + startSeq: 1, + endSeq: 60, + hasOlder: false, + hasNewer: false, + }); + }); + it("clamps an overshooting before cursor into the bounded tail window", () => { const store = new InMemoryAgentTimelineStore(); store.initialize("agent-1", { @@ -143,6 +181,20 @@ describe("projected timeline retention", () => { }, ]); }); + + it("serves a merged assistant message within the item budget", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent", { epoch: "e" }); + const chunk = "a".repeat(200 * 1024); + for (let index = 0; index < 10; index++) { + store.append("agent", { type: "assistant_message", messageId: "m", text: chunk }); + } + const [row] = store.fetch("agent", { limit: 1 }).rows; + expect(row).toMatchObject({ seqStart: 1, seqEnd: 10 }); + expect(row.item).toMatchObject({ type: "assistant_message", messageId: "m" }); + expect(Buffer.byteLength(JSON.stringify(row.item))).toBeLessThanOrEqual(256 * 1024); + expect(store.getLastAssistantMessage("agent")).toHaveLength(10 * chunk.length); + }); }); describe("projected sequence ownership", () => { @@ -177,7 +229,10 @@ describe("projected sequence ownership", () => { store.append("a", { type: "assistant_message", text: "Answer" }); store.append("a", { ...tool, status: "completed" }); store.initialize("b", { rows: store.getRows("a") }); + expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" }); + expect(store.getLastAssistantMessage("b")).toBe("Answer"); expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4); + expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" }); expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]); }); it("returns the last projected assistant message without joining different messages", () => { @@ -211,3 +266,178 @@ it("includes transitive tool updates in a contiguous projected tail", () => { expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]); expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false }); }); + +async function measureTimelineTail(wholeTurns: boolean) { + const fixture = fileURLToPath( + new URL("./test-utils/timeline-responsiveness.ts", import.meta.url), + ); + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + ["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)], + { timeout: 15_000 }, + ); + process.stderr.write(stderr); + return JSON.parse(stdout) as { + result: ReturnType; + blockingMs: number; + }; +} + +it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => { + let unrelatedWork: ReturnType; + const burnCpu = () => { + const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( + process, + "threadCpuUsage", + ); + const start = threadCpuUsage(); + let elapsed: NodeJS.CpuUsage; + do { + elapsed = threadCpuUsage(start); + } while (elapsed.user + elapsed.system < 80_000); + unrelatedWork = setImmediate(burnCpu); + }; + unrelatedWork = setImmediate(burnCpu); + try { + const { result: tail, blockingMs } = await measureTimelineTail(false); + expect(tail.rows).toHaveLength(200); + expect(tail.startSeq).toBe(99_801); + expect(tail.endSeq).toBe(100_000); + expect(blockingMs).toBeLessThan(50); + } finally { + clearImmediate(unrelatedWork); + } +}); + +it.each([false, true])( + "serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)", + async (wholeTurns) => { + const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns); + expect(tail.rows).toHaveLength(200); + expect(tail.startSeq).toBe(99_801); + expect(tail.endSeq).toBe(100_000); + expect(blockingMs).toBeLessThan(50); + }, +); + +it("matches the previous reprojecting selector on seeded randomized timelines", () => { + let seed = 0x76aabbcc; + const random = (bound: number) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return Math.floor((seed / 0x1_0000_0000) * bound); + }; + for (let scenario = 0; scenario < 100; scenario++) { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a", { epoch: "e" }); + for (let seq = 1; seq <= 100; seq++) { + const identity = String(random(8)); + const text = String(seq); + const items: AgentTimelineItem[] = [ + { type: "user_message", text }, + { type: "assistant_message", text }, + { type: "assistant_message", text, messageId: identity }, + { type: "reasoning", text }, + { + type: "tool_call", + callId: identity, + name: "shell", + status: "running", + error: null, + detail: { type: "plain_text", label: text }, + }, + { + type: "plugin", + id: identity, + pluginId: "test", + kind: "state", + version: 1, + data: { text }, + }, + ]; + store.append("a", items[random(items.length)], { turnId: String(random(3)) }); + } + // Include rehydration of already-projected rows, with non-monotonic seqEnd. + store.initialize("b", { rows: store.getRows("a"), epoch: "e" }); + for (const agentId of ["a", "b"]) { + assertSelectionMatchesReference(store, agentId); + store.append(agentId, { type: "assistant_message", text: "later" }); + assertSelectionMatchesReference(store, agentId); + } + } +}); + +function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void { + for (const direction of ["tail", "before", "after"] as const) { + for (const limit of [0, 1, 7, 200]) { + for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) { + const actual = store.fetch(agentId, { + direction, + limit, + cursor: { epoch: "e", seq: cursorSeq }, + }); + const expected = selectReferenceProjectedTimelinePage({ + rows: store.getRows(agentId), + bounds: actual.window, + direction, + limit, + cursorSeq, + }); + expect({ + ...expected, + entries: undefined, + rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)), + }).toEqual({ + entries: undefined, + rows: actual.rows, + startSeq: actual.startSeq, + endSeq: actual.endSeq, + hasOlder: actual.hasOlder, + hasNewer: actual.hasNewer, + }); + } + } + } +} + +it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a", { epoch: "e" }); + const tool: AgentTimelineItem = { + type: "tool_call", + callId: "t", + name: "shell", + status: "running", + error: null, + detail: { type: "plain_text", label: "work" }, + }; + store.append("a", tool); + for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) }); + const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 }); + expect(first.endSeq).toBe(2); + store.append("a", { ...tool, status: "completed" }); + store.append("a", { type: "assistant_message", text: "Done" }); + expect(first.rows[0].item).toMatchObject({ status: "running" }); + const second = store.fetch("a", { + direction: "after", + cursor: { epoch: "e", seq: first.endSeq! }, + limit: 2, + }); + expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]); + expect(second.endSeq).toBe(4); + const third = store.fetch("a", { + direction: "after", + cursor: { epoch: "e", seq: second.endSeq! }, + limit: 2, + }); + expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]); + expect(third.rows[0].item).toMatchObject({ status: "completed" }); + expect(third.endSeq).toBe(6); + const last = store.fetch("a", { + direction: "after", + cursor: { epoch: "e", seq: third.endSeq! }, + limit: 2, + }); + expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]); + expect(last.endSeq).toBe(7); + expect(last.hasNewer).toBe(false); +}); diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index 6a37e11ef0..4c3b9c5c3a 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -1,9 +1,6 @@ import { randomUUID } from "node:crypto"; -import { - TimelineProjection, - selectProjectedTimelinePage, - type ProjectedTimelineRow, -} from "./timeline-projection.js"; +import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js"; +import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineFetchOptions, @@ -43,8 +40,7 @@ export class InMemoryAgentTimelineStore { ? options.rows.map(cloneRow) : this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp); 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); + const projection = new TimelineProjection(rows); this.states.set(agentId, { epoch: options?.epoch ?? randomUUID(), projection, @@ -97,22 +93,22 @@ export class InMemoryAgentTimelineStore { const state = this.requireState(agentId); const direction = options?.direction ?? "tail"; const cursor = options?.cursor; - const rows = state.projection.getRows(); + const hasRows = state.minSeq !== 0; 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 && + hasRows && cursor.seq < state.minSeq - 1; const reset = staleCursor || gap; - const page = selectProjectedTimelinePage({ - rows, + const page = state.projection.selectPage({ bounds: window, direction: reset ? "tail" : direction, cursorSeq: cursor?.seq, limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT, + wholeTurns: options?.wholeTurns, }); return { epoch: state.epoch, @@ -125,7 +121,12 @@ export class InMemoryAgentTimelineStore { hasNewer: page.hasNewer, startSeq: page.startSeq, endSeq: page.endSeq, - rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)), + // Stored rows are already within budget; this bounds assistant and reasoning merges. + rows: page.entries.map((entry) => + Object.assign({ seq: entry.seqEnd }, entry, { + item: limitAgentTimelineItemContent(entry.item), + }), + ), }; } @@ -150,14 +151,11 @@ export class InMemoryAgentTimelineStore { getLastItem(agentId: string): AgentTimelineItem | null { const state = this.requireState(agentId); - return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null; + return state.projection.getLastItem(state.nextSeq - 1); } getLastAssistantMessage(agentId: string): string | null { - const row = this.requireState(agentId) - .projection.getRows() - .findLast((candidate) => candidate.item.type === "assistant_message"); - return row?.item.type === "assistant_message" ? row.item.text : null; + return this.requireState(agentId).projection.getLastAssistantMessage(); } private requireState(agentId: string): AgentTimelineState { diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index 5d2c9fddc1..daa5ba9ec1 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -69,6 +69,7 @@ test("session create forwards clientMessageId to the initial prompt run options" getAgent: vi.fn(() => snapshot), tryRunOutOfBand: vi.fn(() => false), hasInFlightRun: vi.fn(() => false), + reloadStalePluginSession: async () => {}, streamAgent, waitForAgentRunStart: vi.fn(async () => undefined), } as unknown as Parameters[0]["agentManager"], diff --git a/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts b/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts index 872961111b..323e4be48f 100644 --- a/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts +++ b/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts @@ -1,3 +1,4 @@ +import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js"; import { HostObservationError } from "../host-operations/index.js"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -67,7 +68,7 @@ afterEach(async () => { for (const service of services.splice(0)) await service.close(); await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))); }); -async function scenario(directory?: string, removalFailures = 0) { +async function scenario(directory?: string, removalFailures = 0, retryDelayMs = 1) { const home = directory ?? (await mkdtemp(join(tmpdir(), "finish-recovery-"))); if (!directory) homes.push(home); const durableStore = createJsonFinishSubscriptionStore(home); @@ -91,7 +92,7 @@ async function scenario(directory?: string, removalFailures = 0) { callerIsActive: async () => active, serverId: "origin", logger: createTestLogger(), - retryDelayMs: 1, + retryDelayMs, }); services.push(service); await service.start(); @@ -387,13 +388,13 @@ test("removed-host notice retries parent dispatch without observing again or del }); test("failed removal after dispatch acknowledgment retries only storage, preserving the accepted outcome", async () => { - const state = await scenario(undefined, 1); + const state = await scenario(undefined, 2, 1000); await state.service.register(intent); await until(() => state.observation.waits.length === 1); state.observation.waits[0]!.result.resolve(outcome("idle")); await until(() => state.delivery.prompts.length === 1); state.delivery.acknowledgments[0]!.resolve("steered"); - await expect.poll(async () => (await state.store.list()).length).toBe(0); + await expect.poll(async () => (await state.store.list()).length, { timeout: 10000 }).toBe(0); expect(state.delivery.prompts).toHaveLength(1); expect(state.delivery.prompts[0]).toContain("finished."); expect(state.observation.waits).toHaveLength(1); @@ -412,3 +413,87 @@ test("shutdown during observation setup owns rejection of the already-canceled n expect(await state.store.list()).toHaveLength(1); expect(state.delivery.prompts).toEqual([]); }); + +test("remote finish intent protects its caller through registration, observation and delivery", async () => { + const s = await scenario(); + let time = 61 * 60_000; + let collected = false; + const runtime = { + identity: {}, + agentId: "parent", + parentAgentId: undefined, + provider: "test", + sessionId: "parent", + lastActivityAt: 0, + isEligible: () => !collected, + closeIfEligible: async (revalidate: () => boolean) => { + if (!revalidate()) return false; + collected = true; + return true; + }, + }; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + logger: createTestLogger(), + now: () => time, + runtimes: () => (collected ? [] : [runtime]), + isProtected: (agentId) => s.service.hasPendingForCaller(agentId), + schedule: () => () => {}, + }); + const savedPut = s.store.put.bind(s.store); + const writing = deferred(); + s.store.put = async (record) => { + await writing.promise; + await savedPut(record); + }; + const registration = s.service.register({ ...intent, serverId: "remote" }, true); + const protectedDuringRegistration = s.service.hasPendingForCaller?.("parent") ?? false; + writing.resolve(); + const accepted = await registration; + expect(protectedDuringRegistration).toBe(true); + expect((await cleanup.sweep()).collected).toEqual([]); + expect(s.service.hasPendingForCaller("parent")).toBe(true); + accepted.accept(); + await until(() => s.observation.waits.length === 1); + expect(s.service.hasPendingForCaller("parent")).toBe(true); + s.observation.waits[0]!.result.resolve(outcome()); + await until(() => s.delivery.prompts.length === 1); + expect(s.service.hasPendingForCaller("parent")).toBe(true); + s.delivery.acknowledgments[0]!.resolve("steered"); + await until(() => !s.service.hasPendingForCaller("parent")); + expect(await s.store.list()).toEqual([]); + time = 62 * 60_000; + expect((await cleanup.sweep()).collected).toEqual([]); + time = 122 * 60_000; + expect((await cleanup.sweep()).collected).toHaveLength(1); + await cleanup.close(); +}); + +test("finish recovery protects callers before durable intent has loaded", async () => { + const s = await scenario(); + await s.service.register({ ...intent, serverId: "remote" }); + await s.service.close(); + const loading = deferred(); + const recovered = createFinishSubscriptions({ + store: { + ...s.store, + list: async () => { + await loading.promise; + return s.store.list(); + }, + }, + messages: () => s.delivery, + open: s.observation.open.bind(s.observation), + callerIsActive: async () => true, + serverId: "origin", + logger: createTestLogger(), + }); + services.push(recovered); + expect(recovered.hasPendingForCaller("parent")).toBe(true); + const starting = recovered.start(); + expect(recovered.hasPendingForCaller("parent")).toBe(true); + loading.resolve(); + await starting; + expect(recovered.hasPendingForCaller("parent")).toBe(true); + expect(recovered.hasPendingForCaller("unrelated")).toBe(false); +}); diff --git a/packages/server/src/server/agent/finish-subscriptions/index.ts b/packages/server/src/server/agent/finish-subscriptions/index.ts index 58c3c8eff1..22324f0ecc 100644 --- a/packages/server/src/server/agent/finish-subscriptions/index.ts +++ b/packages/server/src/server/agent/finish-subscriptions/index.ts @@ -49,6 +49,8 @@ export interface FinishRegistration { } export interface FinishSubscriptions { start(): Promise; + /** Pending notification intent, including registration, recovery and unacknowledged delivery. */ + hasPendingForCaller(agentId: string): boolean; register( input: Omit, pendingDispatch?: boolean, @@ -72,6 +74,8 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio const tasks = new Set>(); const watches = new Map>(); let closed = false; + let recovering = true; + const registering = new Map(); let mutations = Promise.resolve(); function serialize(action: () => Promise): Promise { const result = mutations.then(action); @@ -300,6 +304,13 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio } return { + hasPendingForCaller(agentId) { + if (recovering || registering.has(agentId)) return true; + for (const group of watches.values()) { + for (const watch of group) if (watch.records[0]!.callerAgentId === agentId) return true; + } + return false; + }, start: () => serialize(async () => { const groups = new Map(); @@ -309,9 +320,11 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio groups.set(key(record), records); } for (const records of groups.values()) attach(records, false); + recovering = false; }), - register: (input, pendingDispatch = false) => - serialize(async () => { + register: (input, pendingDispatch = false) => { + registering.set(input.callerAgentId, (registering.get(input.callerAgentId) ?? 0) + 1); + return serialize(async () => { if (closed) throw new Error("Finish subscriptions are closed"); const record: FinishSubscription = { ...input, @@ -337,13 +350,18 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio }, reject: () => serialize(async () => { - stop(watch, actualGroup); await remove(watch); + stop(watch, actualGroup); }), }; if (!pendingDispatch) registration.accept(); return registration; - }), + }).finally(() => { + const remaining = registering.get(input.callerAgentId)! - 1; + if (remaining) registering.set(input.callerAgentId, remaining); + else registering.delete(input.callerAgentId); + }); + }, async close() { closed = true; await mutations; diff --git a/packages/server/src/server/agent/finish-subscriptions/internal/observation.ts b/packages/server/src/server/agent/finish-subscriptions/internal/observation.ts index 887a4ba075..7abe483cf4 100644 --- a/packages/server/src/server/agent/finish-subscriptions/internal/observation.ts +++ b/packages/server/src/server/agent/finish-subscriptions/internal/observation.ts @@ -13,6 +13,8 @@ import type { FinishObservation } from "../index.js"; export function createFinishObservation(input: { agentManager: AgentManager; agentStorage: AgentStorage; + /** A child chosen for restart recovery reports its state only after its resume was sent. */ + waitForResume(agentId: string, signal: AbortSignal): Promise; messages(): AgentMessageDelivery; hosts(): ToolHosts; serverId: string; @@ -24,6 +26,7 @@ export function createFinishObservation(input: { hosts: input.hosts, local: { async wait(agentId, signal) { + await input.waitForResume(agentId, signal); await input.messages().waitForDispatch(agentId, signal); signal.throwIfAborted(); const live = agentManager.getAgent(agentId); @@ -52,6 +55,7 @@ export function createFinishObservation(input: { }; }, async observe(agentId, handler, signal, onError) { + await input.waitForResume(agentId, signal); const stored = await agentStorage.get(agentId); let updates = Promise.resolve(); const publish = (snapshot: AgentSnapshotPayload | null, event?: AgentStreamEvent) => { diff --git a/packages/server/src/server/agent/idle-runtime-cleanup/idle-runtime-cleanup.test.ts b/packages/server/src/server/agent/idle-runtime-cleanup/idle-runtime-cleanup.test.ts new file mode 100644 index 0000000000..5ed1fa9b79 --- /dev/null +++ b/packages/server/src/server/agent/idle-runtime-cleanup/idle-runtime-cleanup.test.ts @@ -0,0 +1,294 @@ +import { expect, onTestFinished, test } from "vitest"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { createIdleRuntimeCleanup, type RuntimeCleanupCandidate } from "./index.js"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +class Runtime { + readonly identity = {}; + busy = false; + closed = false; + resumable = true; + activity = 0; + parentAgentId: string | undefined; + probe: (() => Promise) | undefined; + admitClose: (() => Promise) | undefined; + finishClose: (() => Promise) | undefined; + constructor(readonly id = "agent") {} + capture(): RuntimeCleanupCandidate { + const activity = this.activity; + return { + identity: this.identity, + agentId: this.id, + parentAgentId: this.parentAgentId, + provider: "test", + sessionId: this.resumable ? this.id : null, + lastActivityAt: activity, + isEligible: () => !this.closed && !this.busy && this.activity === activity, + canStopRuntime: this.probe, + closeIfEligible: async (eligible) => { + await this.admitClose?.(); + if (!eligible()) return false; + this.closed = true; + await this.finishClose?.(); + return true; + }, + }; + } +} +function scenario(enabled = true) { + const runtime = new Runtime(); + const runtimes = [runtime]; + let time = 0; + let protectedCaller = false; + let tick!: () => void; + const cleanup = createIdleRuntimeCleanup({ + enabled, + logger: createTestLogger(), + now: () => time, + runtimes: () => runtimes.filter((item) => !item.closed).map((item) => item.capture()), + isProtected: () => protectedCaller, + schedule: (callback, interval) => { + expect(interval).toBe(60_000); + tick = callback; + return () => {}; + }, + }); + onTestFinished(() => cleanup.close()); + return { + runtime, + runtimes, + cleanup, + tick: () => tick(), + protect: (value: boolean) => { + protectedCaller = value; + }, + sweep: (minutes: number) => { + time = minutes * 60_000; + return cleanup.sweep(); + }, + }; +} + +test("a runtime without a provider check is collected at exactly one hour", async () => { + const s = scenario(); + expect((await s.sweep(59)).collected).toEqual([]); + expect((await s.sweep(60)).collected).toEqual([ + { agentId: "agent", provider: "test", sessionId: "agent" }, + ]); +}); + +test("activity restarts the single one-hour idle period", async () => { + const s = scenario(); + await s.sweep(59); + s.runtime.activity = 59 * 60_000; + expect((await s.sweep(118)).collected).toEqual([]); + expect((await s.sweep(119)).collected).toHaveLength(1); +}); + +test.each(["foreground", "finish subscription"])( + "a positive provider check cannot override %s", + async (blocker) => { + const s = scenario(); + let calls = 0; + s.runtime.probe = async () => { + calls++; + return true; + }; + if (blocker === "foreground") s.runtime.busy = true; + else s.protect(true); + expect((await s.sweep(61)).collected).toEqual([]); + expect(calls).toBe(0); + s.runtime.busy = false; + s.protect(false); + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(121)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toHaveLength(1); + }, +); + +test.each(["veto", "error"])( + "a supported provider %s retains the runtime until a fresh idle hour passes", + async (decision) => { + const s = scenario(); + s.runtime.probe = async () => { + if (decision === "error") throw new Error("Probe failed"); + return false; + }; + const blocked = await s.sweep(61); + expect(blocked.collected).toEqual([]); + expect(blocked.failures).toHaveLength(decision === "error" ? 1 : 0); + await s.sweep(119); + s.runtime.probe = async () => true; + expect((await s.sweep(120)).collected).toEqual([]); + expect((await s.sweep(179)).collected).toEqual([]); + expect((await s.sweep(180)).collected).toHaveLength(1); + }, +); + +test("an idle managed descendant with provider-held work vetoes its parent", async () => { + const s = scenario(); + const child = new Runtime("child"); + child.parentAgentId = s.runtime.id; + child.probe = async () => false; + s.runtimes.push(child); + expect((await s.sweep(61)).collected).toEqual([]); + child.probe = async () => true; + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toHaveLength(2); +}); + +test("new caller protection during an async provider probe prevents stale collection", async () => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = () => probe.promise; + const collecting = s.sweep(61); + s.protect(true); + probe.resolve(true); + expect((await collecting).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.protect(false); + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toHaveLength(1); +}); + +test("a supported provider timeout retains ownership even if it later returns true", async () => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = () => probe.promise; + const result = await s.sweep(61); + expect(result.collected).toEqual([]); + expect(result.failures[0]?.error).toEqual(new Error("Runtime safety probe timed out for agent")); + probe.resolve(true); + expect((await s.sweep(62)).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); +}, 15_000); + +test("the host opt-out disables explicit sweeps and scheduled ticks", async () => { + const s = scenario(false); + let calls = 0; + s.runtime.probe = async () => { + calls++; + return true; + }; + expect((await s.sweep(61)).collected).toEqual([]); + s.tick(); + await s.cleanup.close(); + expect(calls).toBe(0); + expect(s.runtime.closed).toBe(false); +}); + +test("a managed descendant without persistence still protects its parent while working", async () => { + const s = scenario(); + const child = new Runtime("initializing-child"); + child.parentAgentId = s.runtime.id; + child.resumable = false; + child.busy = true; + s.runtimes.push(child); + expect((await s.sweep(61)).collected).toEqual([]); + child.busy = false; + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toEqual([ + { agentId: "agent", provider: "test", sessionId: "agent" }, + ]); + expect(child.closed).toBe(false); +}); + +test("the live host switch retains eligible runtimes and re-enables with their existing idle clocks", async () => { + const s = scenario(); + s.cleanup.setEnabled(false); + expect((await s.sweep(61)).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.cleanup.setEnabled(true); + s.tick(); + expect((await s.cleanup.sweep()).collected).toHaveLength(1); +}); + +test.each([false, true])( + "disabling during a probe invalidates the sweep (re-enable: %s)", + async (reEnable) => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = () => probe.promise; + const collecting = s.sweep(61); + s.cleanup.setEnabled(false); + if (reEnable) s.cleanup.setEnabled(true); + probe.resolve(true); + expect((await collecting).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.cleanup.setEnabled(true); + expect((await s.sweep(62)).collected).toHaveLength(1); + }, +); + +test.each([false, true])( + "disabling while waiting for lifecycle admission prevents closure (re-enable: %s)", + async (reEnable) => { + const s = scenario(); + const admitted = deferred(); + const waiting = deferred(); + s.runtime.admitClose = () => { + waiting.resolve(); + return admitted.promise; + }; + const collecting = s.sweep(61); + await waiting.promise; + s.cleanup.setEnabled(false); + if (reEnable) s.cleanup.setEnabled(true); + admitted.resolve(); + expect((await collecting).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.cleanup.setEnabled(true); + expect((await s.sweep(62)).collected).toHaveLength(1); + }, +); + +test.each(["veto", "error"])( + "an invalidated provider %s does not reset the idle clock", + async (outcome) => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = async () => { + await probe.promise; + if (outcome === "error") throw new Error("Old probe failed"); + return false; + }; + const collecting = s.sweep(61); + s.cleanup.setEnabled(false); + s.cleanup.setEnabled(true); + probe.resolve(false); + expect(await collecting).toEqual({ collected: [], failures: [] }); + s.runtime.probe = async () => true; + expect((await s.sweep(62)).collected).toHaveLength(1); + }, +); + +test("disabling lets a started close finish but prevents the next closure in that sweep", async () => { + const s = scenario(); + const sibling = new Runtime("sibling"); + s.runtimes.push(sibling); + const closing = deferred(); + const finished = deferred(); + s.runtime.finishClose = () => { + closing.resolve(); + return finished.promise; + }; + const collecting = s.sweep(61); + await closing.promise; + expect(s.runtime.closed).toBe(true); + s.cleanup.setEnabled(false); + finished.resolve(); + expect((await collecting).collected).toEqual([ + { agentId: "agent", provider: "test", sessionId: "agent" }, + ]); + expect(sibling.closed).toBe(false); + s.cleanup.setEnabled(true); + expect((await s.sweep(62)).collected).toEqual([ + { agentId: "sibling", provider: "test", sessionId: "sibling" }, + ]); +}); diff --git a/packages/server/src/server/agent/idle-runtime-cleanup/index.ts b/packages/server/src/server/agent/idle-runtime-cleanup/index.ts new file mode 100644 index 0000000000..fddea0d25b --- /dev/null +++ b/packages/server/src/server/agent/idle-runtime-cleanup/index.ts @@ -0,0 +1,167 @@ +import type { Logger } from "pino"; +import { withTimeout } from "../../../utils/promise-timeout.js"; + +/** A captured runtime capability. Its lifecycle owner revalidates before releasing it. */ +export interface RuntimeCleanupCandidate { + readonly identity: object; + readonly agentId: string; + readonly parentAgentId: string | undefined; + readonly provider: string; + readonly sessionId: string | null; + readonly lastActivityAt: number; + isEligible(): boolean; + canStopRuntime?(): Promise; + closeIfEligible(revalidate: () => boolean): Promise; +} +interface Options { + enabled: boolean; + runtimes(): readonly RuntimeCleanupCandidate[]; + isProtected?(agentId: string): boolean; + logger: Pick; + now?: () => number; + schedule?: (tick: () => void, intervalMs: number) => () => void; +} +interface CollectionEntry { + agentId: string; + provider: string; + sessionId: string; +} +interface CollectionResult { + collected: CollectionEntry[]; + failures: Array<{ agentId: string; error: unknown }>; +} +const IDLE_PERIOD_MS = 60 * 60_000; + +/** Owns idle clocks, baseline eligibility, optional provider vetoes and the sweep lifetime. */ +export function createIdleRuntimeCleanup(options: Options) { + const protectedAt = new WeakMap(); + let collection: Promise | null = null; + let stopped = false; + let enabled = options.enabled; + let generation = 0; + const isProtected = options.isProtected ?? (() => false); + const now = options.now ?? Date.now; + + function familyOf(root: RuntimeCleanupCandidate, runtimes: readonly RuntimeCleanupCandidate[]) { + const family = new Map([[root.agentId, root]]); + for (const parent of family.values()) { + for (const child of runtimes) { + if (child.parentAgentId === parent.agentId) family.set(child.agentId, child); + } + } + return [...family.values()]; + } + function baselineAllows(family: readonly RuntimeCleanupCandidate[]) { + return family.every((runtime) => runtime.isEligible() && !isProtected(runtime.agentId)); + } + async function providersAllow( + family: readonly RuntimeCleanupCandidate[], + isCurrent: () => boolean, + ) { + for (const runtime of family) { + if (!isCurrent()) return false; + if (!runtime.canStopRuntime) continue; + const allowed = await withTimeout( + runtime.canStopRuntime(), + 10_000, + `Runtime safety probe timed out for ${runtime.agentId}`, + ); + if (allowed !== true) return false; + } + return true; + } + async function collect(): Promise { + const result: CollectionResult = { collected: [], failures: [] }; + const sweepGeneration = generation; + const isCurrent = () => enabled && !stopped && generation === sweepGeneration; + if (!isCurrent()) return result; + const timestamp = now(); + const runtimes = options.runtimes(); + for (const runtime of runtimes) { + if (!isCurrent()) break; + if (!runtime.sessionId) continue; + const family = familyOf(runtime, runtimes); + try { + const allowed = baselineAllows(family) && (await providersAllow(family, isCurrent)); + if (!isCurrent()) break; + if (!allowed) { + protectedAt.set(runtime.identity, { at: timestamp, protected: true }); + options.logger.debug( + { agentId: runtime.agentId }, + "Idle runtime retained by a baseline or provider veto", + ); + continue; + } + const previous = protectedAt.get(runtime.identity); + if (previous?.protected) + protectedAt.set(runtime.identity, { at: timestamp, protected: false }); + const idleSince = Math.max( + runtime.lastActivityAt, + protectedAt.get(runtime.identity)?.at ?? 0, + ); + if (timestamp - idleSince < IDLE_PERIOD_MS) continue; + const revalidate = () => { + const currentFamily = familyOf(runtime, options.runtimes()); + return ( + isCurrent() && + currentFamily.length === family.length && + currentFamily.every((current) => + family.some((captured) => captured.identity === current.identity), + ) && + baselineAllows(family) + ); + }; + if (await runtime.closeIfEligible(revalidate)) { + const entry = { + agentId: runtime.agentId, + provider: runtime.provider, + sessionId: runtime.sessionId, + }; + result.collected.push(entry); + options.logger.info(entry, "Collected idle agent runtime"); + } else if (isCurrent()) { + protectedAt.set(runtime.identity, { at: timestamp, protected: true }); + } + } catch (error) { + if (!isCurrent()) break; + protectedAt.set(runtime.identity, { at: timestamp, protected: true }); + result.failures.push({ agentId: runtime.agentId, error }); + options.logger.warn( + { agentId: runtime.agentId, err: error }, + "Idle runtime cleanup failed", + ); + } + } + return result; + } + function sweep(): Promise { + if (collection) return collection; + collection = collect().finally(() => { + collection = null; + }); + return collection; + } + const schedule = + options.schedule ?? + ((tick, intervalMs) => { + const timer = setInterval(tick, intervalMs); + timer.unref(); + return () => clearInterval(timer); + }); + const cancel = schedule(() => { + void sweep().catch((error) => options.logger.warn({ err: error }, "Idle runtime sweep failed")); + }, 60_000); + return { + sweep, + setEnabled(value: boolean) { + if (enabled === value) return; + enabled = value; + generation++; + }, + async close() { + stopped = true; + cancel(); + await collection; + }, + }; +} diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index b8a0d820ec..ecf2d912a5 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -76,7 +76,7 @@ import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.j import type { ForgeService } from "../../services/forge-service.js"; import { areEquivalentPaths } from "../../utils/path.js"; import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js"; -import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js"; +import { startWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index"; import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages"; @@ -270,6 +270,8 @@ function buildAgentManagerSpies() { hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), appendTimelineItem: vi.fn().mockResolvedValue(undefined), emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined), + getPluginTools: () => [], + reloadStalePluginSession: async () => {}, hasInFlightRun: vi.fn().mockReturnValue(false), steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }), tryRunOutOfBand: vi.fn().mockReturnValue(false), @@ -617,6 +619,10 @@ class FakeBrowserToolsBroker { } class BoundaryAgentManagerFake { + public getPluginTools(): [] { + return []; + } + private readonly agent = createManagedAgent({ id: "agent-1", cwd: REPO_CWD, @@ -875,6 +881,7 @@ async function createAgentMcpServer( logger: options.logger, ...createFinishObservation({ ...options, + waitForResume: async () => {}, messages: () => messages, serverId: options.serverId ?? "self", hosts: () => { @@ -1378,13 +1385,24 @@ describe("terminal MCP tools", () => { ); it.each([ - ["in-process", createTerminalManager], - ["worker", createWorkerTerminalManager], + ["in-process", async () => ({ manager: createTerminalManager(), home: null })], + [ + "worker", + async () => { + const home = await mkdtemp(join(tmpdir(), "mcp-terminal-keys-home-")); + const manager = await startWorkerTerminalManager({ + paseoHome: home, + paseoVersion: "0.0.0-test", + logger, + }); + return { manager, home }; + }, + ], ] as const)( "delivers MCP key tokens through %s after terminal input modes change", async (_name, createManager) => { const { agentManager, agentStorage } = createTestDeps(); - const terminalManager = createManager(); + const { manager: terminalManager, home } = await createManager(); const terminal = await terminalManager.createTerminal({ cwd: process.cwd(), workspaceId: "ws-terminal-keys", @@ -1414,15 +1432,15 @@ describe("terminal MCP tools", () => { logger, }); + // Every step is a round trip through a real PTY. A loaded Windows runner (ConPTY) has + // taken over 3 s to reach READY, so each step gets the same window rather than 1 s. + const roundTrip = { timeout: 5000 }; let output = ""; const unsubscribe = terminal.subscribe((message) => { if (message.type === "output") { output += message.data; } }); - // Every key travels through the PTY and back; on loaded Windows runners one round - // trip can take seconds, so each wait gets the same budget as the first. - const roundTrip = { timeout: 5000 }; try { await vi.waitFor(async () => { const capture = await terminalManager.captureTerminal(terminal.id); @@ -1459,6 +1477,7 @@ describe("terminal MCP tools", () => { await server.close(); await terminalManager.killTerminalAndWait(terminal.id); terminalManager.killAll(); + if (home) await rm(home, { recursive: true, force: true }); } }, ); @@ -1474,7 +1493,6 @@ describe("terminal MCP tools", () => { id: "term-1", name: "daemon", cwd: process.cwd(), - getState: vi.fn().mockReturnValue({ scrollback: [], grid: [[]] }), }), captureTerminal, }); diff --git a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts index 53270c1a78..0307eabda6 100644 --- a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts +++ b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts @@ -99,16 +99,17 @@ afterEach(() => { }); describe("mutable provider config owner", () => { - test("publishes provider changes after commit and emits nothing on rollback", () => { + test("publishes provider changes after commit and emits nothing on rollback", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 })); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - providerOverrides: { codex: { enabled: true } }, + providerOverrides: CONTROLLED_PROVIDERS, + extraClients: { codex: controlledCodexClient(async () => catalog("initial")) }, }); const cwd = path.resolve("/tmp/provider-config-owner"); - manager.getSnapshot(cwd); + await manager.refreshSnapshotForCwd({ cwd }); const events: string[] = []; manager.on("change", ({ current }) => events.push(current.cwd)); let agentManagerState = manager.getAgentManagerProviderState(); @@ -125,7 +126,7 @@ describe("mutable provider config owner", () => { }); try { - store.patch({ providers: { codex: { enabled: false } } }); + await store.patch({ providers: { codex: { enabled: false } } }); expect(events).toEqual([cwd]); expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false }); @@ -133,7 +134,7 @@ describe("mutable provider config owner", () => { const unsubscribeFailure = store.onApply(() => { throw new Error("later owner failed"); }); - expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow( + await expect(store.patch({ providers: { codex: { enabled: true } } })).rejects.toThrow( "later owner failed", ); unsubscribeFailure(); @@ -150,7 +151,7 @@ describe("mutable provider config owner", () => { } }); - test("does no provider work for unrelated CORS, Git, and app URL reloads", () => { + test("does no provider work for unrelated CORS, Git, and app URL reloads", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-")); tempDirs.push(paseoHome); const initial: PersistedConfig = { @@ -174,9 +175,10 @@ describe("mutable provider config owner", () => { }); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - providerOverrides: { codex: { enabled: true } }, + providerOverrides: CONTROLLED_PROVIDERS, + extraClients: { codex: controlledCodexClient(async () => catalog("initial")) }, }); - manager.getSnapshot("/tmp/provider-config-owner"); + await manager.refreshSnapshotForCwd({ cwd: "/tmp/provider-config-owner" }); const events: string[] = []; manager.on("change", ({ current }) => events.push(current.cwd)); let registryUpdates = 0; @@ -200,7 +202,7 @@ describe("mutable provider config owner", () => { }; writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8"); - expect(store.reload().appliedPaths).toEqual([ + expect((await store.reload()).appliedPaths).toEqual([ "app.baseUrl", "daemon.cors.allowedOrigins", "daemon.git.maxProcessConcurrency", @@ -245,7 +247,7 @@ describe("mutable provider config owner", () => { await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1)); expect(events).toEqual([]); - store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } }); + await store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } }); expect(events).toEqual(["loading:none"]); await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2)); @@ -311,9 +313,9 @@ describe("mutable provider config owner", () => { expect(events).toEqual([]); const before = manager.getSnapshot(cwd); - expect(() => + await expect( store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }), - ).toThrow("later owner failed"); + ).rejects.toThrow("later owner failed"); expect(events).toEqual([]); expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]); expect(observedDuringApply[0]!.snapshot).toBe(before); diff --git a/packages/server/src/server/agent/permission-response.test.ts b/packages/server/src/server/agent/permission-response.test.ts index a5cf477e68..710599c588 100644 --- a/packages/server/src/server/agent/permission-response.test.ts +++ b/packages/server/src/server/agent/permission-response.test.ts @@ -32,7 +32,7 @@ class FakePermissionAgentManager { return this.permissionResult; } - tryRunOutOfBand(): boolean { + async tryRunOutOfBand(): Promise { return this.outOfBandHandled; } @@ -40,6 +40,8 @@ class FakePermissionAgentManager { return undefined; } + async reloadStalePluginSession(): Promise {} + hasInFlightRun(): boolean { return this.hasRunInFlight; } diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index aa0aa1a924..a9cf3b0ed5 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1,4 +1,11 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentStorage } from "./agent-storage.js"; +import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js"; import { relative } from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import type { ProviderConnection, ProviderLaunch, @@ -20,6 +27,21 @@ import { StaleProviderSessionError, } from "./stale-provider-session-error.js"; +test("long concurrent plugin and child streams retain text-sized history and release it on archive", () => { + const fixture = fileURLToPath(new URL("./test-utils/plugin-stream-memory.ts", import.meta.url)); + const output = execFileSync( + process.execPath, + ["--expose-gc", "--max-old-space-size=512", "--import", "tsx", fixture], + { + encoding: "utf8", + timeout: 20_000, + }, + ); + const memory = JSON.parse(output) as { retained: number; closed: number }; + expect(memory.retained).toBeLessThan(24 * 1024 * 1024); + expect(memory.closed).toBeLessThan(6 * 1024 * 1024); +}); + const CAPABILITIES = [ "prompt.message", "session.configure", @@ -31,6 +53,7 @@ const CAPABILITIES = [ interface ProviderHarnessOptions { capabilities?: ProviderConnection["capabilities"]; completeTurn?: boolean; + closeAcknowledgementOnly?: boolean; openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void; handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise; } @@ -186,6 +209,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) { } if (input.type === "session.close") { emit({ type: "session.closed", sessionId: input.sessionId }); + if (options.closeAcknowledgementOnly) return; } if ("requestId" in input) { emit({ type: "request.completed", requestId: input.requestId }); @@ -224,6 +248,572 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"] return events.filter((event) => event.type === type); } +function createRewindProviderHarness( + options: { + reuseAnswerId?: boolean; + forkOnRevert?: boolean; + handleInput?: ProviderHarnessOptions["handleInput"]; + } = {}, +) { + const nativeMessages: string[] = []; + let originalMessages: string[] | null = null; + const harness = createProviderHarness({ + closeAcknowledgementOnly: true, + capabilities: [ + ...CAPABILITIES, + "session.revert.conversation", + "session.revert.files", + "session.revert.both", + ], + async handleInput(input, emit) { + if (await options.handleInput?.(input, emit)) return true; + if (input.type === "session.open" && input.history === "replay") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: [ + ...CAPABILITIES, + "session.revert.conversation", + "session.revert.files", + "session.revert.both", + ], + restoration: "core", + persistence: input.persistence ?? { version: 1, data: { token: "root" } }, + cwd: input.config.cwd, + }); + const messages = + options.forkOnRevert && + (input.persistence?.data as { token?: string })?.token === "root" && + originalMessages + ? originalMessages + : nativeMessages; + for (const [index, text] of messages.entries()) { + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "user_message", id: `user-${text}`, text, revertToken: text }, + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "assistant_message", + id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`, + text: `Reply ${text}`, + }, + }); + } + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return true; + } + if (input.type === "session.prompt") { + if (input.prompt.input.type !== "message") throw new Error("Expected a message"); + const text = input.prompt.input.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join(""); + nativeMessages.push(text); + const turnId = `turn-${text}`; + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId }, + }); + emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "user_message", + id: `user-${text}`, + text, + clientMessageId: input.prompt.clientMessageId, + revertToken: text, + }, + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "assistant_message", + id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`, + text: `Reply ${text}`, + }, + }); + emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" }); + return true; + } + if (input.type === "session.revert") { + if (options.forkOnRevert && input.scope !== "files") { + originalMessages = [...nativeMessages]; + emit({ + type: "session.persistence", + sessionId: input.sessionId, + persistence: { version: 1, data: { token: "fork" } }, + }); + } + if (input.scope !== "files") + nativeMessages.splice(nativeMessages.indexOf(String(input.token))); + emit({ type: "request.completed", requestId: input.requestId }); + return true; + } + return false; + }, + }); + return { ...harness, nativeMessages }; +} + +async function withRewindAgent( + run: (context: { + manager: AgentManager; + agentId: string; + nativeMessages: string[]; + messages: () => string[]; + }) => Promise, + options: Parameters[0] = {}, +) { + const harness = createRewindProviderHarness(options); + const logger = createTestLogger(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([harness.registration]); + const manager = new AgentManager({ + logger, + clients: registry.clients(), + providerDefinitions: registry.definitions(), + }); + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: tmpdir() }, + undefined, + { workspaceId: undefined }, + ); + const messages = () => + manager + .getTimeline(agent.id) + .flatMap((item) => + item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [], + ); + try { + await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" }); + await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" }); + await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" }); + expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]); + await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages }); + } finally { + await manager.closeAgent(agent.id); + await registry.shutdown(); + } +} + +test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => { + await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => { + await manager.rewind(agentId, "user-B", "conversation"); + const afterRewind = messages(); + expect(nativeMessages).toEqual(["A"]); + await manager.runAgent(agentId, "D", { clientMessageId: "client-D" }); + const afterReplacement = messages(); + expect(nativeMessages).toEqual(["A", "D"]); + expect({ afterRewind, afterReplacement }).toEqual({ + afterRewind: ["A", "Reply A"], + afterReplacement: ["A", "Reply A", "D", "Reply D"], + }); + }); +}); + +test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => { + await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => { + await manager.rewind(agentId, "user-B", "files"); + expect(nativeMessages).toEqual(["A", "B", "C"]); + expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]); + await manager.runAgent(agentId, "D", { clientMessageId: "client-D" }); + expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]); + }); +}); + +test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => { + const harness = createRewindProviderHarness({ reuseAnswerId: true }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() }); + const live: AgentStreamEvent[] = []; + try { + await session.run("A"); + await session.run("B"); + session.subscribe((event) => live.push(event)); + await session.revertConversation!({ messageId: "user-B" }); + live.length = 0; + await session.run("B revised"); + const assistant = live.flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], + ); + expect(harness.nativeMessages).toEqual(["A", "B revised"]); + expect(assistant).toEqual(["Reply B revised"]); + } finally { + await session.close(); + await registry.shutdown(); + } +}); + +async function withPluginRewindSession( + options: Parameters[0], + run: ( + session: Awaited>, + harness: ReturnType, + ) => Promise, +) { + const harness = createRewindProviderHarness(options); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() }); + try { + await session.run("A"); + await session.run("B"); + await session.run("C"); + await run(session, harness); + } finally { + await session.close(); + await registry.shutdown(); + } +} + +async function pluginMessages(session: Awaited>) { + const events = await Array.fromAsync(session.streamHistory()); + return events.flatMap((event) => + event.type === "timeline" && + (event.item.type === "user_message" || event.item.type === "assistant_message") + ? [event.item.text] + : [], + ); +} + +test("combined plugin rewind replays the retained native branch", async () => { + await withPluginRewindSession({}, async (session) => { + await session.revertBoth!({ messageId: "user-B" }); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + await session.revertConversation!({ messageId: "user-A" }); + expect(await pluginMessages(session)).toEqual([]); + }); +}); + +test("plugin rewind resumes the latest persistence rather than the original handle", async () => { + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type === "session.revert") + emit({ + type: "session.persistence", + sessionId: input.sessionId, + persistence: { version: 1, data: { token: "fork" } }, + }); + return false; + }, + }, + async (session, harness) => { + await session.revertConversation!({ messageId: "user-B" }); + expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({ + persistence: { version: 1, data: { token: "fork" } }, + history: "replay", + }); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + }, + ); +}); + +test("failed native plugin rewind preserves history and does not reopen", async () => { + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.revert") return false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "native rewind failed" }, + }); + return true; + }, + }, + async (session, harness) => { + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "native rewind failed", + ); + expect(await pluginMessages(session)).toEqual([ + "A", + "Reply A", + "B", + "Reply B", + "C", + "Reply C", + ]); + expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1); + }, + ); +}); + +test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => { + let failReplay = true; + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.open" || input.history !== "replay" || !failReplay) + return false; + failReplay = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "replay failed" }, + }); + return true; + }, + }, + async (session, harness) => { + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "replay failed", + ); + await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload"); + await session.revertConversation!({ messageId: "user-B" }); + await session.run("D"); + expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1); + }, + ); +}); + +test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => { + let failReplay = true; + await withRewindAgent( + async ({ manager, agentId, messages }) => { + await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow( + "replay failed", + ); + await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload"); + await manager.rewind(agentId, "user-B", "conversation"); + expect(messages()).toEqual(["A", "Reply A"]); + await manager.runAgent(agentId, "D"); + expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]); + }, + { + handleInput: async (input, emit) => { + if (input.type !== "session.open" || input.history !== "replay" || !failReplay) + return false; + failReplay = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "replay failed" }, + }); + return true; + }, + }, + ); +}); + +test("reload after failed plugin rewind replay resumes the committed fork", async () => { + let failReplay = true; + await withRewindAgent( + async ({ manager, agentId, messages }) => { + await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow( + "replay failed", + ); + await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true }); + await manager.hydrateTimelineFromProvider(agentId, { force: true }); + expect(messages()).toEqual(["A", "Reply A"]); + }, + { + forkOnRevert: true, + handleInput: async (input, emit) => { + if (input.type === "session.close") { + emit({ type: "request.completed", requestId: input.requestId }); + return true; + } + if (input.type !== "session.open" || input.history !== "replay" || !failReplay) + return false; + failReplay = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "replay failed" }, + }); + return true; + }, + }, + ); +}); + +test("closing during plugin rewind replay closes each native binding once", async () => { + let opened!: () => void; + let release!: () => void; + const replayStarted = new Promise((resolve) => { + opened = resolve; + }); + const replayAllowed = new Promise((resolve) => { + release = resolve; + }); + await withPluginRewindSession( + { + handleInput: async (input) => { + if (input.type === "session.open" && input.history === "replay") { + opened(); + await replayAllowed; + } + return false; + }, + }, + async (session, harness) => { + const rewind = session.revertConversation!({ messageId: "user-B" }); + await replayStarted; + const closing = session.close(); + release(); + await Promise.all([rewind, closing]); + const closes = harness.inputs.flatMap((input) => + input.type === "session.close" ? [input.sessionId] : [], + ); + expect(closes).toHaveLength(2); + expect(new Set(closes).size).toBe(2); + }, + ); +}); + +test("plugin rewind retries a failed close before reopening the native writer", async () => { + let failClose = true; + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.close" || !failClose) return false; + failClose = false; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "close failed" }, + }); + return true; + }, + }, + async (session, harness) => { + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "close failed", + ); + expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1); + }, + ); +}); + +test("plugin rewind retries permission settlement before retiring its session", async () => { + let failPermission = true; + await withPluginRewindSession( + { + handleInput: async (input) => { + if (input.type !== "session.permission" || !failPermission) return false; + failPermission = false; + throw new Error("permission delivery failed"); + }, + }, + async (session, harness) => { + const opened = harness.inputs.find((input) => input.type === "session.open")!; + if (opened.type !== "session.open") throw new Error("Missing root"); + harness.emit({ + type: "session.permission", + sessionId: opened.sessionId, + request: { id: "pending", name: "tool", kind: "tool" }, + }); + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "permission delivery failed", + ); + expect(session.getPendingPermissions()).toHaveLength(1); + expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0); + expect(await pluginMessages(session)).toEqual(["A", "Reply A"]); + expect(session.getPendingPermissions()).toEqual([]); + expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2); + }, + ); +}); + +test("plugin rewind replaces child listeners and settles old pending permissions", async () => { + await withPluginRewindSession({}, async (session, harness) => { + const root = harness.inputs.find((input) => input.type === "session.open")!; + if (root.type !== "session.open") throw new Error("Missing root"); + harness.emit({ + type: "session.permission", + sessionId: root.sessionId, + request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" }, + }); + expect(session.getPendingPermissions()).toHaveLength(1); + await session.revertConversation!({ messageId: "user-B" }); + expect(session.getPendingPermissions()).toEqual([]); + expect(harness.inputs).toContainEqual( + expect.objectContaining({ + type: "session.permission", + permissionId: "old-permission", + response: { behavior: "deny", message: "Session replaced after rewind" }, + }), + ); + harness.emit({ + type: "timeline.item", + sessionId: "child-1", + item: { type: "assistant_message", id: "child-message", text: "Discarded child update" }, + }); + const history = await Array.fromAsync(session.streamHistory()); + expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]); + const reopened = harness.inputs.findLast((input) => input.type === "session.open")!; + if (reopened.type !== "session.open") throw new Error("Missing reopened root"); + harness.emit({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: reopened.sessionId, + capabilities: [], + restoration: "parent", + cwd: tmpdir(), + }); + harness.emit({ + type: "timeline.item", + sessionId: "child-1", + item: { type: "assistant_message", id: "child-message", text: "New child reply" }, + }); + const next = await Array.fromAsync(session.streamHistory()); + expect(next).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ text: "New child reply" }), + }), + }), + ); + }); +}); + +test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => { + await withPluginRewindSession( + { + handleInput: async (input, emit) => { + if (input.type !== "session.open") return false; + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"], + restoration: "core", + cwd: input.config.cwd, + }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return true; + }, + }, + async (session, harness) => { + expect(session.capabilities.supportsRewindConversation).toBe(false); + await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow( + "requires session persistence", + ); + expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]); + await session.revertFiles!({ messageId: "user-B" }); + expect(harness.nativeMessages).toEqual(["A", "B", "C"]); + }, + ); +}); + function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) { for (const [sessionId, parentSessionId] of [ ["a", rootSessionId], @@ -287,6 +877,99 @@ function expectNestedChildren(events: AgentStreamEvent[]) { } describe("PluginAgentClientRegistry", () => { + test("history keeps the tool's first position when completion follows later text", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: client.provider, cwd: "/tmp" }); + const opened = harness.inputs.find((input) => input.type === "session.open"); + if (opened?.type !== "session.open") throw new Error("missing session open"); + const tool = { + type: "tool_call" as const, + id: "tool-position", + callId: "call-position", + name: "read", + detail: { type: "unknown" as const, input: null, output: null }, + error: null, + }; + try { + harness.emit({ + type: "timeline.item", + sessionId: opened.sessionId, + item: { ...tool, status: "running" }, + }); + harness.emit({ + type: "timeline.item", + sessionId: opened.sessionId, + item: { type: "assistant_message", id: "later-text", text: "Working" }, + }); + harness.emit({ + type: "timeline.item", + sessionId: opened.sessionId, + item: { ...tool, status: "completed" }, + }); + const items = []; + for await (const event of session.streamHistory()) + if (event.type === "timeline") items.push(event.item); + expect(items).toEqual([ + { + type: "tool_call", + callId: "call-position", + name: "read", + detail: tool.detail, + error: null, + status: "completed", + }, + { type: "assistant_message", messageId: "later-text", text: "Working" }, + ]); + } finally { + await session.close(); + await registry.shutdown(); + } + }); + + test("detached text deltas preserve split surrogates and history replays one complete message", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: client.provider, cwd: "/tmp" }); + const opened = harness.inputs.find((input) => input.type === "session.open"); + if (opened?.type !== "session.open") throw new Error("missing session open"); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + const prefix = "long prefix ".repeat(10); + const first = prefix + "a\uD83D"; + const complete = first + "\uDE00b"; + try { + for (const text of [first, complete, complete]) { + harness.emit({ + type: "timeline.item", + sessionId: opened.sessionId, + item: { type: "assistant_message", id: "split-message", text }, + }); + } + const deltas = events.flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" + ? [event.item.text] + : [], + ); + expect(deltas).toEqual([first, "\uDE00b"]); + expect(deltas.join("")).toBe(prefix + "a😀b"); + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) history.push(event); + expect( + history.flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" ? [event.item] : [], + ), + ).toEqual([{ type: "assistant_message", messageId: "split-message", text: complete }]); + } finally { + await session.close(); + await registry.shutdown(); + } + }); + test("stores only agent options while the plugin receives merged defaults", async () => { const logger = createTestLogger(); const harness = createProviderHarness(); @@ -305,7 +988,7 @@ describe("PluginAgentClientRegistry", () => { }); const own = { nested: { replace: "agent" } }; const agent = await manager.createAgent( - { provider: "plugin-direct", cwd: "/tmp", providerOptions: own }, + { provider: "plugin-direct", cwd: tmpdir(), providerOptions: own }, undefined, { workspaceId: undefined }, ); @@ -481,6 +1164,137 @@ describe("PluginAgentClientRegistry", () => { } }); + test.each(["completed", "canceled", "failed"] as const)( + "reports a reused plugin child as running until its next turn is %s", + async (outcome) => { + const logger = createTestLogger(); + const harness = createProviderHarness(); + const plugins = new PluginAgentClientRegistry(logger); + plugins.replace([harness.registration]); + const registry = buildProviderRegistry(logger, { pluginProviders: plugins.definitions() }); + const manager = new AgentManager({ + logger, + clients: { "plugin-direct": registry["plugin-direct"].createClient(logger) }, + providerDefinitions: { "plugin-direct": registry["plugin-direct"] }, + }); + try { + const agent = await manager.createAgent( + { provider: "plugin-direct", cwd: tmpdir() }, + undefined, + { workspaceId: undefined }, + ); + const open = harness.inputs.find((input) => input.type === "session.open")!; + harness.emit({ + type: "session.opened", + sessionId: "reusable-child", + parentSessionId: open.sessionId, + capabilities: [], + restoration: "parent", + title: "Plugin child", + cwd: tmpdir(), + }); + harness.emit({ + type: "session.turn", + sessionId: "reusable-child", + turnId: "first-turn", + state: "started", + }); + harness.emit({ + type: "session.turn", + sessionId: "reusable-child", + turnId: "first-turn", + state: "completed", + }); + await nextTurn(); + const [child] = manager.listProviderSubagents(agent.id); + expect(child).toMatchObject({ + id: "reusable-child", + title: "Plugin child", + status: "completed", + }); + + harness.emit({ + type: "session.turn", + sessionId: "reusable-child", + turnId: "second-turn", + state: "started", + }); + await nextTurn(); + expect(manager.listProviderSubagents(agent.id)).toMatchObject([ + { id: child.id, title: child.title, status: "running" }, + ]); + + harness.emit({ + type: "session.turn", + sessionId: "reusable-child", + turnId: "second-turn", + state: outcome, + }); + await nextTurn(); + expect(manager.listProviderSubagents(agent.id)).toMatchObject([ + { id: child.id, title: child.title, status: outcome }, + ]); + } finally { + await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))); + await plugins.shutdown(); + } + }, + ); + + test.each(["started", "completed", "canceled", "failed"] as const)( + "replays a reused plugin child's latest turn state: %s", + async (state) => { + const harness = createProviderHarness({ + openChildren(rootSessionId, emit) { + emit({ + type: "session.opened", + sessionId: "reusable-child", + parentSessionId: rootSessionId, + capabilities: [], + restoration: "parent", + cwd: "/workspace", + }); + for (const [turnId, turnState] of [ + ["first-turn", "started"], + ["first-turn", "completed"], + ["second-turn", "started"], + ] as const) { + emit({ type: "session.turn", sessionId: "reusable-child", turnId, state: turnState }); + } + if (state !== "started") { + emit({ + type: "session.turn", + sessionId: "reusable-child", + turnId: "second-turn", + state, + }); + } + }, + }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + try { + const session = await registry.clients()[harness.registration.id]!.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + const statuses = []; + for await (const event of session.streamHistory()) { + if ( + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.id === "reusable-child" + ) { + statuses.push(event.event.status); + } + } + expect(statuses.at(-1)).toBe(state === "started" ? "running" : state); + } finally { + await registry.shutdown(); + } + }, + ); + test("gates persistence operations on negotiated provider capabilities", async () => { const harness = createProviderHarness({ capabilities: ["session.persistence"] }); const registry = new PluginAgentClientRegistry(createTestLogger()); @@ -1234,3 +2048,110 @@ test("config-required plugin membership is independent of enablement and other p await plugins.shutdown(); } }); + +test.each([true, false, "error", "wrong-session", "missing"] as const)( + "plugin runtime safety query: %s", + async (answer) => { + const harness = createProviderHarness({ + capabilities: + answer === "missing" ? CAPABILITIES : [...CAPABILITIES, "session.can_stop_runtime"], + handleInput: async (input, emit) => { + if (input.type !== "session.can_stop_runtime") return false; + if (answer === "error") + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "Probe failed" }, + }); + else + emit({ + type: "session.can_stop_runtime", + requestId: input.requestId, + sessionId: answer === "wrong-session" ? "child-1" : input.sessionId, + canStop: answer === true, + }); + return true; + }, + }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" }); + try { + if (answer === "error" || answer === "wrong-session") { + await expect(session.canStopRuntime!()).rejects.toThrow( + answer === "error" ? "Probe failed" : "another session", + ); + } else if (answer === "missing") expect(session.canStopRuntime).toBeUndefined(); + else expect(await session.canStopRuntime!()).toBe(answer === true); + const requests = harness.inputs.filter((input) => input.type === "session.can_stop_runtime"); + expect(requests).toHaveLength(answer === "missing" ? 0 : 1); + if (answer !== "missing") + expect(requests[0]).toMatchObject({ + sessionId: harness.inputs.find((input) => input.type === "session.open")?.sessionId, + }); + } finally { + await session.close(); + await registry.shutdown(); + } + }, +); + +test("plugin without a runtime safety capability uses baseline collection without a query", async () => { + const home = await mkdtemp(join(tmpdir(), "plugin-baseline-cleanup-")); + const logger = createTestLogger(); + const storage = new AgentStorage(join(home, "agents"), logger); + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([harness.registration]); + const manager = new AgentManager({ clients: registry.clients(), registry: storage, logger }); + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: home }, + undefined, + { workspaceId: undefined }, + ); + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + logger, + runtimes: () => manager.getRuntimeCleanupCandidates(), + now: () => agent.updatedAt.getTime() + 60 * 60_000, + schedule: () => () => {}, + }); + try { + expect(agent.session?.canStopRuntime).toBeUndefined(); + expect((await cleanup.sweep()).collected).toHaveLength(1); + expect(manager.getAgent(agent.id)).toBeNull(); + expect(harness.inputs.filter((input) => input.type === "session.can_stop_runtime")).toEqual([]); + } finally { + await cleanup.close(); + await Promise.all(manager.listAgents().map((item) => manager.closeAgent(item.id))); + await registry.shutdown(); + await storage.flush(); + await rm(home, { recursive: true, force: true }); + } +}); + +test("plugin provider import launches with runtime MCP servers and returns only saved configuration", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const storedConfig = { provider: harness.registration.id, cwd: "/project" }; + const company = { type: "http", url: "https://tools.example.com/mcp" } as const; + try { + const imported = await client.importSession!( + { providerHandleId: 'plugin:{"version":1,"data":{"token":"root"}}', cwd: "/project" }, + { config: { ...storedConfig, mcpServers: { company } }, storedConfig }, + ); + expect(harness.inputs).toContainEqual( + expect.objectContaining({ + type: "session.open", + config: expect.objectContaining({ mcpServers: { company } }), + }), + ); + expect(imported.config).toEqual(storedConfig); + await imported.session.close(); + } finally { + await registry.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index db5d7651a2..2d20056930 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { PluginSessionHistory } from "./plugin-session-history.js"; import { resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; @@ -113,9 +114,15 @@ function providerError(error: ProviderError): Error { function isProviderRequestReply( event: ProviderEvent, -): event is Extract { +): event is Extract< + ProviderEvent, + { type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" } +> { return ( - event.type === "request.completed" || event.type === "catalog" || event.type === "sessions" + event.type === "request.completed" || + event.type === "catalog" || + event.type === "sessions" || + event.type === "session.can_stop_runtime" ); } @@ -564,10 +571,21 @@ class ProviderRuntime { } private finishRequest( - event: Extract, + event: Extract< + ProviderEvent, + { type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" } + >, ): void { const request = this.requests.get(event.requestId); if (!request) return; + if ( + event.type === "session.can_stop_runtime" && + this.providerSessions.get(event.sessionId)?.id !== request.sessionId + ) { + this.requests.delete(event.requestId); + request.reject(new Error("Runtime safety response belongs to another session")); + return; + } this.requests.delete(event.requestId); request.resolve(event); } @@ -575,6 +593,7 @@ class ProviderRuntime { class ProviderRuntimeSession { readonly history: ProviderEvent[] = []; + private historyClaimed = false; private readonly listeners = new Set<(event: ProviderEvent) => void>(); private capabilities: readonly string[] = []; private openRequest: { requestId: string; deferred: Deferred } | null = null; @@ -584,6 +603,7 @@ class ProviderRuntimeSession { >(); private readonly activeTurnIds = new Set(); private terminal = false; + private closePromise: Promise | null = null; config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] }; commands: Array<{ name: string; description: string; argumentHint?: string }> = []; persistence: ProviderPersistence | null = null; @@ -605,6 +625,10 @@ class ProviderRuntimeSession { } onEvent(listener: (event: ProviderEvent) => void): () => void { + // This buffer bridges session.open and adapter attachment. Once replayed, + // the adapter owns history; retaining live cumulative snapshots is quadratic. + this.historyClaimed = true; + this.history.length = 0; this.listeners.add(listener); return () => this.listeners.delete(listener); } @@ -635,6 +659,27 @@ class ProviderRuntimeSession { } } + async canStopRuntime(): Promise { + if ( + this.terminal || + this.prompts.size || + this.activeTurnIds.size || + !this.capabilities.includes("session.can_stop_runtime") + ) + return false; + const event = await this.runtime.complete( + { + type: "session.can_stop_runtime", + requestId: randomUUID(), + sessionId: this.providerSessionId, + }, + AbortSignal.timeout(10_000), + ); + if (event.type !== "session.can_stop_runtime") + throw new Error("Invalid runtime safety response"); + return event.canStop; + } + async configure(changes: ProviderConfigChanges): Promise { const input: Extract = { type: "session.configure", @@ -675,10 +720,19 @@ class ProviderRuntimeSession { } async close(): Promise { + this.history.length = 0; if (this.restoration === "parent") { this.runtime.removeSession(this.id, this.providerSessionId); return; } + this.closePromise ??= this.closeNativeSession().catch((error) => { + this.closePromise = null; + throw error; + }); + await this.closePromise; + } + + private async closeNativeSession(): Promise { try { await this.runtime.complete({ type: "session.close", @@ -687,9 +741,9 @@ class ProviderRuntimeSession { }); } catch (error) { if (!this.runtime.isClosed) throw error; - } finally { - this.runtime.removeSession(this.id, this.providerSessionId); } + // A failed close still needs its routing for a subsequent session.closed ack. + this.runtime.removeSession(this.id, this.providerSessionId); } beginOpen(requestId: string): Promise { @@ -788,7 +842,7 @@ class ProviderRuntimeSession { if (event.type === "session.persistence" && this.restoration === "core") { this.persistence = event.persistence; } - this.history.push(event); + if (!this.historyClaimed) this.history.push(event); for (const listener of this.listeners) listener(event); } } @@ -1014,7 +1068,7 @@ class PluginAgentClient implements AgentClient { if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`); return { session, - config: { ...context.config, provider: this.provider, cwd: input.cwd }, + config: { ...context.storedConfig, provider: this.provider, cwd: input.cwd }, persistence, timeline: session.timelineHistory(), providerSubagentEvents: session.subagentHistory(), @@ -1047,11 +1101,40 @@ class PluginAgentClient implements AgentClient { persistence: input.persistence, history: input.history, }); - const session = new PluginAgentSession(this.provider, bridge, () => { - this.rootsBySession.delete(bridge.id); - }); - this.rootsBySession.set(bridge.id, session); - this.attachPendingChildren(); + const forget = () => { + for (const [id, owner] of this.rootsBySession) { + if (owner === session) this.rootsBySession.delete(id); + } + }; + const bind = (next: ProviderRuntimeSession) => { + this.rootsBySession.set(next.id, session); + this.attachPendingChildren(); + }; + const config = mapSessionConfig(input.config, input.launchContext, input.persist); + const session = new PluginAgentSession( + this.provider, + bridge, + forget, + async (persistence, state) => { + forget(); + return await this.runtime.openSession({ + sessionId: randomUUID(), + config: { + ...config, + model: state.model, + mode: state.mode, + thinkingOption: state.thinkingOption, + settings: Object.fromEntries( + state.settings.map((setting) => [setting.id, setting.value]), + ), + }, + persistence, + history: "replay", + }); + }, + bind, + ); + bind(bridge); return session; } @@ -1090,25 +1173,54 @@ class PluginAgentClient implements AgentClient { } } +interface TimelineTextSnapshot { + type: "assistant_message" | "reasoning" | "user_message"; + text: string; +} + +function isTextSnapshotItem( + item: ProviderTimelineItem, +): item is Extract { + return ( + item.type === "assistant_message" || item.type === "reasoning" || item.type === "user_message" + ); +} + class PluginAgentSession implements AgentSession { readonly initialTimeline: ImportedProviderSession["timeline"]; private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); - private readonly history: AgentStreamEvent[] = []; + private readonly history = new PluginSessionHistory(); private readonly pendingPermissions = new Map(); private readonly permissionResponses = new Map(); private readonly revertTokens = new Map(); - private readonly timelineSnapshots = new Map(); + private readonly timelineSnapshots = new Map(); private readonly subagentIdsBySession = new Map(); - private readonly childUnsubscribes = new Map void>(); - private readonly childSnapshots = new Map>(); + private readonly children = new Map< + string, + { session: ProviderRuntimeSession; unsubscribe: () => void } + >(); + private readonly childSnapshots = new Map>(); private unsubscribe: (() => void) | null = null; private currentTurnId: string | null = null; private closed = false; + private replay: { + persistence: ProviderPersistence; + config: ProviderConfigState; + messageId: string; + scope: "conversation" | "both"; + closed: boolean; + } | null = null; + private replaying: Promise | null = null; constructor( readonly provider: string, - private readonly bridge: ProviderRuntimeSession, + private bridge: ProviderRuntimeSession, private readonly onClose: () => void, + private readonly reopen: ( + persistence: ProviderPersistence, + state: ProviderConfigState, + ) => Promise, + private readonly onBind: (bridge: ProviderRuntimeSession) => void, ) { this.subagentIdsBySession.set(bridge.id, null); for (const event of bridge.history) this.accept(event, false); @@ -1125,7 +1237,13 @@ class PluginAgentSession implements AgentSession { } get capabilities(): AgentCapabilityFlags { - return agentCapabilities(this.bridge.negotiatedCapabilities); + const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities); + const resumable = this.canReplayHistory; + return { + ...capabilities, + supportsRewindConversation: capabilities.supportsRewindConversation && resumable, + supportsRewindBoth: capabilities.supportsRewindBoth && resumable, + }; } get features(): AgentFeature[] { @@ -1153,6 +1271,7 @@ class PluginAgentSession implements AgentSession { prompt: AgentPromptInput, options: AgentRunOptions = {}, ): Promise<{ turnId: string }> { + this.assertReplayComplete(); const clientMessageId = options.clientMessageId ?? randomUUID(); const result = await this.bridge.prompt({ clientMessageId, @@ -1175,6 +1294,7 @@ class PluginAgentSession implements AgentSession { prompt: AgentPromptInput, options: SteerActiveTurnOptions, ): Promise { + this.assertReplayComplete(); if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) { return { status: "unavailable" }; } @@ -1198,7 +1318,13 @@ class PluginAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { - for (const event of this.history) yield event; + await this.ensureReplay(); + for (const event of this.history.read(this.provider)) yield event; + } + + get canStopRuntime(): (() => Promise) | undefined { + if (!this.bridge.negotiatedCapabilities.includes("session.can_stop_runtime")) return undefined; + return () => this.bridge.canStopRuntime(); } async getRuntimeInfo() { @@ -1220,6 +1346,7 @@ class PluginAgentSession implements AgentSession { } async setMode(modeId: string): Promise { + this.assertReplayComplete(); await this.bridge.configure({ mode: modeId }); } @@ -1256,11 +1383,10 @@ class PluginAgentSession implements AgentSession { async close(): Promise { if (this.closed) return; this.closed = true; - this.unsubscribe?.(); - this.unsubscribe = null; - for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe(); - this.childUnsubscribes.clear(); - this.subagentIdsBySession.clear(); + this.listeners.clear(); + await this.replaying?.catch(() => undefined); + await this.clearBinding(); + this.replay = null; this.listeners.clear(); this.onClose(); await this.bridge.close(); @@ -1275,14 +1401,17 @@ class PluginAgentSession implements AgentSession { } async setModel(modelId: string | null): Promise { + this.assertReplayComplete(); await this.bridge.configure({ model: modelId }); } async setThinkingOption(thinkingOptionId: string | null): Promise { + this.assertReplayComplete(); await this.bridge.configure({ thinkingOption: thinkingOptionId }); } async setFeature(featureId: string, value: unknown): Promise { + this.assertReplayComplete(); await this.bridge.configure({ settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) }, }); @@ -1301,16 +1430,20 @@ class PluginAgentSession implements AgentSession { } timelineHistory(): ImportedProviderSession["timeline"] { - return this.history.flatMap((event) => - event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [], - ); + return this.history + .read(this.provider) + .flatMap((event) => + event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [], + ); } subagentHistory(): NonNullable { - return this.history.filter( - (event): event is Extract => - event.type === "provider_subagent", - ); + return this.history + .read(this.provider) + .filter( + (event): event is Extract => + event.type === "provider_subagent", + ); } attachChild( @@ -1339,13 +1472,13 @@ class PluginAgentSession implements AgentSession { cwd: opened.cwd, }, }); - const snapshots = new Map(); + const snapshots = new Map(); this.childSnapshots.set(childId, snapshots); for (const event of child.history) this.acceptChildEvent(childId, event, snapshots); - this.childUnsubscribes.set( - child.id, - child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)), - ); + this.children.set(child.id, { + session: child, + unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)), + }); } private accept(event: ProviderEvent, live: boolean): void { @@ -1525,7 +1658,7 @@ class PluginAgentSession implements AgentSession { private acceptChildEvent( childId: string, event: ProviderEvent, - snapshots: Map, + snapshots: Map, ): void { if (event.type === "timeline.item") { const item = mapTimelineItem(event.item, snapshots); @@ -1538,11 +1671,15 @@ class PluginAgentSession implements AgentSession { } return; } - if (event.type === "session.turn" && event.state !== "started") { + if (event.type === "session.turn") { this.publish({ type: "provider_subagent", provider: this.provider, - event: { type: "upsert", id: childId, status: event.state }, + event: { + type: "upsert", + id: childId, + status: event.state === "started" ? "running" : event.state, + }, }); return; } @@ -1559,7 +1696,84 @@ class PluginAgentSession implements AgentSession { } } + private async clearBinding(): Promise { + this.unsubscribe?.(); + this.unsubscribe = null; + for (const child of this.children.values()) { + child.unsubscribe(); + await child.session.close(); + } + this.children.clear(); + this.subagentIdsBySession.clear(); + this.history.clear(); + this.timelineSnapshots.clear(); + this.childSnapshots.clear(); + this.revertTokens.clear(); + this.pendingPermissions.clear(); + this.permissionResponses.clear(); + this.currentTurnId = null; + } + + private assertReplayComplete(): void { + if (this.replay) { + throw new Error( + "Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.", + ); + } + } + + // A native rewind has already committed. Until replay succeeds, neither history + // nor a new prompt may use the retired binding. Keep its latest handle for retry. + private async ensureReplay(): Promise { + if (!this.replay) return; + if (this.closed) throw new StaleProviderSessionError(this.id); + if (!this.replaying) { + this.replaying = this.replayBinding().finally(() => { + this.replaying = null; + }); + } + await this.replaying; + } + + private async replayBinding(): Promise { + const replay = this.replay!; + if (!replay.closed) { + for (const request of this.pendingPermissions.values()) { + await this.respondToPermission(request.id, { + behavior: "deny", + message: "Session replaced after rewind", + }); + this.pendingPermissions.delete(request.id); + this.permissionResponses.delete(request.id); + } + await this.clearBinding(); + await this.bridge.close(); + replay.closed = true; + } + const bridge = await this.reopen(replay.persistence, replay.config); + this.bridge = bridge; + this.subagentIdsBySession.set(bridge.id, null); + for (const event of bridge.history) this.accept(event, false); + this.unsubscribe = bridge.onEvent((event) => this.accept(event, true)); + this.onBind(bridge); + this.replay = null; + } + + private get canReplayHistory(): boolean { + return ( + this.bridge.restoration === "core" && + this.bridge.persistence !== null && + this.bridge.negotiatedCapabilities.includes("session.persistence") + ); + } + private async revert(messageId: string, scope: "conversation" | "files" | "both") { + const pending = this.replay; + await this.ensureReplay(); + if (pending?.messageId === messageId && pending.scope === scope) return; + if (scope !== "files" && !this.canReplayHistory) { + throw new Error("Plugin conversation rewind requires session persistence for history replay"); + } const token = this.revertTokens.get(messageId); if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`); await this.bridge.revert({ @@ -1569,6 +1783,12 @@ class PluginAgentSession implements AgentSession { token, scope, }); + if (scope !== "files") { + const persistence = this.bridge.persistence; + if (!persistence) throw new Error("Plugin rewind removed session persistence"); + this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false }; + await this.ensureReplay(); + } } private publish(event: AgentStreamEvent): void { @@ -1674,24 +1894,24 @@ function mapPromptContent(prompt: AgentPromptInput): ProviderContent[] { function mapTimelineItem( item: ProviderTimelineItem, - snapshots: Map, + snapshots: Map, revertTokens?: Map, ): AgentTimelineItem | null { const previous = snapshots.get(item.id); - snapshots.set(item.id, item); - const { id, revertToken, ...withoutIdentity } = item; - if (revertToken !== undefined && revertTokens) { - revertTokens.set(id, revertToken); - if (item.type === "user_message" || item.type === "assistant_message") { - revertTokens.set(item.messageId ?? id, revertToken); - } + if (isTextSnapshotItem(item)) { + snapshots.set(item.id, { type: item.type, text: item.text }); } + const { id, revertToken: _revertToken, ...withoutIdentity } = item; + rememberRevertToken(item, revertTokens); if (item.type === "assistant_message" || item.type === "reasoning") { const previousText = previous?.type === item.type ? previous.text : ""; - const text = item.text.startsWith(previousText) + const suffix = item.text.startsWith(previousText) ? item.text.slice(previousText.length) : item.text; + // Match OpenCode's detachedSuffix: a sliced delta otherwise keeps its entire + // cumulative IPC snapshot alive in the projected timeline. Preserve split surrogates. + const text = Buffer.from(suffix, "utf16le").toString("utf16le"); if (text.length === 0) return null; return item.type === "assistant_message" ? { type: "assistant_message", text, messageId: item.messageId ?? id } @@ -1719,6 +1939,17 @@ function mapTimelineItem( return withoutIdentity as AgentTimelineItem; } +function rememberRevertToken( + item: ProviderTimelineItem, + tokens?: Map, +): void { + if (item.revertToken === undefined || !tokens) return; + tokens.set(item.id, item.revertToken); + if (item.type === "user_message" || item.type === "assistant_message") { + tokens.set(item.messageId ?? item.id, item.revertToken); + } +} + function persistenceHandle( provider: string, persistence: ProviderPersistence, diff --git a/packages/server/src/server/agent/plugin-session-history.ts b/packages/server/src/server/agent/plugin-session-history.ts new file mode 100644 index 0000000000..f5efd446d7 --- /dev/null +++ b/packages/server/src/server/agent/plugin-session-history.ts @@ -0,0 +1,66 @@ +import { TimelineProjection } from "./timeline-projection.js"; +import type { AgentStreamEvent } from "./agent-sdk-types.js"; + +// Replay canonical items, rather than retaining every streamed delta or tool state. +// Sequence numbers here only preserve adapter replay order, not wire cursors. +export class PluginSessionHistory { + private readonly timelines = new Map< + string, + { projection: TimelineProjection; seq: number; positions: Map } + >(); + private readonly events: Array<{ seq: number; event: AgentStreamEvent }> = []; + private seq = 0; + + push(event: AgentStreamEvent): void { + const seq = ++this.seq; + const child = + event.type === "provider_subagent" && event.event.type === "timeline" ? event.event : null; + if (event.type !== "timeline" && !child) { + this.events.push({ seq, event }); + return; + } + const key = child?.id ?? ""; + let projection = this.timelines.get(key); + if (!projection) { + projection = { projection: new TimelineProjection(), seq: 0, positions: new Map() }; + this.timelines.set(key, projection); + } + const timeline = event.type === "timeline" ? event : child!; + const row = projection.projection.append({ + seq: ++projection.seq, + item: timeline.item, + timestamp: timeline.timestamp ?? "", + ...(event.type === "timeline" && event.turnId ? { turnId: event.turnId } : {}), + }); + if (!projection.positions.has(row.seqStart)) projection.positions.set(row.seqStart, seq); + } + + read(provider: string): AgentStreamEvent[] { + const events = [...this.events]; + for (const [id, projection] of this.timelines) { + for (const row of projection.projection.getRows()) { + const timestamp = row.timestamp ? { timestamp: row.timestamp } : {}; + const event: AgentStreamEvent = id + ? { + type: "provider_subagent", + provider, + event: { type: "timeline", id, item: row.item, ...timestamp }, + } + : { + type: "timeline", + provider, + item: row.item, + ...timestamp, + ...(row.turnId ? { turnId: row.turnId } : {}), + }; + events.push({ seq: projection.positions.get(row.seqStart)!, event }); + } + } + return events.sort((a, b) => a.seq - b.seq).map(({ event }) => event); + } + + clear(): void { + this.timelines.clear(); + this.events.length = 0; + } +} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 9f85e4d37f..0f3c39868a 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -469,6 +469,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()), interrupt: () => inner.interrupt(), close: () => inner.close(), + canStopRuntime: inner.canStopRuntime?.bind(inner), listCommands: inner.listCommands?.bind(inner), setModel: inner.setModel?.bind(inner), setThinkingOption: inner.setThinkingOption?.bind(inner), diff --git a/packages/server/src/server/agent/provider-runtime.ts b/packages/server/src/server/agent/provider-runtime.ts index 398d35746d..c266614273 100644 --- a/packages/server/src/server/agent/provider-runtime.ts +++ b/packages/server/src/server/agent/provider-runtime.ts @@ -22,7 +22,12 @@ interface CreateAgentProviderRuntimeOptions { export async function createAgentProviderRuntime( options: CreateAgentProviderRuntimeOptions, ): Promise { - const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger }); + const configuredPort = + options.snapshotManager.runtimeSettings?.opencode?.env?.PASEO_OPENCODE_BRIDGE_PORT; + const port = configuredPort === undefined ? undefined : Number(configuredPort); + if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) + throw new Error("PASEO_OPENCODE_BRIDGE_PORT must be an integer between 1 and 65535"); + const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger, port }); try { await bridge.start(); const snapshotManager = new ProviderSnapshotManager({ 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 dc71c0f5c6..5c2ca55174 100644 --- a/packages/server/src/server/agent/provider-subagents/store.test.ts +++ b/packages/server/src/server/agent/provider-subagents/store.test.ts @@ -80,7 +80,7 @@ describe("ProviderSubagentStore", () => { const [row] = subagents.fetchTimeline("parent-a", "child-1").rows; expect(row?.item).toMatchObject({ type: "tool_call", - detail: { type: "shell", output: "x".repeat(64 * 1024) }, + detail: { type: "shell", output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/) }, }); }); 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 a050aa9077..7e50b32cf5 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1,6 +1,6 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -35,6 +35,7 @@ import { resolveACPModelSelection, summarizeACPRequestError, } from "./acp-agent.js"; +import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js"; import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js"; import { COPILOT_AGENT_FEATURE_OPTION, @@ -810,6 +811,46 @@ describe("ACPAgentSession terminal tools", () => { truncated: false, }); }); + + test("trims large terminal output without rescanning the retained suffix", async () => { + const session = createSession(); + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: ["-e", 'process.stdout.write("x".repeat(1024 * 1024))'], + cwd: process.cwd(), + outputByteLimit: 1024, + }); + await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId }); + + await expect( + session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }), + ).resolves.toEqual({ + output: "x".repeat(1024), + truncated: true, + exitStatus: { exitCode: 0, signal: null }, + }); + }, 1000); + + test("trims terminal output at a UTF-8 character boundary", async () => { + const session = createSession(); + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: ["-e", 'process.stdout.write("😀ab")'], + cwd: process.cwd(), + outputByteLimit: 3, + }); + await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId }); + + await expect( + session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }), + ).resolves.toEqual({ + output: "ab", + truncated: true, + exitStatus: { exitCode: 0, signal: null }, + }); + }); }); describe("mapACPUsage", () => { @@ -2593,6 +2634,72 @@ describe("ACPAgentSession slash commands", () => { }, ]); }); + + test("lists late command batches through the public ACP client", async () => { + const artifacts = path.resolve(".artifacts"); + await mkdir(artifacts, { recursive: true }); + const directory = await mkdtemp(path.join(artifacts, "acp-command-inventory-")); + const script = path.join(directory, "agent.mjs"); + await writeFile( + script, + ` +import { createInterface } from "node:readline"; +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +const help = { name: "help", description: "Show available commands" }; +const skill = { name: "xlsx", description: "Work with spreadsheets", input: { hint: "[instructions]" } }; +const publish = (commands) => send({ + jsonrpc: "2.0", method: "session/update", params: { + sessionId: "inventory-session", update: { + sessionUpdate: "available_commands_update", availableCommands: commands, + }, + }, +}); +createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: 1, agentCapabilities: {} } }); + } else if (request.method === "session/new") { + // The first batch is already cached when createSession returns. + publish([help]); + send({ jsonrpc: "2.0", id: request.id, result: { sessionId: "inventory-session" } }); + setTimeout(() => publish([help]), 100); + setTimeout(() => publish([help, skill]), 350); + } else if (request.id !== undefined) { + send({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } }); + } +}); +`, + ); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, script], + }); + try { + const session = await client.createSession({ provider: "acp", cwd: directory }); + try { + const commands = await session.listCommands!(); + expect(commands).toEqual([ + { + name: "help", + description: "Show available commands", + argumentHint: "", + kind: "command", + }, + { + name: "xlsx", + description: "Work with spreadsheets", + argumentHint: "[instructions]", + kind: "command", + }, + ]); + expect(await session.listCommands!()).toEqual(commands); + } finally { + await session.close(); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); }); describe("ACPAgentSession", () => { @@ -4424,6 +4531,161 @@ new AgentSideConnection( ); `; +describe("ACP idle runtime cleanup", () => { + test("pending permissions veto cleanup until answered", async () => { + const session = createSession(); + try { + expect(await session.canStopRuntime()).toBe(true); + const permission = session.requestPermission({ + sessionId: "session-1", + toolCall: { toolCallId: "tool-1", title: "Run command", status: "pending" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }); + expect(await session.canStopRuntime()).toBe(false); + await session.respondToPermission(session.getPendingPermissions()[0]!.id, { + behavior: "allow", + }); + await permission; + expect(await session.canStopRuntime()).toBe(true); + } finally { + await session.close(); + } + }); + + test("a failed terminal spawn does not pin the runtime or discard its diagnostic", async () => { + const session = createSession(); + try { + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: path.join(tmpdir(), "paseo-no-such-command", "missing"), + }); + await expect( + session.waitForTerminalExit({ sessionId: "session-1", ...terminal }), + ).rejects.toThrow("ENOENT"); + expect(await session.canStopRuntime()).toBe(true); + expect( + (await session.terminalOutput({ sessionId: "session-1", ...terminal })).output, + ).toContain("ENOENT"); + await session.releaseTerminal({ sessionId: "session-1", ...terminal }); + expect(await session.canStopRuntime()).toBe(true); + } finally { + await session.close(); + } + }); + + test("retains a quiet terminal after a finished turn, then waits a fresh idle hour after exit", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-idle-")); + const agentScript = path.join(dir, "agent.mjs"); + await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT); + const session = new ACPAgentSession( + { provider: "test-acp", cwd: dir }, + { + provider: "test-acp", + logger: createTestLogger(), + defaultCommand: [process.execPath, agentScript], + defaultModes: [], + capabilities: { supportsStreaming: true, supportsSessionPersistence: true }, + launchEnv: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + }, + }, + ); + const hour = 60 * 60_000; + let now = 0; + let closed = false; + const candidate = { + identity: session, + agentId: "test-agent", + parentAgentId: undefined, + provider: session.provider, + sessionId: "permissive-session", + lastActivityAt: 0, + isEligible: () => !closed, + canStopRuntime: (session as AgentSession).canStopRuntime?.bind(session), + async closeIfEligible(revalidate: () => boolean) { + if (!revalidate()) return false; + await session.close(); + closed = true; + return true; + }, + }; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + now: () => now, + runtimes: () => (closed ? [] : [candidate]), + schedule: () => () => {}, + logger: createTestLogger(), + }); + try { + await session.initializeNewSession(); + const completed = new Promise((resolve) => + session.subscribe((event) => { + if (event.type === "turn_completed") resolve(); + }), + ); + let activeTurnSafety: Promise | undefined; + session.subscribe((event) => { + if (event.type === "turn_started") activeTurnSafety = session.canStopRuntime(); + }); + await session.startTurn("Finish this foreground turn"); + await expect(activeTurnSafety).resolves.toBe(false); + await completed; + expect(await session.canStopRuntime()).toBe(true); + await session.sessionUpdate({ + sessionId: candidate.sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "buffered" }, + }, + }); + expect(await session.canStopRuntime()).toBe(false); + await session.sessionUpdate({ + sessionId: candidate.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "flush" }, + }, + }); + expect(await session.canStopRuntime()).toBe(true); + const terminal = await session.createTerminal({ + sessionId: candidate.sessionId, + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + now = 2 * hour; + expect((await cleanup.sweep()).collected).toEqual([]); + expect(closed).toBe(false); + expect(await session.terminalOutput({ sessionId: candidate.sessionId, ...terminal })).toEqual( + { + output: "", + truncated: false, + exitStatus: undefined, + }, + ); + await session.killTerminal({ sessionId: candidate.sessionId, ...terminal }); + await session.waitForTerminalExit({ sessionId: candidate.sessionId, ...terminal }); + expect((await cleanup.sweep()).collected).toEqual([]); + now += hour - 1; + expect((await cleanup.sweep()).collected).toEqual([]); + now += 1; + expect((await cleanup.sweep()).collected).toEqual([ + { + agentId: candidate.agentId, + provider: candidate.provider, + sessionId: candidate.sessionId, + }, + ]); + expect(closed).toBe(true); + } finally { + await cleanup.close(); + await session.close(); + await rm(dir, { recursive: true, force: true }); + } + }); +}); + describe("custom ACP provider with configured models", () => { let dir: string; let agentScript: string; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 823f941de1..339c491e46 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -120,6 +120,7 @@ import { } from "../provider-launch-config.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js"; +import { resolveResumeProcessCwd } from "./resume-process-cwd.js"; import { buildStringCommandShellInvocation, createStringCommandShellEnvOverlay, @@ -142,29 +143,29 @@ function assertChildWithPipes( } } -// Node reports a failed spawn (missing binary or missing cwd) as a child -// `error` event; without a listener it becomes an uncaught exception. -function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise { +// Watch process failures as well as spawn errors: the SDK leaves initialize +// pending when the child exits without replying. `close` waits for stderr to +// drain, so the failure includes the launcher's diagnostic. +function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Promise { return new Promise((_, reject) => { child.once("error", (error) => { const stderr = stderrChunks.join("").trim(); reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error))); }); + child.once("close", (code, signal) => { + const stderr = stderrChunks.join("").trim(); + const status = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`; + reject( + new Error( + `ACP agent process exited before initialization completed (${status})${ + stderr ? `\n${stderr}` : "" + }`, + ), + ); + }); }); } -async function isDirectory(target: string): Promise { - try { - return (await fs.stat(target)).isDirectory(); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") { - return false; - } - throw error; - } -} - function isRecord(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value); } @@ -325,6 +326,14 @@ const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000; const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000; const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000; +/** + * An ACP agent publishes its slash-command inventory incrementally. Wait this + * long without another batch before treating the inventory as complete. MiniMax + * publishes its skills about 350ms after its built-in commands. + */ +const COMMAND_INVENTORY_QUIET_MS = 600; +const COMMAND_INVENTORY_POLL_MS = 25; + function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } { return { type: error instanceof Error ? error.name : typeof error, @@ -542,7 +551,7 @@ interface ACPProcessTransport { connection: ClientSideConnection; stderrChunks: string[]; spawnReady: Promise; - spawnError: Promise; + processError: Promise; } interface ACPImportPromptPreviews { @@ -649,6 +658,7 @@ interface TerminalExit { } interface TerminalEntry { + running: boolean; id: string; child: ChildProcess; output: string; @@ -1379,6 +1389,7 @@ export class ACPAgentClient implements AgentClient { client?: ACPClient; }, ): Promise { + const configuredOptions = ACPProviderOptionsSchema.parse(options?.providerOptions ?? {}); const transport = await this.spawnTransport(launchEnv, options?.client); const probe: UninitializedACPProcess = { child: transport.child, @@ -1390,7 +1401,7 @@ export class ACPAgentClient implements AgentClient { const initialize = await this.initializeTransport( transport, options?.initializeTimeoutMs, - options?.providerOptions, + configuredOptions.clientCapabilities, ); const initializedProbe: SpawnedACPProcess = { ...probe, @@ -1424,7 +1435,7 @@ export class ACPAgentClient implements AgentClient { stderrChunks.push(chunk.toString()); }); - const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks); + const processErrorPromise = rejectOnProcessFailure(child, stderrChunks); const spawnReadyPromise = new Promise((resolve) => { child.once("spawn", () => { resolve(); @@ -1443,16 +1454,15 @@ export class ACPAgentClient implements AgentClient { connection, stderrChunks, spawnReady: spawnReadyPromise, - spawnError: spawnErrorPromise, + processError: processErrorPromise, }; } protected async initializeTransport( transport: ACPProcessTransport, initializeTimeoutMs?: number, - providerOptions?: Record, + clientCapabilities?: ACPClientCapabilities, ): Promise { - const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {}); let timeout: ReturnType | null = null; const initializeTimeoutPromise = initializeTimeoutMs ? new Promise((_, reject) => { @@ -1469,11 +1479,11 @@ export class ACPAgentClient implements AgentClient { protocolVersion: PROTOCOL_VERSION, clientCapabilities: buildACPClientCapabilities( this.clientCapabilityMeta, - configuredOptions.clientCapabilities ?? this.clientCapabilities, + clientCapabilities ?? this.clientCapabilities, ), clientInfo: { name: "Paseo", version: "dev" }, }), - transport.spawnError, + transport.processError, ...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []), ]), ); @@ -1554,7 +1564,7 @@ export class ACPAgentClient implements AgentClient { try { transport = await this.spawnTransport(PROBE_ENV); await withTimeout( - Promise.race([transport.spawnReady, transport.spawnError]), + Promise.race([transport.spawnReady, transport.processError]), phaseTimeoutMs, `ACP spawn timed out after ${phaseTimeoutMs}ms`, ); @@ -1752,7 +1762,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { private lastActivityAt: string | null = null; private configOptions: SessionConfigOption[] = []; private cachedCommands: AgentSlashCommand[] = []; - private commandsReadyDeferred: { promise: Promise; resolve: () => void } | null = null; + /** When the last command batch arrived; null while nothing has been published. */ + private commandsUpdatedAt: number | null = null; private commandsReadySettled = false; private waitForInitialCommands: boolean; private initialCommandsWaitTimeoutMs: number; @@ -2050,58 +2061,39 @@ export class ACPAgentSession implements AgentSession, ACPClient { ]; } - private ensureCommandsReadyDeferred(): void { - if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) { - return; - } - - let resolve!: () => void; - const promise = new Promise((r) => { - resolve = r; - }); - this.commandsReadyDeferred = { promise, resolve }; - } - private settleCommandsReady(): void { - if (this.commandsReadySettled) { - return; - } this.commandsReadySettled = true; - this.commandsReadyDeferred?.resolve(); - this.commandsReadyDeferred = null; } - private async waitForCommandsReady(): Promise { - const deferred = this.commandsReadyDeferred; - if (!deferred) { - return; - } - - let timer: ReturnType | null = null; - try { - await Promise.race([ - deferred.promise, - new Promise((resolve) => { - timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - } + /** + * Providers publish their command inventory incrementally. MiniMax sends its + * built-in commands twice, then a third batch about 350ms later that merges in + * every discovered skill. Returning after the first batch loses the skills. + * + * Wait for the inventory to go quiet rather than for the first batch. The + * quiet window must exceed the gap between a provider's own batches; 600ms + * covers MiniMax with margin. `initialCommandsWaitTimeoutMs` still bounds the + * whole wait for providers that publish once or never publish at all. + */ + private async waitForCommandsToSettle(): Promise { + const deadline = Date.now() + this.initialCommandsWaitTimeoutMs; + while (!this.commandsReadySettled && !this.closed) { + const remaining = deadline - Date.now(); + if (remaining <= 0) return; + const quietFor = this.commandsUpdatedAt === null ? 0 : Date.now() - this.commandsUpdatedAt; + if (quietFor >= COMMAND_INVENTORY_QUIET_MS) return; + await new Promise((resolve) => + setTimeout(resolve, Math.min(remaining, COMMAND_INVENTORY_POLL_MS)), + ); } } async listCommands(): Promise { - if (this.cachedCommands.length > 0) { - return this.cachedCommands; - } - if (!this.waitForInitialCommands || this.closed) { + if (this.commandsReadySettled || this.closed || !this.waitForInitialCommands) { return this.cachedCommands; } - this.ensureCommandsReadyDeferred(); - await this.waitForCommandsReady(); + await this.waitForCommandsToSettle(); this.settleCommandsReady(); return this.cachedCommands; } @@ -2554,6 +2546,15 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + async canStopRuntime(): Promise { + return ( + this.activeForegroundTurnId === null && + this.pendingPermissions.size === 0 && + this.pendingUserMessage === null && + !Array.from(this.terminalEntries.values()).some((terminal) => terminal.running) + ); + } + async close(): Promise { if (this.closed) { return; @@ -2589,12 +2590,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } - const terminalTerminations = Array.from(this.terminalEntries.values(), (terminal) => - this.terminateProcess(terminal.child, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }), - ); + const terminalTerminations = Array.from(this.terminalEntries.values()) + .filter((terminal) => terminal.running) + .map((terminal) => + this.terminateProcess(terminal.child, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }), + ); await Promise.all(terminalTerminations); this.terminalEntries.clear(); @@ -2721,14 +2724,12 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } - // Cache an asynchronously-delivered slash-command batch and unblock any - // listCommands() call that is waiting on the initial batch. Used when a - // provider supplies an extensionCommandsParser whose result arrives after - // session/new (e.g. via a vendor extension notification). The ready gate is - // always settled — even for an empty batch — so a provider that legitimately - // reports no commands does not leave listCommands() blocked for the full - // initial-commands timeout. An optional sessionId scopes the batch to this - // session; notifications addressed to a different session are ignored. + // Cache an asynchronously-delivered slash-command batch from a provider that + // supplies an extensionCommandsParser (e.g. a vendor extension notification). + // An empty batch is the provider saying it has no commands, so it settles the + // waiter immediately instead of letting it wait out the quiet window. An + // optional sessionId scopes the batch to this session; notifications + // addressed to a different session are ignored. private applyResolvedCommands( commands: AgentSlashCommand[], options?: { sessionId?: string }, @@ -2742,7 +2743,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { } if (commands.length > 0) { + this.commandsUpdatedAt = Date.now(); this.cachedCommands = commands; + // A non-empty batch may be followed by more. Let the quiet window decide + // when the inventory is complete rather than settling here; an empty + // batch is the provider saying it has nothing, which must still unblock + // the waiter immediately. + return; } this.settleCommandsReady(); } @@ -2796,6 +2803,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { waitForExit.catch(() => undefined); const entry: TerminalEntry = { + running: true, id: terminalId, child, output: "", @@ -2816,10 +2824,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { child.once("error", (error) => { const spawnError = error instanceof Error ? error : new Error(String(error)); appendTerminalOutput(entry, `${spawnError.message}\n`); + // A failed spawn has no exit event. Other process errors do not prove exit. + if (child.pid === undefined) entry.running = false; rejectExit(spawnError); }); child.once("exit", (code, signal) => { const exit = { exitCode: code, signal }; + entry.running = false; entry.exit = exit; resolveExit(exit); }); @@ -2844,7 +2855,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise { const entry = this.getTerminalEntry(params.terminalId); - if (!entry.exit) { + if (entry.running) { await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); } this.terminalEntries.delete(params.terminalId); @@ -2852,7 +2863,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { async killTerminal(params: KillTerminalRequest): Promise> { const entry = this.getTerminalEntry(params.terminalId); - if (!entry.exit) { + if (entry.running) { await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); } return {}; @@ -2861,11 +2872,8 @@ export class ACPAgentSession implements AgentSession, ACPClient { // Loading an archived agent's history runs nothing in its working directory, which // may have been removed with its worktree. session/load still names the original // directory; only the process starts from the home directory. - private async resolveProcessCwd(): Promise { - if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) { - return this.config.cwd; - } - return homedir(); + private resolveProcessCwd(): Promise { + return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose); } private async spawnProcess(): Promise { @@ -2894,7 +2902,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { child.stderr.on("data", (chunk: Buffer | string) => { stderrChunks.push(chunk.toString()); }); - const spawnError = rejectOnSpawnError(child, stderrChunks); + const processError = rejectOnProcessFailure(child, stderrChunks); child.once("exit", (code, signal) => { if (this.closed || this.historyRuntimeReleased) { return; @@ -2931,7 +2939,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { ), clientInfo: { name: "Paseo", version: "dev" }, }), - spawnError, + processError, ]), ); @@ -3122,13 +3130,19 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.handleUsageUpdate(update); return pendingUserEvents; case "available_commands_update": + this.commandsUpdatedAt = Date.now(); this.cachedCommands = update.availableCommands.map((command) => ({ name: command.name, description: command.description, argumentHint: command.input?.hint ?? "", kind: "command", })); - this.settleCommandsReady(); + // An empty batch means the agent has no commands and would otherwise + // wait out the whole quiet window. A non-empty batch may be one of + // several, so completion belongs to the quiet window. + if (update.availableCommands.length === 0) { + this.settleCommandsReady(); + } return pendingUserEvents; default: return pendingUserEvents; @@ -4077,10 +4091,16 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void { if (!limit) { return; } - while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) { - entry.output = entry.output.slice(1); - entry.truncated = true; + const output = Buffer.from(entry.output, "utf8"); + if (output.length <= limit) { + return; } + let start = output.length - limit; + while (start < output.length && (output[start] & 0xc0) === 0x80) { + start += 1; + } + entry.output = output.subarray(start).toString("utf8"); + entry.truncated = true; } function readRecord(value: unknown): Record | null { diff --git a/packages/server/src/server/agent/providers/acp-startup-exit.test.ts b/packages/server/src/server/agent/providers/acp-startup-exit.test.ts new file mode 100644 index 0000000000..39ae6f743e --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-startup-exit.test.ts @@ -0,0 +1,112 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; + +import { GenericACPAgentClient } from "./generic-acp-agent.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; + +// Exercise the public catalog API with a real child, including stdout EOF and +// stderr draining. No provider installation or external service is involved. +const failingCommand: [string, ...string[]] = [ + process.execPath, + "-e", + "process.stdin.once('data', () => { process.stderr.write('launcher failed: fixture startup error\\n'); process.exitCode = 1; process.stdin.destroy(); });", +]; + +describe("ACP catalog startup failures", () => { + test("reports a process that exits during initialize with its stderr", async () => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: failingCommand, + }); + await expect( + client.fetchCatalog({ scope: "workspace", cwd: process.cwd(), force: true }), + ).rejects.toThrow(/launcher failed: fixture startup error/); + }, 5_000); + + test("session startup reports the same process exit with its stderr", async () => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: failingCommand, + }); + await expect(client.createSession({ provider: "acp", cwd: process.cwd() })).rejects.toThrow( + /launcher failed: fixture startup error/, + ); + }, 5_000); + + test("provider diagnostics report the startup exit instead of an initialize timeout", async () => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: failingCommand, + diagnosticPhaseTimeoutMs: 2_000, + }); + const { diagnostic } = await client.getDiagnostic(); + expect(diagnostic).toContain( + "ACP agent process exited before initialization completed (exit code 1)", + ); + expect(diagnostic).toContain("launcher failed: fixture startup error"); + expect(diagnostic).not.toContain("initialize timed out"); + }); + + test("invalid provider options reject without crashing the host process", async () => { + const clientModule = new URL("./generic-acp-agent.ts", import.meta.url).href; + const loggerModule = new URL("../../../test-utils/test-logger.ts", import.meta.url).href; + const script = ` + import { GenericACPAgentClient } from ${JSON.stringify(clientModule)}; + import { createTestLogger } from ${JSON.stringify(loggerModule)}; + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, '-e', 'setInterval(() => {}, 1000)'], + }); + try { + await client.fetchCatalog({ + scope: 'workspace', cwd: process.cwd(), force: true, + providerOptions: { clientCapabilities: { fs: 'invalid' } }, + }); + throw new Error('Expected invalid options to reject'); + } catch (error) { + if (error.name !== 'ZodError') throw error; + } + // Let child cleanup and Node's unhandled-rejection check complete. + await new Promise(resolve => setTimeout(resolve, 100)); + console.log('host stayed alive'); + `; + const result = await promisify(execFile)( + process.execPath, + ["--import", "tsx", "--input-type=module", "-e", script], + { timeout: 10_000 }, + ); + expect(result.stdout).toContain("host stayed alive"); + expect(result.stderr).toBe(""); + }); + + test("a successful catalog probe still returns its models and closes cleanly", async () => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [ + process.execPath, + "-e", + ` + require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => { + const request = JSON.parse(line); + const result = request.method === 'initialize' + ? { protocolVersion: 1, agentCapabilities: {} } + : { sessionId: 'fixture', models: { + currentModelId: 'fixture-model', + availableModels: [{ modelId: 'fixture-model', name: 'Fixture model' }] + } }; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n'); + }); + `, + ], + }); + const catalog = await client.fetchCatalog({ + scope: "workspace", + cwd: process.cwd(), + force: true, + }); + expect(catalog.models).toEqual([ + expect.objectContaining({ id: "fixture-model", label: "Fixture model" }), + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 4cab1a5005..8aa15581ae 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -1,3 +1,5 @@ +import { ClaudeRuntimeSafety } from "./runtime-safety.js"; +import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -464,13 +466,13 @@ describe("background Claude subagents", () => { type: "sub_agent", subAgentType: "general-purpose", description: "Count files here", - log: "[Bash] ls -1", + log: "", }, }); }); test.each(["before", "after"])( - "keeps one logged Task card when child tools start %s the parent finishes", + "keeps one Task card when child tools start %s the parent finishes", async (timing) => { // A child can stream across the main session's turn boundary. Claude opens the next // main-session turn with system/init; bare child frames do not open a turn. @@ -603,7 +605,7 @@ describe("background Claude subagents", () => { type: "sub_agent", subAgentType: "general-purpose", description: "Count files here", - log: "[Bash] ls -1", + log: "", }, }); }, @@ -706,3 +708,137 @@ describe("background Claude subagents", () => { }, ); }); + +test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => { + const safety = new ClaudeRuntimeSafety(); + const observe = (message: unknown) => safety.observe(message as SDKMessage); + observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" }); + expect(safety.canStopRuntime()).toBe(true); + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" }, + { task_id: "monitor", task_type: "local_bash", description: "watch build" }, + { task_id: "native-child", task_type: "local_agent", description: "research" }, + ], + }); + expect(safety.canStopRuntime()).toBe(false); + observe({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect(safety.canStopRuntime()).toBe(false); + for (const task_id of ["quiet-shell", "monitor"]) + observe({ type: "system", subtype: "task_notification", task_id }); + expect(safety.canStopRuntime()).toBe(false); + observe({ type: "system", subtype: "task_notification", task_id: "native-child" }); + expect(safety.canStopRuntime()).toBe(true); + safety.reset(); + observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" }); + expect(safety.canStopRuntime()).toBe(false); + observe({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect(safety.canStopRuntime()).toBe(true); +}); + +test("Claude session runtime safety observes pumped background work and autonomous replies", async () => { + let wake: (() => void) | undefined; + const channel = { closed: false }; + const queue: Array<{ message: SDKMessage; consumed(): void }> = []; + const pump = (async function* () { + while (!channel.closed) { + const entry = queue.shift(); + if (!entry) { + await new Promise((resolve) => { + wake = resolve; + }); + continue; + } + yield entry.message; + entry.consumed(); + } + })(); + const push = (message: unknown) => + new Promise((consumed) => { + queue.push({ message: message as SDKMessage, consumed }); + wake?.(); + }); + const end = () => { + channel.closed = true; + wake?.(); + }; + let nativeInput!: AsyncIterator; + let connected!: () => void; + const connection = new Promise((resolve) => { + connected = resolve; + }); + // The SDK Query is injected through the production adapter's existing launch port. + const query = { + next: () => pump.next(), + return: async () => { + end(); + return pump.return(undefined); + }, + close: end, + interrupt: async () => {}, + setPermissionMode: async () => {}, + setModel: async () => {}, + supportedModels: async () => [], + supportedCommands: async () => [], + [Symbol.asyncIterator]() { + return this; + }, + } as unknown as Query; + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + queryFactory: ({ prompt }) => { + if (typeof prompt === "string") throw new Error("Expected streaming input"); + nativeInput = prompt[Symbol.asyncIterator](); + connected(); + return query; + }, + }).createSession({ provider: "claude", cwd: process.cwd() }); + const init = { + type: "system", + subtype: "init", + session_id: "runtime-safety", + claude_code_version: "2.1.292", + permissionMode: "default", + }; + const result = { + type: "result", + subtype: "success", + usage: { input_tokens: 1, output_tokens: 1 }, + total_cost_usd: 0, + }; + try { + await session.startTurn("start background work"); + await connection; + const input = await nativeInput.next(); + await push({ type: "command_lifecycle", command_uuid: input.value.uuid, state: "started" }); + await push(init); + await push(result); + expect(await session.canStopRuntime!()).toBe(true); + await push({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "monitor", task_type: "local_bash", description: "quiet monitor" }], + }); + expect(await session.canStopRuntime!()).toBe(false); + await push({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect(await session.canStopRuntime!()).toBe(false); + await push({ + type: "system", + subtype: "task_notification", + task_id: "monitor", + status: "completed", + output: "ready", + summary: "ready", + }); + // Completion can wake Claude autonomously after the foreground turn has ended. + await push(init); + expect(await session.canStopRuntime!()).toBe(false); + await push(result); + expect(await session.canStopRuntime!()).toBe(true); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index 0d2748fd62..6d268e932c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.env.test.ts @@ -1,10 +1,11 @@ +import { once } from "node:events"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { AgentLaunchContext } from "../../agent-sdk-types.js"; import { ClaudeAgentClient } from "./agent.js"; -import type { ClaudeQueryInput } from "./query.js"; +import { claudeQuery, type ClaudeQueryInput } from "./query.js"; function createQueryMock(events: unknown[]): Query { let index = 0; @@ -29,6 +30,55 @@ function createQueryMock(events: unknown[]): Query { } describe("Claude SDK env", () => { + test.each( + ["node", "bun", process.execPath].flatMap((command) => + [undefined, "Paseo", "custom-client"].map((entrypoint) => ({ command, entrypoint })), + ), + )( + "preserves explicit launch identity $entrypoint in $command while clearing inherited session env", + async ({ command, entrypoint }) => { + let output = ""; + let exited = Promise.resolve([]); + const query = claudeQuery( + { prompt: "", options: {} }, + { + runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } }, + launchEnv: entrypoint === undefined ? {} : { CLAUDE_CODE_ENTRYPOINT: entrypoint }, + queryFactory: ({ options }) => { + if (!options.spawnClaudeCodeProcess) { + throw new Error("Missing Claude process launcher"); + } + const child = options.spawnClaudeCodeProcess({ + command, + args: [ + "-e", + "process.stdout.write(JSON.stringify({ entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, parentSession: process.env.CLAUDECODE ?? null }))", + ], + cwd: process.cwd(), + env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: "sdk-ts", CLAUDECODE: "1" }, + signal: new AbortController().signal, + }); + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + exited = once(child, "close"); + return createQueryMock([]); + }, + }, + ); + + try { + expect(await exited).toEqual([0, null]); + expect(JSON.parse(output)).toEqual({ + ...(entrypoint === undefined ? {} : { entrypoint }), + parentSession: null, + }); + } finally { + query.close(); + } + }, + ); + test("forwards launch-context env through Claude process env", async () => { let capturedEnv: Record | undefined; let capturedPerTaskStopAffordance: boolean | undefined; diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts index b355f367b3..e2ea6850bb 100644 --- a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -1,9 +1,14 @@ +import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js"; import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { open } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; import * as os from "node:os"; import * as path from "node:path"; import pino from "pino"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js"; +import type { ClaudeRewindSdk } from "./rewind.js"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { withClaudeReplayHistory } from "./transcript-history.js"; @@ -32,7 +37,10 @@ describe("ClaudeAgentSession persisted history load", () => { let configDir: string; let records: LogRecord[]; - async function resume(queryFactory?: ClaudeQueryFactory): Promise { + async function resume( + queryFactory?: ClaudeQueryFactory, + rewindSdk?: ClaudeRewindSdk, + ): Promise { const client = new ClaudeAgentClient({ logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }), queryFactory: @@ -42,10 +50,36 @@ describe("ClaudeAgentSession persisted history load", () => { }), resolveBinary: async () => process.execPath, resolveVersion: async () => "2.1.220", + rewindSdk, }); return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd }); } + function replyFactory(text: string, messageId = "live-reply"): ClaudeQueryFactory { + return (() => { + const messages = [ + { type: "assistant", uuid: messageId, message: { content: text } }, + { type: "result", subtype: "success", total_cost_usd: 0 }, + ]; + return { + async next() { + const value = messages.shift(); + return value ? { done: false, value } : { done: true, value: undefined }; + }, + close() {}, + async return() { + return { done: true, value: undefined }; + }, + async supportedCommands() { + return []; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }) as ClaudeQueryFactory; + } + function transcriptPath(): string { return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`); } @@ -65,6 +99,286 @@ describe("ClaudeAgentSession persisted history load", () => { rmSync(tempRoot, { recursive: true, force: true }); }); + test("serves unrelated health requests while resuming 32 MiB of ordered history", async () => { + const startedAt = performance.now(); + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + const file = await open(transcriptPath(), "w"); + const text = "x".repeat(16 * 1024); + let bytes = 0; + let count = 0; + try { + while (bytes < 32 * 1024 * 1024) { + const line = + JSON.stringify({ type: "assistant", message: { content: `${count}:${text}` } }) + "\n"; + bytes += Buffer.byteLength(line); + await file.write(line); + count++; + } + } finally { + await file.close(); + } + const daemon = await createTestPaseoDaemon(); + let loading = true; + let answeredDuringLoad = false; + const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => { + expect(response.status).toBe(200); + await response.text(); + answeredDuringLoad = loading; + return undefined; + }); + let session: AgentSession | undefined; + try { + const { + result: { resumed: loadedSession, historyCount }, + blockingMs, + } = await measureMainThreadWork("Claude history main-thread work", async () => { + const resumed = await resume(replyFactory("live after large resume")); + session = resumed; + let index = 0; + for await (const event of resumed.streamHistory()) { + expect(event).toEqual({ + type: "timeline", + provider: "claude", + timestamp: undefined, + item: { type: "assistant_message", text: `${index}:${text}` }, + }); + index++; + } + loading = false; + await health; + return { resumed, historyCount: index }; + }); + console.info( + JSON.stringify({ + bytes, + count, + blockingMs, + answeredDuringLoad, + elapsedMs: performance.now() - startedAt, + }), + ); + expect(historyCount).toBe(count); + expect(blockingMs).toBeLessThan(50); + expect(answeredDuringLoad).toBe(true); + const live = await collectSessionTurnEvents(loadedSession, "follow up"); + expect( + live + .filter((event) => event.type === "timeline" && event.item.type === "assistant_message") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "assistant_message", messageId: "live-reply", text: "live after large resume" }, + ]); + } finally { + await session?.close(); + await daemon.close(); + } + }, 60_000); + + test("rebinds ordered history before returning from rewind and accepts a live turn", async () => { + const entries = [ + { type: "user", uuid: "u1", message: { content: "first" } }, + { type: "assistant", uuid: "a1", message: { content: "first reply" } }, + { type: "user", uuid: "u2", message: { content: "second" } }, + { type: "assistant", uuid: "a2", message: { content: "second reply" } }, + ]; + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n")); + const forkId = "rebound-history"; + const forkFile = path.join(path.dirname(transcriptPath()), `${forkId}.jsonl`); + writeFileSync( + forkFile, + entries + .slice(0, 2) + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + const forks: unknown[] = []; + const late = { + type: "assistant", + uuid: "retired-reply", + message: { content: "retired query" }, + }; + let pendingRead: ((value: unknown) => void) | undefined; + let releaseRetirement!: () => void; + const retirementGate = new Promise((resolve) => { + releaseRetirement = resolve; + }); + let retirementStarted!: () => void; + const retiring = new Promise((resolve) => { + retirementStarted = resolve; + }); + let firstQuery = true; + let retired = false; + const queryFactory = (() => { + if (!firstQuery) return replyFactory("live after rebind")({} as never); + firstQuery = false; + const messages = [ + { type: "assistant", uuid: "before-rebind", message: { content: "before rebind" } }, + { type: "result", subtype: "success", total_cost_usd: 0 }, + ]; + return { + async next() { + const value = messages.shift(); + if (value) return { done: false, value }; + if (retired) return { done: true, value: undefined }; + return new Promise((resolve) => { + pendingRead = resolve; + }); + }, + close() { + retired = true; + pendingRead?.({ done: false, value: late }); + }, + async return() { + retirementStarted(); + await retirementGate; + return { done: true, value: undefined }; + }, + async supportedCommands() { + return []; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }) as ClaudeQueryFactory; + const session = await resume(queryFactory, { + async forkSession(sessionId, options) { + forks.push({ sessionId, ...options }); + return { sessionId: forkId }; + }, + }); + try { + const original = await Array.fromAsync(session.streamHistory()); + expect(original).toHaveLength(4); + await collectSessionTurnEvents(session, "before rewind"); + const emitted: AgentStreamEvent[] = []; + const subscription = session.subscribe((event) => emitted.push(event)); + let rewindDone = false; + const rewind = (async () => { + await session.revertConversation!({ messageId: "u2" }); + rewindDone = true; + })(); + await retiring; + let historyDone = false; + const reading = (async () => { + const history = await Array.fromAsync(session.streamHistory()); + historyDone = true; + return history; + })(); + const readingAgain = Array.fromAsync(session.streamHistory()); + await Promise.resolve(); + expect(rewindDone).toBe(false); + expect(historyDone).toBe(false); + releaseRetirement(); + await rewind; + subscription(); + expect(retired).toBe(true); + expect( + emitted.filter( + (event) => event.type === "timeline" && event.item.type === "assistant_message", + ), + ).toEqual([ + { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: `Claude switched to a new session: ${SESSION_ID} -> ${forkId}`, + }, + }, + ]); + expect(forks).toEqual([ + { sessionId: SESSION_ID, upToMessageId: "a1", transcriptPath: transcriptPath() }, + ]); + expect(session.id).toBe(forkId); + const [history, secondRead] = await Promise.all([reading, readingAgain]); + expect(history).toEqual(original.slice(0, 2)); + expect(secondRead).toEqual([]); + const live = await collectSessionTurnEvents(session, "continue the fork"); + expect( + live + .filter((event) => event.type === "timeline" && event.item.type === "assistant_message") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "assistant_message", messageId: "live-reply", text: "live after rebind" }, + ]); + } finally { + releaseRetirement(); + await session.close(); + } + }); + + test("reports a failed rewind without poisoning readiness when query retirement throws", async () => { + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + writeFileSync( + transcriptPath(), + [ + { type: "user", uuid: "u1", message: { content: "first" } }, + { type: "assistant", uuid: "a1", message: { content: "reply" } }, + { type: "user", uuid: "u2", message: { content: "second" } }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + let firstQuery = true; + let finishRead: ((value: unknown) => void) | undefined; + const queryFactory = ((input) => { + const query = replyFactory( + firstQuery ? "before retirement" : "still available", + firstQuery ? "before-retirement" : "live-reply", + )(input); + if (!firstQuery) return query; + firstQuery = false; + let reads = 0; + return { + ...query, + async next() { + if (++reads <= 2) return query.next(); + return new Promise((resolve) => { + finishRead = resolve; + }); + }, + close() { + finishRead?.({ done: true, value: undefined }); + throw new Error("retirement failed"); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }) as ClaudeQueryFactory; + const session = await resume(queryFactory, { + async forkSession() { + return { sessionId: "failed-rebind" }; + }, + }); + try { + await Array.fromAsync(session.streamHistory()); + await collectSessionTurnEvents(session, "before rewind"); + await expect(session.revertConversation!({ messageId: "u2" })).rejects.toThrow( + "retirement failed", + ); + expect(records).toContainEqual( + expect.objectContaining({ + level: pino.levels.values.warn, + msg: "Failed to rebind Claude session", + sessionId: "failed-rebind", + err: expect.objectContaining({ message: "retirement failed" }), + }), + ); + expect(session.id).toBe(SESSION_ID); + const live = await collectSessionTurnEvents(session, "after failed rebind"); + expect( + live + .filter((event) => event.type === "timeline" && event.item.type === "assistant_message") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([{ type: "assistant_message", messageId: "live-reply", text: "still available" }]); + } finally { + await session.close(); + } + }); + test.each(["file_path", "files"])( "replays %s without probing targets and preserves live existence checks", async (field) => { @@ -300,21 +614,58 @@ describe("ClaudeAgentSession persisted history load", () => { ); }); - test("loads readable history when the temporary directory is unavailable", () => { + test("replays a message steered into a running turn as the user's message", async () => { + const queued = (commandMode: string, prompt: unknown, sourceUuid?: string) => ({ + type: "attachment", + uuid: `attachment-${commandMode}`, + parentUuid: "a1", + attachment: { + type: "queued_command", + commandMode, + prompt, + ...(sourceUuid ? { source_uuid: sourceUuid } : {}), + }, + }); + const entries = [ + { type: "user", uuid: "u1", parentUuid: null, message: { content: "first" } }, + { type: "assistant", uuid: "a1", parentUuid: "u1", message: { content: "working" } }, + queued("prompt", [{ type: "text", text: "steered while working" }], "steered-1"), + queued("task-notification", "t1"), + { type: "assistant", uuid: "a2", parentUuid: "a1", message: { content: "answer" } }, + ]; + mkdirSync(path.dirname(transcriptPath()), { recursive: true }); + writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n")); + + const session = await resume(); + try { + const users = []; + for await (const event of session.streamHistory()) { + if (event.type === "timeline" && event.item.type === "user_message") users.push(event.item); + } + expect(users).toEqual([ + { type: "user_message", text: "first", messageId: "u1" }, + { type: "user_message", text: "steered while working", messageId: "steered-1" }, + ]); + } finally { + await session.close(); + } + }); + + test("loads readable history when the temporary directory is unavailable", async () => { const file = transcriptPath(); const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } }; mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(entry)); const unavailable = path.join(tempRoot, "unavailable-tmp"); for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable); - withClaudeReplayHistory(file, (history) => { - expect([...history.parentEntries]).toEqual([entry]); + await withClaudeReplayHistory(file, async (history) => { + expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]); }); }); test.each(["parent", "child", "workflow"])( "preserves indexed %s history when its transcript disappears between passes", - (target) => { + async (target) => { const parent = transcriptPath(); const sessionDirectory = parent.slice(0, -".jsonl".length); const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl"); @@ -334,14 +685,16 @@ describe("ClaudeAgentSession persisted history load", () => { writeFileSync(parent, JSON.stringify(parentEntry)); writeFileSync(child, JSON.stringify(childEntry)); writeFileSync(workflow, JSON.stringify(childEntry)); - withClaudeReplayHistory(parent, (history) => { + await withClaudeReplayHistory(parent, async (history) => { rmSync({ parent, child, workflow }[target]); // Ownership facts and visible payloads are separate passes over the same source. for (let pass = 0; pass < 2; pass++) { - expect([...history.parentEntries]).toEqual([parentEntry]); + expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]); expect(history.subagents).toHaveLength(1); - expect([...history.subagents[0]!.entries]).toEqual([childEntry]); - expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]); + expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]); + expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([ + childEntry, + ]); } }); }, diff --git a/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts b/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts index 5a06fea7fd..a772e544d1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.image-rendering.test.ts @@ -13,8 +13,8 @@ const MATERIALIZED_PNG_PATH_PATTERN = /paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/; interface ClaudeImageTestSession { - translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; - convertHistoryEntry(entry: unknown): AgentTimelineItem[]; + translateMessageToEvents(message: SDKMessage): Promise; + convertHistoryEntry(entry: unknown): Promise; } async function createSession(): Promise { @@ -157,7 +157,7 @@ describe("Claude tool_result image rendering", () => { test("emits the image as assistant markdown and keeps base64 out of the live tool output", async () => { const session = await createSession(); - const events = session.translateMessageToEvents(imageToolResultUserMessage()); + const events = await session.translateMessageToEvents(imageToolResultUserMessage()); const timelineItems = events .filter((event) => event.type === "timeline") @@ -181,7 +181,7 @@ describe("Claude tool_result image rendering", () => { test("replays the image as assistant markdown through history conversion", async () => { const session = await createSession(); - const items = session.convertHistoryEntry(imageToolResultHistoryEntry()); + const items = await session.convertHistoryEntry(imageToolResultHistoryEntry()); const [imageMessage, ...extraImages] = imageMessages(items); expect(extraImages).toEqual([]); @@ -202,7 +202,7 @@ describe("Claude tool_result image rendering", () => { test("keeps base64 out of an errored tool_result that carries an image", async () => { const session = await createSession(); - const events = session.translateMessageToEvents(erroredImageToolResultUserMessage()); + const events = await session.translateMessageToEvents(erroredImageToolResultUserMessage()); const timelineItems = events .filter((event) => event.type === "timeline") @@ -226,7 +226,7 @@ describe("Claude tool_result image rendering", () => { test("emits one image message per image block in a multi-image tool_result", async () => { const session = await createSession(); - const events = session.translateMessageToEvents(multiImageToolResultUserMessage()); + const events = await session.translateMessageToEvents(multiImageToolResultUserMessage()); const timelineItems = events .filter((event) => event.type === "timeline") diff --git a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts index 02a09ba44b..fa0266c355 100644 --- a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts @@ -349,8 +349,11 @@ async function startSteeredTurn(sessionId: string): Promise<{ expectedTurnId: start.value.turnId, clientMessageId: "steer-client", }); - expect(steered).toEqual({ status: "accepted" }); await waitFor(() => query?.prompts.length === 2); + expect(steered).toEqual({ + status: "accepted", + providerMessageId: query?.prompts[1]?.uuid, + }); return { session, query: () => query, turn }; } @@ -972,7 +975,10 @@ test("steers an autonomous turn through its existing query without restarting it const steeredTimeline = await autonomousEvents.next(); const completion = await autonomousEvents.next(); - expect(steer).toEqual({ status: "accepted" }); + expect(steer).toEqual({ + status: "accepted", + providerMessageId: queryRef?.prompts[1]?.uuid, + }); expect(queryFactory).toHaveBeenCalledTimes(1); expect(queryRef?.interrupt).not.toHaveBeenCalled(); expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual(["seed prompt", "steer prompt"]); diff --git a/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts b/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts index 1b1932e9cd..ed9b324ff4 100644 --- a/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts @@ -1,7 +1,11 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDir } from "./project-dir.js"; import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js"; import { streamSession } from "../test-utils/session-stream-adapter.js"; import type { AgentSession } from "../../agent-sdk-types.js"; @@ -267,3 +271,49 @@ describe("Claude rewind after a turn whose last assistant message came from a su expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]); }); }); + +describe("Claude rewind to a message steered into a running turn", () => { + test("forks at the entry Claude read the message after", async () => { + const configDir = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-")); + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + const conversation = createConversation([{ assistantMessageId: "assistant-1" }]); + const rewindSdk = new FakeClaudeSdk(); + const session = await createSession(conversation, rewindSdk); + + try { + await runTurns(session, 1); + // A steered message opens no turn: Claude Code records it as a queued_command attachment + // under the uuid Paseo minted for it, hung off the entry it was read after. + const projectDir = await claudeProjectDir(process.cwd(), { configDir }); + mkdirSync(projectDir, { recursive: true }); + const entries = [ + { type: "user", uuid: conversation.userMessageIds[0], parentUuid: null }, + { type: "assistant", uuid: "assistant-1", parentUuid: conversation.userMessageIds[0] }, + { type: "user", uuid: "tool-result-1", parentUuid: "assistant-1" }, + { + type: "attachment", + uuid: "queued-command-1", + parentUuid: "tool-result-1", + attachment: { type: "queued_command", source_uuid: "steered-message-1" }, + }, + ]; + writeFileSync( + path.join(projectDir, `${SESSION_ID}.jsonl`), + entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""), + ); + + await session.revertConversation?.({ messageId: "steered-message-1" }); + } finally { + await session.close(); + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + rmSync(configDir, { recursive: true, force: true }); + } + + expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "tool-result-1" }]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 666e701649..91007aa7ce 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { streamSession } from "../test-utils/session-stream-adapter.js"; import { ClaudeAgentClient } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; @@ -77,6 +78,29 @@ function sidechainEntry(options: { agentId?: string; stopReason?: string | null }); } +function buildQueryMock(events: unknown[]) { + let index = 0; + return { + next: vi.fn(async () => { + if (index >= events.length) return { done: true, value: undefined }; + const value = events[index]; + index += 1; + return { done: false, value }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + close: vi.fn(() => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => []), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, + }; +} + describe("ClaudeAgentSession persisted subagent replay", () => { const logger = createTestLogger(); const queryFactory = vi.fn(() => { @@ -422,6 +446,237 @@ describe("ClaudeAgentSession persisted subagent replay", () => { }); }); + describe("a background subagent", () => { + // Shapes recorded from Claude Code 2.1.x: the launch returns at once with an async result, + // and a child stopped by an interrupt ends on the interrupt placeholder, not an end_turn. + function backgroundLaunch(): string[] { + return [ + parentEntry([ + { + type: "tool_use", + id: TOOL_USE_ID, + name: "Agent", + input: { + subagent_type: "general-purpose", + description: "probe sleeper", + run_in_background: true, + }, + }, + ]), + JSON.stringify({ + type: "user", + sessionId: "replay-session", + timestamp: "2026-07-26T06:27:48.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: TOOL_USE_ID, + content: [ + { + type: "text", + text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`, + }, + ], + }, + ], + }, + toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID }, + }), + ]; + } + + function readStatus(event: { status?: string }): string | undefined { + return event.status; + } + + function interruptedChild(): string[] { + return [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:27:50.000Z", + message: { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_child_bash", name: "Bash", input: {} }], + stop_reason: "tool_use", + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:28:05.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_child_bash", + content: "The user doesn't want to proceed with this tool use.", + is_error: true, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:28:05.001Z", + message: { + role: "user", + content: [{ type: "text", text: "[Request interrupted by user for tool use]" }], + }, + }), + ]; + } + + test.each([ + ["the meta sidecar", JSON.stringify({ toolUseId: TOOL_USE_ID })], + ["the legacy scrape", null], + ])("is canceled when an interrupt stopped it, linked through %s", async (_link, meta) => { + writeSession({ parentLines: backgroundLaunch(), meta, sidechainLines: interruptedChild() }); + + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ + id: TOOL_USE_ID, + status: "canceled", + }); + }); + + test("stays running when the child was given a new prompt after the interrupt", async () => { + writeSession({ + parentLines: backgroundLaunch(), + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [ + ...interruptedChild(), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + timestamp: "2026-07-26T06:29:00.000Z", + message: { role: "user", content: [{ type: "text", text: "Carry on with the task." }] }, + }), + ], + }); + + const statuses = upserts(await replayDescriptors()).map(readStatus); + expect(statuses).not.toContain("canceled"); + expect(statuses).toContain("running"); + }); + + test.each(["interrupted", "running"])( + "ignores an API request copy of a %s child's launch result", + async (state) => { + const launch = backgroundLaunch(); + const result = JSON.parse(launch[1]!); + writeSession({ + parentLines: [ + ...launch, + JSON.stringify({ + type: "api-request-blob", + sessionId: "replay-session", + hash: "request-copy", + message: result.message, + }), + ], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: + state === "interrupted" + ? interruptedChild() + : [sidechainEntry({ stopReason: "tool_use" })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ + id: TOOL_USE_ID, + status: state === "interrupted" ? "canceled" : "running", + }); + }, + ); + + test("stays running while the child has not finished", async () => { + writeSession({ + parentLines: backgroundLaunch(), + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "tool_use" })], + }); + + const statuses = upserts(await replayDescriptors()).map(readStatus); + expect(statuses).not.toContain("completed"); + expect(statuses).toContain("running"); + }); + + function taskNotification(status: string, form = "user"): string { + const text = `\n${AGENT_ID}\n${TOOL_USE_ID}\n${status}\nBackground task ended\n`; + const records = { + user: { message: { role: "user", content: [{ type: "text", text }] } }, + system: { + subtype: "task_notification", + task_id: AGENT_ID, + tool_use_id: TOOL_USE_ID, + status, + }, + "queue-operation": { operation: "enqueue", content: text }, + }; + return JSON.stringify({ + type: form, + timestamp: "2026-07-26T06:29:00.000Z", + ...records[form as keyof typeof records], + }); + } + + for (const form of ["user", "system", "queue-operation"]) { + test.each([ + ["completed", "completed"], + ["failed", "failed"], + ["killed", "canceled"], + ["stopped", "canceled"], + ])( + `restores a %s task notification from ${form} when the child stop_reason is null`, + async (status, expected) => { + writeSession({ + parentLines: [...backgroundLaunch(), taskNotification(status, form)], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: null })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ + id: TOOL_USE_ID, + status: expected, + }); + }, + ); + } + + test("prefers a stopped notification over the child's end_turn", async () => { + writeSession({ + parentLines: [...backgroundLaunch(), taskNotification("stopped")], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "end_turn" })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "canceled" }); + }); + + test("does not invent an outcome for an unknown notification status", async () => { + writeSession({ + parentLines: [...backgroundLaunch(), taskNotification("unknown")], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: null })], + }); + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "running" }); + }); + + test("completes from the child's own end_turn", async () => { + writeSession({ + parentLines: backgroundLaunch(), + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [sidechainEntry({ stopReason: "end_turn" })], + }); + + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "completed" }); + }); + }); + test("does not replay a skill-spawned subagent the parent never named", async () => { // The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only // agentType and its id appears nowhere in the parent — it used to replay as running forever, @@ -506,7 +761,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => { ); }, 120_000); - test("limits retained child shell output to the timeline's existing content limit", async () => { + test("limits retained child shell output to the timeline item budget", async () => { const output = "x".repeat(128 * 1024); writeSession({ parentLines: [taskToolUse(), taskToolResult()], @@ -551,7 +806,10 @@ describe("ClaudeAgentSession persisted subagent replay", () => { type: "timeline", id: TOOL_USE_ID, item: expect.objectContaining({ - detail: expect.objectContaining({ type: "shell", output: output.slice(0, 64 * 1024) }), + detail: expect.objectContaining({ + type: "shell", + output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/), + }), }), }), ]); @@ -1028,4 +1286,106 @@ describe("ClaudeAgentSession persisted subagent replay", () => { ); expect(items).toEqual([{ type: "assistant_message", text: report }]); }); + + test("keeps a subagent resumed after the session reopens on the row its transcript restored", async () => { + // Verified on the wire (Claude Code 2.1.309): SendMessage resumes a finished subagent under its + // original task_id but a new tool_use_id, and the child's frames still name the original Task + // call as their parent. A reopened session only knows that child from its transcript. + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ agentType: "general-purpose", toolUseId: TOOL_USE_ID }), + sidechainLines: [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + sessionId: "replay-session", + timestamp: "2026-07-26T06:27:50.000Z", + effort: "high", + message: { + role: "assistant", + model: "claude-sonnet-5", + content: [{ type: "text", text: "summary of the docs" }], + stop_reason: "end_turn", + }, + }), + ], + }); + const liveQuery = vi.fn(() => + buildQueryMock([ + { + type: "system", + subtype: "init", + session_id: "replay-session", + permissionMode: "default", + }, + { + type: "system", + subtype: "task_started", + task_id: AGENT_ID, + tool_use_id: "toolu_send_message", + description: "Summarize the docs", + subagent_type: "general-purpose", + task_type: "local_agent", + is_backgrounded: true, + prompt: "Summarize them again", + }, + { + type: "assistant", + parent_tool_use_id: TOOL_USE_ID, + session_id: "replay-session", + message: { + id: "msg_resumed_child", + role: "assistant", + model: "claude-opus-5", + content: [{ type: "text", text: "summary again" }], + }, + }, + { + type: "system", + subtype: "task_updated", + task_id: AGENT_ID, + patch: { status: "completed" }, + }, + { + type: "result", + subtype: "success", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + total_cost_usd: 0, + }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger, + queryFactory: liveQuery, + resolveVersion: async () => "2.1.309", + resolveBinary: async () => "/test/claude/bin", + }).resumeSession({ provider: "claude", sessionId: "replay-session" }, { cwd }); + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Ask it to summarize again")) { + events.push(event); + if (event.type === "turn_completed" || event.type === "turn_failed") break; + } + await session.close(); + + const live = events.flatMap((event) => + event.type === "provider_subagent" ? [event.event] : [], + ); + expect(new Set(live.map((event) => event.id))).toEqual(new Set([TOOL_USE_ID])); + expect(live).toContainEqual( + expect.objectContaining({ + type: "upsert", + id: TOOL_USE_ID, + subtitle: "general-purpose · Opus 5 · High", + }), + ); + expect(live).toContainEqual( + expect.objectContaining({ + type: "timeline", + id: TOOL_USE_ID, + item: expect.objectContaining({ type: "assistant_message", text: "summary again" }), + }), + ); + expect(live.at(-1)).toMatchObject({ type: "upsert", id: TOOL_USE_ID, status: "completed" }); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index 84293afa6a..f219baa1b1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts @@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { queryFactory.mockReset(); }); - test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => { + test("labels the Task card from its input and preserves callId lifecycle collapse", async () => { const session = await new ClaudeAgentClient({ logger, queryFactory, @@ -307,9 +307,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { expect(latest.detail.subAgentType).toBe("Explore"); expect(latest.detail.description).toBe("Inspect repository structure"); - expect(latest.detail.log).toContain("[Read] README.md"); - expect(latest.detail.log).toContain("[Edit] src/index.ts"); - expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING"); + expect(latest.detail.log).toBe(""); const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({ seq: index + 1, @@ -396,10 +394,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { .map((event) => event.item) .at(-1); - expect(latestSubAgentUpdate?.detail).toMatchObject({ - type: "sub_agent", - log: expect.stringContaining("[Read] README.md"), - }); + expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" }); }); test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => { @@ -553,7 +548,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { ).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" }); }); - test("tails sub-agent actions instead of dropping latest entries at cap", async () => { + test("keeps the Task card the same size while the child runs", async () => { queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205))); const session = await new ClaudeAgentClient({ @@ -568,24 +563,27 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { const events = await collectUntilTerminal(streamSession(session, "delegate work")); await session.close(); - const timelineToolCalls = events - .filter( - (event): event is Extract => - event.type === "timeline" && event.item.type === "tool_call", + const runningCards = events + .flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.callId === "task-tail-1" && + event.item.status === "running" && + event.item.detail.type === "sub_agent" + ? [event.item] + : [], ) - .map((event) => event.item) - .filter((item) => item.callId === "task-tail-1"); - const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent"); - const latest = subAgentUpdates[subAgentUpdates.length - 1]; - expect(latest).toBeDefined(); - if (!latest || latest.detail.type !== "sub_agent") { - throw new Error("expected sub_agent detail"); - } + .map((item) => JSON.stringify(item)); + expect(runningCards).toHaveLength(1); - expect(latest.detail.log).not.toContain("[Read] file-1.md"); - expect(latest.detail.log).not.toContain("[Read] file-5.md"); - expect(latest.detail.log).toContain("[Read] file-6.md"); - expect(latest.detail.log).toContain("[Read] file-205.md"); + const childToolCalls = events.filter( + (event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === "task-tail-1" && + event.event.item.type === "tool_call", + ); + expect(childToolCalls).toHaveLength(205); }); test("keeps a long main-thread tool call out of the subagents track", async () => { 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 2b2c725269..758fad2e8f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -32,7 +32,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages"; import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js"; interface TestClaudeSession { - translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; + translateMessageToEvents(message: SDKMessage): Promise; close(): Promise; } @@ -53,7 +53,7 @@ afterEach(() => { }); describe("convertClaudeHistoryEntry", () => { - test("maps user tool results to timeline items", () => { + test("maps user tool results to timeline items", async () => { const toolUseId = "toolu_test"; const entry = { type: "user", @@ -79,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => { ]; const mapBlocks = vi.fn().mockReturnValue(stubTimeline); - const result = convertClaudeHistoryEntry(entry, mapBlocks); + const result = await convertClaudeHistoryEntry(entry, mapBlocks); expect(result).toEqual(stubTimeline); expect(mapBlocks).toHaveBeenCalledTimes(1); expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true); }); - test("replays persisted Claude tool results as completed tool calls", () => { + test("replays persisted Claude tool results as completed tool calls", async () => { const entry = { type: "user", message: { @@ -124,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => { const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall; - expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual( + expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual( completedToolCall, ); }); - test("returns user messages when no tool blocks exist", () => { + test("returns user messages when no tool blocks exist", async () => { const entry = { type: "user", message: { @@ -138,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => { }, }; - expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ { type: "user_message", text: "Run npm test", @@ -146,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => { ]); }); - test("converts compact boundary metadata variants", () => { + test("converts compact boundary metadata variants", async () => { const fixtures = [ { entry: { @@ -175,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => { ] as const; for (const fixture of fixtures) { - expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([ { type: "compaction", status: "completed", @@ -186,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => { } }); - test("skips synthetic user entries", () => { + test("skips synthetic user entries", async () => { const entry = { type: "user", isSynthetic: true, @@ -197,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => { }; const mapBlocks = vi.fn().mockReturnValue([]); - const result = convertClaudeHistoryEntry(entry, mapBlocks); + const result = await convertClaudeHistoryEntry(entry, mapBlocks); expect(result).toEqual([]); expect(mapBlocks).not.toHaveBeenCalled(); }); - test("skips meta user entries from Claude skill loading", () => { + test("skips meta user entries from Claude skill loading", async () => { const entry = { type: "user", isMeta: true, @@ -219,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => { }; const mapBlocks = vi.fn().mockReturnValue([]); - const result = convertClaudeHistoryEntry(entry, mapBlocks); + const result = await convertClaudeHistoryEntry(entry, mapBlocks); expect(result).toEqual([]); expect(mapBlocks).not.toHaveBeenCalled(); }); - test("skips interrupt placeholder transcript noise", () => { + test("skips interrupt placeholder transcript noise", async () => { const interruptEntry = { type: "user", message: { @@ -246,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => { .fn() .mockReturnValue([{ type: "assistant_message", text: "No response requested." }]); - expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]); }); - test("skips messages (model switch, /context, etc.)", () => { + test("skips messages (model switch, /context, etc.)", async () => { // Real entries from Claude Code JSONL history files const modelSwitch = { type: "user", @@ -305,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => { const mapBlocks = vi.fn().mockReturnValue([]); - expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]); - expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]); + expect(await convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]); // Real user messages must NOT be filtered const realMessage = { type: "user", message: { role: "user", content: "fix the bug in auth.ts" }, }; - expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([ + expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([ { type: "user_message", text: "fix the bug in auth.ts" }, ]); }); - test("maps task notifications to synthetic tool calls", () => { + test("maps task notifications to synthetic tool calls", async () => { const entry = { type: "system", subtype: "task_notification", @@ -333,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => { output_file: "/tmp/bg-fail-1.txt", }; - expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ { type: "tool_call", callId: "task_notification_task-note-system-1", @@ -357,7 +357,7 @@ describe("convertClaudeHistoryEntry", () => { ]); }); - test("maps queue-operation task notifications to synthetic tool calls", () => { + test("maps queue-operation task notifications to synthetic tool calls", async () => { const entry = { type: "queue-operation", operation: "enqueue", @@ -372,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => { ].join("\n"), }; - expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([ { type: "tool_call", callId: "task_notification_task-note-queue-1", @@ -396,7 +396,7 @@ describe("convertClaudeHistoryEntry", () => { ]); }); - test("passes assistant content blocks through to the mapper", () => { + test("passes assistant content blocks through to the mapper", async () => { const entry = { type: "assistant", message: { @@ -414,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => { ]; const mapBlocks = vi.fn().mockReturnValue(mappedTimeline); - expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline); + expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline); expect(mapBlocks).toHaveBeenCalledWith(entry.message.content); }); }); @@ -887,6 +887,68 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + test("keeps an extraArgs settings file when Paseo sets Claude flag settings", async () => { + const settingsDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-")); + const settingsPath = path.join(settingsDir, "settings.json"); + await fs.writeFile( + settingsPath, + JSON.stringify({ env: { ENABLE_TOOL_SEARCH: "false" }, permissions: { deny: ["Monitor"] } }), + ); + const { queryFactory } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-opus-5-5", + providerOptions: { + extraArgs: { chrome: null, settings: settingsPath }, + }, + }); + + await session.startTurn("hello"); + + const options = queryFactory.mock.calls[0]?.[0].options; + expect(options?.extraArgs).toEqual({ chrome: null }); + expect(options?.settings).toEqual({ + env: { ENABLE_TOOL_SEARCH: "false" }, + permissions: { deny: ["Monitor"] }, + fastMode: false, + }); + await session.close(); + await fs.rm(settingsDir, { recursive: true, force: true }); + }); + + test("keeps inline extraArgs settings JSON for internal agents", async () => { + const { queryFactory } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + internal: true, + providerOptions: { + extraArgs: { settings: '{"env":{"ENABLE_TOOL_SEARCH":"false"}}' }, + }, + }); + + await session.startTurn("hello"); + + const options = queryFactory.mock.calls[0]?.[0].options; + expect(options?.extraArgs).toEqual({}); + expect(options?.settings).toEqual({ + env: { ENABLE_TOOL_SEARCH: "false" }, + disableAllHooks: true, + }); + await session.close(); + }); + test("lists fast mode only for supported Opus models", async () => { const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); @@ -1162,16 +1224,17 @@ describe("ClaudeAgentSession features", () => { const iterator = input[Symbol.asyncIterator](); await expect(iterator.next()).resolves.toMatchObject({ value: { type: "user" } }); - await expect( - session.steerActiveTurn?.("same turn follow-up", { - expectedTurnId: turnId, - clientMessageId: "steer-client-id", - }), - ).resolves.toEqual({ status: "accepted" }); + const admission = await session.steerActiveTurn?.("same turn follow-up", { + expectedTurnId: turnId, + clientMessageId: "steer-client-id", + }); + expect(admission).toEqual({ status: "accepted", providerMessageId: expect.any(String) }); + if (admission?.status !== "accepted") throw new Error("Expected steer admission"); await expect(iterator.next()).resolves.toMatchObject({ value: { type: "user", priority: "next", + uuid: admission.providerMessageId, message: { content: [{ type: "text", text: "same turn follow-up" }] }, }, }); @@ -1208,7 +1271,7 @@ describe("ClaudeAgentSession features", () => { input: Record, options: Record, ): Promise; - translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; + translateMessageToEvents(message: SDKMessage): Promise; }; try { @@ -1229,7 +1292,7 @@ describe("ClaudeAgentSession features", () => { expectedTurnId: turnId, clearPendingPermissions: true, }), - ).resolves.toEqual({ status: "accepted" }); + ).resolves.toEqual({ status: "accepted", providerMessageId: expect.any(String) }); await expect(firstPermission).resolves.toMatchObject({ behavior: "deny", interrupt: undefined, @@ -1248,7 +1311,7 @@ describe("ClaudeAgentSession features", () => { ), ).resolves.toMatchObject({ behavior: "deny", interrupt: undefined }); - internal.translateMessageToEvents({ + await internal.translateMessageToEvents({ type: "command_lifecycle", command_uuid: steerUuid, state: "started", @@ -1288,7 +1351,7 @@ describe("ClaudeAgentSession features", () => { const permission = internal.handlePermissionRequest("Write", {}, { toolUseID: "tool-1" }); await expect( session.steerActiveTurn?.("system notification", { expectedTurnId: turnId }), - ).resolves.toEqual({ status: "accepted" }); + ).resolves.toEqual({ status: "accepted", providerMessageId: expect.any(String) }); expect(session.getPendingPermissions()).toHaveLength(1); const requestId = session.getPendingPermissions()[0]!.id; await session.respondToPermission(requestId, { behavior: "deny", message: "test cleanup" }); @@ -2395,7 +2458,7 @@ describe("ClaudeAgentSession context window usage", () => { test("emits canonical task snapshots from Claude TaskCreate results", async () => { const session = await createSessionForTest(); try { - session.translateMessageToEvents({ + await session.translateMessageToEvents({ type: "assistant", message: { content: [ @@ -2409,7 +2472,7 @@ describe("ClaudeAgentSession context window usage", () => { }, } as unknown as SDKMessage); - const events = session.translateMessageToEvents({ + const events = await session.translateMessageToEvents({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }], @@ -3611,9 +3674,9 @@ describe("ClaudeAgentSession context window usage", () => { ]); try { - const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents( - createCompactBoundary(), - ); + const compactEvents = await ( + session as unknown as TestClaudeSession + ).translateMessageToEvents(createCompactBoundary()); expect(compactEvents).toContainEqual( expect.objectContaining({ type: "usage_updated", @@ -3799,7 +3862,7 @@ describe("ClaudeAgentSession context window usage", () => { test("result.result is surfaced as an assistant message when no model output was produced", async () => { const session = await createSessionForTest(); - const events = session.translateMessageToEvents({ + const events = await session.translateMessageToEvents({ type: "result", subtype: "success", result: "Unknown command: /foo-doesnt-exist", @@ -3834,7 +3897,7 @@ describe("ClaudeAgentSession context window usage", () => { test("result.result is not duplicated when the model produced output during the turn", async () => { const session = await createSessionForTest(); - const events = session.translateMessageToEvents({ + const events = await session.translateMessageToEvents({ type: "result", subtype: "success", result: "Here is the answer.", diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 71a279834d..d232185cbb 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1,3 +1,5 @@ +import { setImmediate } from "node:timers/promises"; +import { ClaudeRuntimeSafety } from "./runtime-safety.js"; import { validateProviderOptions } from "../../provider-options.js"; import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -31,7 +33,9 @@ import { mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationUserContentToToolCall, readTaskNotificationToolUseIdFromHistoryRecord, + readTaskNotificationFromHistoryRecord, } from "./task-notification-tool-call.js"; +import { isClaudeInterruptPlaceholderText } from "./interrupt-placeholder.js"; import { getClaudeContextWindowMaxTokens, getClaudeModelsWithSettings, @@ -44,6 +48,7 @@ import { parseClaudeCodeVersion, resolveClaudeDisabledThinkingForModel, } from "./model-manifest.js"; +import { expandTilde } from "../../../../utils/path.js"; import { parsePartialJsonObject } from "./partial-json.js"; import { withClaudeReplayHistory, type ClaudeReplayHistory } from "./transcript-history.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; @@ -54,6 +59,8 @@ import { type ClaudeHookObservationInput, } from "./subagents/live-source.js"; import { observeReplaySubagents, type ClaudeReplayParentFacts } from "./subagents/replay-source.js"; +import { mapTaskStatus } from "./subagents/task-status.js"; +import type { ProviderSubagentStatus } from "../../provider-subagents/store.js"; import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js"; import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js"; import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js"; @@ -75,13 +82,14 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { discoverClaudeModels } from "./model-discovery.js"; import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js"; import { + findSteeredMessageParent, realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles, type ClaudeRewindSdk, } from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; -import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js"; +import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js"; import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js"; import { isProviderImageMarkdown, @@ -423,7 +431,6 @@ const CLAUDE_ROOT_ONLY_COMMANDS = new Set([ "usage", ]); const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]"; -const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/; const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested."; const STEER_SUPERSEDED_PERMISSION_MESSAGE = "The user answered with a message instead of approving. Their message follows."; @@ -643,6 +650,40 @@ function readClaudeFastModeSetting(settings: ClaudeOptions["settings"]): boolean return typeof settings.fastMode === "boolean" ? settings.fastMode : null; } +type ClaudeSettingsObject = NonNullable>; + +// Mirrors `claude --settings`: the value is inline JSON or a path to a JSON file, +// resolved against the session cwd like the Claude process would. +async function readClaudeCliSettings(value: string, cwd: string): Promise { + const trimmed = value.trim(); + const isInlineJson = trimmed.startsWith("{"); + const source = isInlineJson ? "--settings JSON" : path.resolve(cwd, expandTilde(trimmed)); + let raw = trimmed; + if (!isInlineJson) { + try { + raw = await fsPromises.readFile(source, "utf8"); + } catch (error) { + throw new Error( + `Failed to read Claude settings file ${source}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error( + `Claude ${source} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Claude ${source} must contain a JSON object`); + } + return parsed as ClaudeSettingsObject; +} + function mergeClaudeSettings( settings: ClaudeOptions["settings"], updates: NonNullable>, @@ -787,11 +828,6 @@ function normalizeClaudeTranscriptText(value: unknown): string | null { return normalized.length > 0 ? normalized : null; } -function isClaudeInterruptPlaceholderText(value: unknown): boolean { - const normalized = normalizeClaudeTranscriptText(value); - return normalized !== null && INTERRUPT_PLACEHOLDER_PATTERN.test(normalized); -} - function isClaudeNoResponsePlaceholderText(value: unknown): boolean { return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER; } @@ -1633,7 +1669,7 @@ export class ClaudeAgentClient implements AgentClient { cwd: merged.cwd, }; const claudeConfig = this.assertConfig(mergedConfig); - return new ClaudeAgentSession(claudeConfig, { + const session = new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, runtimeSettings: this.runtimeSettings, handle, @@ -1644,6 +1680,8 @@ export class ClaudeAgentClient implements AgentClient { resolveBinary: this.resolveBinary, rewindSdk: this.rewindSdk, }); + await session.ensureHistoryReady(); + return session; } async getCatalogCacheKey(_options: FetchCatalogOptions): Promise { @@ -2255,6 +2293,8 @@ class ClaudeAgentSession implements AgentSession { private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; private query: Query | null = null; + private readonly runtimeSafety = new ClaudeRuntimeSafety(); + private processingSdkMessages = 0; private readonly harnessEnvironment: Record; private readonly usageSessionKey = randomUUID(); private childProcess: ChildProcess | null = null; @@ -2326,6 +2366,9 @@ class ClaudeAgentSession implements AgentSession { { type: "provider_subagent" } >[] = []; private historyPending = false; + // Readiness never rejects; history/rebind failures are logged at their owning boundary. + private historyReady: Promise | null = null; + private historySessionId: string | null = null; private replayingHistory = false; private turnState: TurnState = "idle"; private nextTurnOrdinal = 1; @@ -2379,7 +2422,7 @@ class ClaudeAgentSession implements AgentSession { } this.claudeSessionId = handle.sessionId; this.persistence = handle; - this.loadPersistedHistory(handle.sessionId); + this.historySessionId = handle.sessionId; } else { this.claudeSessionId = null; this.persistence = null; @@ -2459,6 +2502,7 @@ class ClaudeAgentSession implements AgentSession { prompt: AgentPromptInput, options?: AgentRunOptions, ): Promise<{ turnId: string }> { + await this.ensureHistoryReady(); if (this.closed) { throw new Error("Claude session is closed"); } @@ -2536,7 +2580,11 @@ class ClaudeAgentSession implements AgentSession { this.input.push(sdkMessage); setTimeout(() => { if (this.activeForegroundTurnId === turnId) { - this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId); + void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch( + (error: unknown) => { + this.logger.warn({ err: error }, "Failed to emit submitted Claude message"); + }, + ); } }, 0); } catch (error) { @@ -2580,7 +2628,7 @@ class ClaudeAgentSession implements AgentSession { return { status: "unavailable" }; } this.enqueueSteer(input, message, options.clearPendingPermissions === true); - return { status: "accepted" }; + return { status: "accepted", ...(message.uuid ? { providerMessageId: message.uuid } : {}) }; } private enqueueSteer( @@ -2629,6 +2677,7 @@ class ClaudeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { + await this.ensureHistoryReady(); if ( !this.historyPending || (this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0) @@ -2640,7 +2689,9 @@ class ClaudeAgentSession implements AgentSession { this.persistedHistory = []; this.persistedProviderSubagentEvents = []; this.historyPending = false; + let replayed = 0; for (const entry of history) { + if (++replayed % 32 === 0) await setImmediate(); yield { type: "timeline", item: entry.item, @@ -2931,6 +2982,18 @@ class ClaudeAgentSession implements AgentSession { return this.persistence; } + async canStopRuntime(): Promise { + return ( + !this.activeForegroundTurnId && + !this.autonomousTurn && + !this.mainTurnInFlight && + this.unstartedMessageUuids.size === 0 && + this.pendingPermissions.size === 0 && + this.processingSdkMessages === 0 && + this.runtimeSafety.canStopRuntime() + ); + } + async close(): Promise { this.logger.trace( { @@ -2946,6 +3009,7 @@ class ClaudeAgentSession implements AgentSession { "provider.claude.session_close.start", ); this.closed = true; + await this.ensureHistoryReady(); this.rejectAllPendingPermissions(new Error("Claude session closed")); this.cancelCurrentTurn?.(); this.subscribers.clear(); @@ -2986,7 +3050,7 @@ class ClaudeAgentSession implements AgentSession { // (see `claude --help`), so the SDK's persistSession=false is silently dropped // in stream-json mode. Sweep the transcript ourselves so ephemeral runs // (metadata generator, branch-name generator) don't show up as resumable. - const historyPath = this.resolveHistoryPath(this.claudeSessionId); + const historyPath = await this.resolveHistoryPath(this.claudeSessionId); if (historyPath) { try { await promises.rm(historyPath, { force: true }); @@ -3030,7 +3094,8 @@ class ClaudeAgentSession implements AgentSession { } async revertConversation(input: { messageId: string }): Promise { - const target = this.resolveConversationRewindTarget(input.messageId); + await this.ensureHistoryReady(); + const target = await this.resolveConversationRewindTarget(input.messageId); if (target.kind === "fresh-session") { this.startFreshConversationSession(); return; @@ -3039,16 +3104,15 @@ class ClaudeAgentSession implements AgentSession { await revertClaudeConversation({ sdk: this.rewindSdk, sessionId: currentSessionId, - transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null, + transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null, messageId: target.messageId, resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId), - setSessionId: (sessionId) => { - this.rebindConversationSession(sessionId); - }, + setSessionId: (sessionId) => this.rebindConversationSession(sessionId), }); } async revertFiles(input: { messageId: string }): Promise { + await this.ensureHistoryReady(); const messageId = await this.resolveClaudeMessageId(input.messageId); await revertClaudeFiles({ query: await this.ensureQuery(), @@ -3223,38 +3287,52 @@ class ClaudeAgentSession implements AgentSession { return candidates; } - private rebindConversationSession(sessionId: string): void { - const oldSessionId = this.claudeSessionId; - this.claudeSessionId = sessionId; - this.pendingFreshSessionId = null; - this.persistence = null; - this.cachedRuntimeInfo = null; - this.queryRestartNeeded = true; - this.persistedHistory = []; - this.persistedProviderSubagentEvents = []; - this.historyPending = false; - this.userMessageIds = []; - this.emittedUserMessageIds.clear(); - this.rewindTurnAnchors.length = 0; - this.taskState.reset(); - this.loadPersistedHistory(sessionId); - if (oldSessionId && oldSessionId !== sessionId) { - this.dispatchEvents([ - { - type: "timeline", - provider: "claude", - item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId), - }, - { - type: "thread_started", - provider: "claude", - sessionId, - }, - ]); - } + private rebindConversationSession(sessionId: string): Promise { + const previousReady = this.ensureHistoryReady(); + const rebind = (async () => { + await previousReady; + await this.retireQuery(); + if (this.closed) return; + const oldSessionId = this.claudeSessionId; + this.claudeSessionId = sessionId; + this.pendingFreshSessionId = null; + this.persistence = null; + this.cachedRuntimeInfo = null; + this.queryRestartNeeded = true; + this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; + this.historyPending = false; + this.userMessageIds = []; + this.emittedUserMessageIds.clear(); + this.rewindTurnAnchors.length = 0; + this.taskState.reset(); + this.historySessionId = sessionId; + await this.loadPersistedHistory(sessionId); + if (this.closed) return; + if (oldSessionId && oldSessionId !== sessionId) { + this.dispatchEvents([ + { + type: "timeline", + provider: "claude", + item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId), + }, + { + type: "thread_started", + provider: "claude", + sessionId, + }, + ]); + } + })(); + this.historyReady = rebind.catch((error) => { + this.logger.warn({ err: error, sessionId }, "Failed to rebind Claude session"); + }); + return rebind; } private startFreshConversationSession(): void { + this.historySessionId = null; + this.historyReady = Promise.resolve(); const sessionId = randomUUID(); this.claudeSessionId = sessionId; this.pendingFreshSessionId = sessionId; @@ -3352,12 +3430,24 @@ class ClaudeAgentSession implements AgentSession { return messageId; } - private resolveConversationRewindTarget(messageId: string): ClaudeConversationRewindTarget { + private async resolveConversationRewindTarget( + messageId: string, + ): Promise { const targetUserMessageId = this.resolveClaudeMessageId(messageId); const index = this.rewindTurnAnchors.findIndex( (anchor) => anchor.userMessageId === targetUserMessageId, ); if (index < 0) { + // A message steered into a running turn opens no turn of its own. + const transcriptPath = this.claudeSessionId + ? await this.resolveHistoryPath(this.claudeSessionId) + : null; + const steerParent = transcriptPath + ? await findSteeredMessageParent(transcriptPath, targetUserMessageId) + : null; + if (steerParent) { + return { kind: "fork", messageId: steerParent }; + } throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`); } @@ -3375,7 +3465,43 @@ class ClaudeAgentSession implements AgentSession { return { kind: "fresh-session" }; } + private async retireQuery(): Promise { + const oldQuery = this.query; + if (!oldQuery) return; + const oldInput = this.input; + // Null out query/input BEFORE awaiting the old iterator's return so the + // old pump sees this.query !== activeQuery and skips failActiveTurns. + this.query = null; + this.input = null; + this.queryPumpPromise = null; + this.queryRestartNeeded = false; + // Ending the input retires the process on purpose. Detach first so its + // exit is not reported as a crash. + const retiredChild = this.childProcess; + this.childProcess = null; + if (retiredChild) this.failRunningRuntimeTasks(); + oldInput?.end(); + oldQuery.close?.(); + try { + await oldQuery.return?.(); + } catch { + /* ignore */ + } + // Tree-kill the old process tree now that the SDK has cleaned up. + // If we skip this, MCP children of the previous claude process can + // survive as orphans when the session spawns a replacement query. + if (retiredChild) { + await terminateWithTreeKill(retiredChild, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }).catch(() => { + /* process may already be dead */ + }); + } + } + private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise { + await this.ensureHistoryReady(); // Controls use the running turn's query. Pending launch settings take effect // after that turn finishes, when its active query handle has been cleared. if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) { @@ -3383,37 +3509,7 @@ class ClaudeAgentSession implements AgentSession { } if (this.queryRestartNeeded && this.query) { - const oldQuery = this.query; - const oldInput = this.input; - // Null out query/input BEFORE awaiting the old iterator's return so the - // old pump sees this.query !== activeQuery and skips failActiveTurns. - this.query = null; - this.input = null; - this.queryPumpPromise = null; - this.queryRestartNeeded = false; - // Ending the input retires the process on purpose. Detach first so its - // exit is not reported as a crash. - const retiredChild = this.childProcess; - this.childProcess = null; - if (retiredChild) this.failRunningRuntimeTasks(); - oldInput?.end(); - oldQuery.close?.(); - try { - await oldQuery.return?.(); - } catch { - /* ignore */ - } - // Tree-kill the old process tree now that the SDK has cleaned up. - // If we skip this, MCP children of the previous claude process can - // survive as orphans when the session spawns a replacement query. - if (retiredChild) { - await terminateWithTreeKill(retiredChild, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }).catch(() => { - /* process may already be dead */ - }); - } + await this.retireQuery(); } // Preserve claudeSessionId across query recreation so buildOptions() passes @@ -3424,6 +3520,7 @@ class ClaudeAgentSession implements AgentSession { const options = await this.buildOptions(launchMode); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; + this.runtimeSafety.reset(); // A fresh Claude process has no turn of its own in flight. this.mainTurnInFlight = false; this.query = claudeQuery( @@ -3561,7 +3658,7 @@ class ClaudeAgentSession implements AgentSession { this.config.providerOptions, this.config.toolPolicy, ); - const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode }); + const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode }); const sdkEnv = this.harnessEnvironment; assertClaudeModeCanRun(permissionMode, sdkEnv); @@ -3646,10 +3743,10 @@ class ClaudeAgentSession implements AgentSession { return base; } - private buildSettingsOptions( + private async buildSettingsOptions( providerOptions: ClaudeProviderOptions, input: { ultracode: boolean }, - ): Pick | Record { + ): Promise | Record> { const fastMode = this.resolveFastModeSetting(); // Internal agents do daemon work such as naming a branch, so the user's and // project's hooks must not run for them. @@ -3657,12 +3754,21 @@ class ClaudeAgentSession implements AgentSession { if (fastMode === null && !input.ultracode && !disableAllHooks) { return {}; } + const updates = { + ...(fastMode === null ? {} : { fastMode }), + ...(input.ultracode ? { ultracode: true } : {}), + ...(disableAllHooks ? { disableAllHooks: true } : {}), + }; + // The SDK replaces extraArgs.settings with Options.settings, so a user's + // `--settings` would be dropped whenever Paseo sets flag settings. Fold it in. + const { settings: extraArgsSettings, ...extraArgs } = providerOptions.extraArgs ?? {}; + if (typeof extraArgsSettings !== "string") { + return { settings: mergeClaudeSettings(providerOptions.settings, updates) }; + } + const cliSettings = await readClaudeCliSettings(extraArgsSettings, this.config.cwd); return { - settings: mergeClaudeSettings(providerOptions.settings, { - ...(fastMode === null ? {} : { fastMode }), - ...(input.ultracode ? { ultracode: true } : {}), - ...(disableAllHooks ? { disableAllHooks: true } : {}), - }), + extraArgs, + settings: { ...cliSettings, ...providerOptions.settings, ...updates }, }; } @@ -4079,13 +4185,19 @@ class ClaudeAgentSession implements AgentSession { ); }; const handlePumpedMessage = async (message: SDKMessage): Promise => { - logRawMessage(message); - consecutiveInterruptAbortRecoveries = 0; - if (await this.handleMissingResumedConversation(message, activeQuery)) { - return true; + if (!this.ownsQuery(activeQuery)) return true; + this.processingSdkMessages++; + try { + this.runtimeSafety.observe(message); + logRawMessage(message); + consecutiveInterruptAbortRecoveries = 0; + if (await this.handleMissingResumedConversation(message, activeQuery)) return true; + if (!this.ownsQuery(activeQuery)) return true; + await this.routeSdkMessageFromPump(message); + return false; + } finally { + this.processingSdkMessages--; } - await this.routeSdkMessageFromPump(message); - return false; }; const drainActiveQuery = async (): Promise => { for await (const message of activeQuery) { @@ -4201,6 +4313,10 @@ class ClaudeAgentSession implements AgentSession { return isClaudeTurnInit(message) && !this.activeForegroundTurnId; } + private ownsQuery(query: Query): boolean { + return !this.closed && this.query === query; + } + private async routeSdkMessageFromPump(message: SDKMessage): Promise { if (this.consumeSessionStateChange(message)) { return; @@ -4275,10 +4391,17 @@ class ClaudeAgentSession implements AgentSession { messageIdHint: string | null, turnId: string | null, ): Promise { - const messageEvents = this.translateMessageToEvents(message, { + const query = this.query; + const messageEvents = await this.translateMessageToEvents(message, { suppressAssistantText: true, suppressReasoning: true, }); + if ( + this.closed || + this.query !== query || + (this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null) !== turnId + ) + return []; const assistantTimelineEvents = readClaudeParentToolUseId(message) ? [] : this.timelineAssembler @@ -4423,13 +4546,13 @@ class ClaudeAgentSession implements AgentSession { return withdrewAll; } - private translateMessageToEvents( + private async translateMessageToEvents( message: SDKMessage, options?: { suppressAssistantText?: boolean; suppressReasoning?: boolean; }, - ): AgentStreamEvent[] { + ): Promise { const parentToolUseId = readClaudeParentToolUseId(message); if (parentToolUseId) { return this.translateSidechainFrameToEvents(message, parentToolUseId); @@ -4477,11 +4600,11 @@ class ClaudeAgentSession implements AgentSession { this.appendSystemMessageEvents(message, events); break; case "user": - this.appendUserMessageEvents(message, events); + await this.appendUserMessageEvents(message, events); this.appendSidechainResultEvents(message, events); break; case "assistant": { - const timelineItems = this.mapBlocksToTimeline(message.message.content, { + const timelineItems = await this.mapBlocksToTimeline(message.message.content, { suppressAssistantText: options?.suppressAssistantText ?? false, suppressReasoning: options?.suppressReasoning ?? false, }); @@ -4492,7 +4615,7 @@ class ClaudeAgentSession implements AgentSession { break; } case "stream_event": - this.appendStreamEventEvents(message, events, options); + await this.appendStreamEventEvents(message, events, options); break; case "result": this.appendResultEvents(message, events); @@ -4557,7 +4680,7 @@ class ClaudeAgentSession implements AgentSession { /** * The Task card in the parent transcript, built from the declaration. * - * The sidechain tracker also emits this card, enriched with the child's action log, and the two + * The sidechain tracker also emits this card when it first sees the child, and the two * collapse on the shared tool-call id. Emitting it at declaration matters because a * backgrounded subagent produces no sidechain frames at all — verified on the wire — so the * tracker never runs for one and its card would otherwise stay an unlabeled "Task". @@ -4594,13 +4717,14 @@ class ClaudeAgentSession implements AgentSession { } } - private emitSubmittedUserMessage( + private async emitSubmittedUserMessage( message: Extract, turnId: string, clientMessageId?: string, - ): void { + ): Promise { const events: AgentStreamEvent[] = []; - this.appendUserMessageEvents(message, events); + await this.appendUserMessageEvents(message, events); + if (this.activeForegroundTurnId !== turnId) return; if (events.length === 0) { return; } @@ -4730,10 +4854,10 @@ class ClaudeAgentSession implements AgentSession { } } - private appendUserMessageEvents( + private async appendUserMessageEvents( message: Extract, events: AgentStreamEvent[], - ): void { + ): Promise { if (isSyntheticUserEntry(message)) { return; } @@ -4772,7 +4896,7 @@ class ClaudeAgentSession implements AgentSession { return; } if (Array.isArray(content)) { - this.appendUserContentArrayEvents(content, messageId, events); + await this.appendUserContentArrayEvents(content, messageId, events); } } @@ -4798,12 +4922,12 @@ class ClaudeAgentSession implements AgentSession { events.push({ type: "timeline", item, provider: "claude" }); } - private appendUserContentArrayEvents( + private async appendUserContentArrayEvents( content: ReadonlyArray, messageId: string | undefined, events: AgentStreamEvent[], - ): void { - const timelineItems = this.mapBlocksToTimeline(content, { + ): Promise { + const timelineItems = await this.mapBlocksToTimeline(content, { textMessageType: "user_message", }); for (const item of timelineItems) { @@ -4819,16 +4943,16 @@ class ClaudeAgentSession implements AgentSession { } } - private appendStreamEventEvents( + private async appendStreamEventEvents( message: Extract, events: AgentStreamEvent[], options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined, - ): void { + ): Promise { const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event); if (usageUpdatedEvent) { events.push(usageUpdatedEvent); } - const timelineItems = this.mapPartialEvent(message.event, { + const timelineItems = await this.mapPartialEvent(message.event, { suppressAssistantText: options?.suppressAssistantText ?? false, suppressReasoning: options?.suppressReasoning ?? false, }); @@ -5252,22 +5376,31 @@ class ClaudeAgentSession implements AgentSession { } } - private loadPersistedHistory(sessionId: string): void { + ensureHistoryReady(): Promise { + if (!this.historyReady) { + this.historyReady = this.historySessionId + ? this.loadPersistedHistory(this.historySessionId) + : Promise.resolve(); + } + return this.historyReady; + } + + private async loadPersistedHistory(sessionId: string): Promise { let historyPath: string | null = null; this.replayingHistory = true; try { this.taskState.reset(); - historyPath = this.resolveHistoryPath(sessionId); - if (!historyPath || !fs.existsSync(historyPath)) { + historyPath = await this.resolveHistoryPath(sessionId); + if (!historyPath || !(await transcriptExists(historyPath))) { this.logger.info( { sessionId, cwd: this.config.cwd, historyPath }, "No Claude transcript to load history from", ); return; } - withClaudeReplayHistory(historyPath, (history) => { - const replay = this.ingestPersistedSidechains(history); - this.ingestPersistedHistory(history.parentEntries, replay); + await withClaudeReplayHistory(historyPath, async (history) => { + const replay = await this.ingestPersistedSidechains(history); + await this.ingestPersistedHistory(history.parentEntries, replay); }); } catch (error) { this.logger.warn( @@ -5279,13 +5412,13 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedHistory( - entries: Iterable, + private async ingestPersistedHistory( + entries: Iterable | AsyncIterable, replay: ClaudeReplayOwnership, - ): void { + ): Promise { const timeline: PersistedTimelineEntry[] = []; - for (const entry of entries) { - this.ingestPersistedHistoryEntry(entry, timeline, replay); + for await (const entry of entries) { + await this.ingestPersistedHistoryEntry(entry, timeline, replay); } if (timeline.length > 0) { @@ -5294,28 +5427,35 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership { - const parentFacts = readClaudeReplayParentFacts(history.parentEntries); + private async ingestPersistedSidechains( + history: ClaudeReplayHistory, + ): Promise { + const parentFacts = await readClaudeReplayParentFacts(history.parentEntries); // Replay produces the same observations the live task protocol produces, then folds them // with the same function, so identity and status are derived once for both paths. - const subagentReplay = observeReplaySubagents({ - subagents: history.subagents.map((subagent) => ({ + const subagents = []; + for (const subagent of history.subagents) { + subagents.push({ ...subagent, - parentFacts: readClaudeReplayParentFacts(subagent.entries), - })), + parentFacts: await readClaudeReplayParentFacts(subagent.entries), + }); + } + const subagentReplay = await observeReplaySubagents({ + subagents, parent: parentFacts, convertEntry: (entry) => this.convertHistoryEntry(entry), }); const observations = [ ...subagentReplay.observations, - ...observeReplayWorkflows({ + ...(await observeReplayWorkflows({ workflows: history.workflows, parentEntries: history.parentEntries, entriesByRunId: history.workflowEntriesByRunId, convertEntry: (entry) => this.convertHistoryEntry(entry), - }), + })), ]; + this.taskProtocolSource.restore(subagentReplay.restored); const restoredProviderSubagentIds = new Set( observations .filter((observation) => observation.kind === "declared") @@ -5339,11 +5479,11 @@ class ClaudeAgentSession implements AgentSession { return replay; } - private ingestPersistedHistoryEntry( + private async ingestPersistedHistoryEntry( entry: ClaudeHistoryEntry, timeline: PersistedTimelineEntry[], replay: ClaudeReplayOwnership, - ): void { + ): Promise { if (entry.isSidechain) { return; } @@ -5357,7 +5497,7 @@ class ClaudeAgentSession implements AgentSession { ? replay.toolOwners.get(notificationToolUseId) : undefined; if (notificationOwner) { - for (const item of this.convertHistoryEntry(entry)) { + for (const item of await this.convertHistoryEntry(entry)) { this.persistedProviderSubagentEvents.push({ type: "provider_subagent", provider: "claude", @@ -5374,7 +5514,7 @@ class ClaudeAgentSession implements AgentSession { const taskSnapshot = this.taskState.observe(entry); const items = [ ...(taskSnapshot ? [taskSnapshot] : []), - ...this.convertHistoryEntry(entry).map((item) => + ...(await this.convertHistoryEntry(entry)).map((item) => labelReplayedSubagentCard(item, replay.subagentToolCalls), ), ]; @@ -5401,20 +5541,20 @@ class ClaudeAgentSession implements AgentSession { } } - private resolveHistoryPath(sessionId: string): string | null { + private async resolveHistoryPath(sessionId: string): Promise { const cwd = this.config.cwd; if (!cwd) return null; - return claudeTranscriptPathSync({ + return claudeTranscriptPath({ cwd, sessionId, configDir: claudeConfigDir(this.harnessEnvironment), }); } - private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] { - return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map( - limitAgentTimelineItemContent, - ); + private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise { + return ( + await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)) + ).map(limitAgentTimelineItemContent); } // Maps Claude content blocks into AgentTimelineItems. @@ -5429,14 +5569,14 @@ class ClaudeAgentSession implements AgentSession { // // NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function // for user entries. Both paths must produce equivalent user_message items. - private mapBlocksToTimeline( + private async mapBlocksToTimeline( content: string | ReadonlyArray, options?: { textMessageType?: "assistant_message" | "user_message"; suppressAssistantText?: boolean; suppressReasoning?: boolean; }, - ): AgentTimelineItem[] { + ): Promise { const textMessageType = options?.textMessageType ?? "assistant_message"; const suppressText = textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false); @@ -5463,7 +5603,7 @@ class ClaudeAgentSession implements AgentSession { if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) { continue; } - this.mapBlockToTimeline(block, { + await this.mapBlockToTimeline(block, { items, userTextParts, textMessageType, @@ -5508,7 +5648,7 @@ class ClaudeAgentSession implements AgentSession { } } - private mapBlockToTimeline( + private async mapBlockToTimeline( block: ClaudeContentChunk, context: { items: AgentTimelineItem[]; @@ -5517,7 +5657,7 @@ class ClaudeAgentSession implements AgentSession { suppressText: boolean; suppressReasoning: boolean; }, - ): void { + ): Promise { switch (block.type) { case "text": case "text_delta": @@ -5541,7 +5681,7 @@ class ClaudeAgentSession implements AgentSession { case "code_execution_tool_result": case "bash_code_execution_tool_result": case "text_editor_code_execution_tool_result": - this.handleToolResult(block, context.items); + await this.handleToolResult(block, context.items); break; default: break; @@ -5587,7 +5727,10 @@ class ClaudeAgentSession implements AgentSession { ); } - private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { + private async handleToolResult( + block: ClaudeContentChunk, + items: AgentTimelineItem[], + ): Promise { const entry = typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined; const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined; @@ -5620,7 +5763,7 @@ class ClaudeAgentSession implements AgentSession { this.rememberSettledSubagentToolCall(settledToolCall, entry); for (const image of images) { - const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, { + const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, { materialize: materializeProviderImage, }); if (imageItem) { @@ -5844,13 +5987,13 @@ class ClaudeAgentSession implements AgentSession { return false; } - private mapPartialEvent( + private async mapPartialEvent( event: SDKPartialAssistantMessage["event"], options?: { suppressAssistantText?: boolean; suppressReasoning?: boolean; }, - ): AgentTimelineItem[] { + ): Promise { if (this.updatePartialEventToolState(event)) { return []; } @@ -5858,14 +6001,14 @@ class ClaudeAgentSession implements AgentSession { switch (event.type) { case "content_block_start": return isClaudeContentChunk(event.content_block) - ? this.mapBlocksToTimeline([event.content_block], { + ? await this.mapBlocksToTimeline([event.content_block], { suppressAssistantText: options?.suppressAssistantText, suppressReasoning: options?.suppressReasoning, }) : []; case "content_block_delta": return isClaudeContentChunk(event.delta) - ? this.mapBlocksToTimeline([event.delta], { + ? await this.mapBlocksToTimeline([event.delta], { suppressAssistantText: options?.suppressAssistantText, suppressReasoning: options?.suppressReasoning, }) @@ -6147,6 +6290,7 @@ function buildClaudeSubagentCardDetail( type: "sub_agent", ...(facts.title ? { subAgentType: facts.title } : {}), ...(facts.description ? { description: facts.description } : {}), + // COMPAT(subagent-log): remove after 2027-04-30. log: "", actions: [], }; @@ -6171,37 +6315,38 @@ function labelReplayedSubagentCard( * source consumes. The agentId scrape is kept only as a fallback for sessions recorded before * Claude Code wrote the meta sidecar. */ -function readClaudeReplayParentFacts( - entries: Iterable, -): ClaudeReplayParentFacts { +async function readClaudeReplayParentFacts( + entries: Iterable | AsyncIterable, +): Promise { const toolCalls = new Map(); - const linksByAgentId = new Map(); - const outcomes = new Map(); - for (const entry of entries) { + const linksByAgentId = new Map(); + const outcomes: Map = new Map(); + for await (const entry of entries) { + const notification = readTaskNotificationFromHistoryRecord(entry); + const status = mapTaskStatus(notification?.status ?? undefined); + if (notification?.toolUseId && status) { + outcomes.set(notification.toolUseId, { status }); + } const content = toObjectRecord(entry.message)?.content; if (!Array.isArray(content)) continue; for (const value of content) { const block = toObjectRecord(value); + const declaration = readClaudeReplayTaskDeclaration(entry.type, block); + if (declaration) toolCalls.set(declaration.id, declaration.identity); if ( - block?.type === "tool_use" && - (block.name === "Task" || block.name === "Agent") && - typeof block.id === "string" - ) { - const input = toObjectRecord(block.input); - const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type); - const description = readNonEmptyString(input?.description); - toolCalls.set(block.id, { - ...(title ? { title } : {}), - ...(description ? { description } : {}), + entry.type !== "user" || + block?.type !== "tool_result" || + typeof block.tool_use_id !== "string" + ) + continue; + if (toObjectRecord(entry.toolUseResult)?.status !== "async_launched") + outcomes.set(block.tool_use_id, { + status: block.is_error === true ? "failed" : "completed", }); - } - if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; - outcomes.set(block.tool_use_id, { failed: block.is_error === true }); const agentId = readLegacyClaudeSubagentId(block.content); if (agentId) linksByAgentId.set(agentId, { toolCallId: block.tool_use_id, - failed: block.is_error === true, }); } } @@ -6212,6 +6357,29 @@ function readClaudeReplayParentFacts( }; } +function readClaudeReplayTaskDeclaration( + entryType: unknown, + block: Record | undefined, +): { + id: string; + identity: { title?: string; description?: string }; +} | null { + if ( + entryType !== "assistant" || + block?.type !== "tool_use" || + (block.name !== "Task" && block.name !== "Agent") || + typeof block.id !== "string" + ) + return null; + const input = toObjectRecord(block.input); + const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type); + const description = readNonEmptyString(input?.description); + return { + id: block.id, + identity: { ...(title ? { title } : {}), ...(description ? { description } : {}) }, + }; +} + function readLegacyClaudeSubagentId(content: unknown): string | undefined { return /agentId:\s*([\w-]+)/.exec(JSON.stringify(content))?.[1]; } @@ -6228,12 +6396,14 @@ interface ClaudeHistoryEntry { [key: string]: unknown; } -function mapAssistantHistoryBlocksWithMessageId( +async function mapAssistantHistoryBlocksWithMessageId( entry: ClaudeHistoryEntry, content: string | ClaudeContentChunk[], - mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[], -): AgentTimelineItem[] { - const items = mapBlocks(content); + mapBlocks: ( + content: string | ClaudeContentChunk[], + ) => AgentTimelineItem[] | Promise, +): Promise { + const items = await mapBlocks(content); const assistantMessageId = typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null; if (!assistantMessageId) { @@ -6275,6 +6445,10 @@ function convertClaudeHistoryEntryPreamble( if (entry.type === "user" && isSyntheticHistoryUserEntry(entry)) { return { shortCircuit: [] }; } + const steeredPrompt = readSteeredPromptHistoryItem(entry); + if (steeredPrompt) { + return { shortCircuit: [steeredPrompt] }; + } // Only user and assistant records are conversation turns. Claude Code 2.1.x also journals each // API request as `api-request-blob` records whose message replays the system context. if (entry.type !== "user" && entry.type !== "assistant") { @@ -6301,10 +6475,27 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean { return item.type === "assistant_message" && isProviderImageMarkdown(item.text); } -export function convertClaudeHistoryEntry( +/** + * A message steered into a running turn. Claude Code gives it no user entry: it records a + * queued_command attachment in prompt mode, under the uuid Paseo minted for the message, which is + * also what a rewind to it resolves. The other modes carry task notifications, not user messages. + */ +function readSteeredPromptHistoryItem(entry: ClaudeHistoryEntry): AgentTimelineItem | null { + if (entry.type !== "attachment") return null; + const attachment = toObjectRecord(entry.attachment); + if (attachment?.type !== "queued_command" || attachment.commandMode !== "prompt") return null; + const text = extractUserMessageText(attachment.prompt); + if (!text) return null; + const messageId = readTrimmedString(attachment.source_uuid); + return { type: "user_message", text, ...(messageId ? { messageId } : {}) }; +} + +export async function convertClaudeHistoryEntry( entry: ClaudeHistoryEntry, - mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[], -): AgentTimelineItem[] { + mapBlocks: ( + content: string | ClaudeContentChunk[], + ) => AgentTimelineItem[] | Promise, +): Promise { const preamble = convertClaudeHistoryEntryPreamble(entry); if ("shortCircuit" in preamble) { return preamble.shortCircuit; @@ -6343,7 +6534,7 @@ export function convertClaudeHistoryEntry( } if (hasToolBlock && normalizedBlocks) { - const mapped = mapBlocks(normalizedBlocks); + const mapped = await mapBlocks(normalizedBlocks); if (entry.type === "user") { // tool_result handling (handleToolResult) emits image markdown as an assistant_message // alongside the tool_call. User-entry text blocks also map to assistant_message in this path @@ -6357,7 +6548,7 @@ export function convertClaudeHistoryEntry( } if (entry.type === "assistant" && contentValue) { - return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks); + return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks); } return timeline; @@ -6709,7 +6900,7 @@ async function readClaudeRenamedSessionTitle(input: { }): Promise { let content: string; try { - content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8"); + content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8"); } catch (error) { // A session with no transcript on disk has no rename to carry over. Every // other read failure is a real fault and stays explicit. @@ -6809,3 +7000,13 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle | state: record.state as ClaudeCommandLifecycle["state"], }; } + +async function transcriptExists(file: string): Promise { + try { + await fs.promises.access(file); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} diff --git a/packages/server/src/server/agent/providers/claude/interrupt-placeholder.ts b/packages/server/src/server/agent/providers/claude/interrupt-placeholder.ts new file mode 100644 index 0000000000..f74c899234 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/interrupt-placeholder.ts @@ -0,0 +1,6 @@ +const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/; + +/** The text Claude Code records in place of a turn that an interrupt stopped. */ +export function isClaudeInterruptPlaceholderText(value: unknown): boolean { + return typeof value === "string" && INTERRUPT_PLACEHOLDER_PATTERN.test(value.trim()); +} diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts index 030a54c813..c9fa690101 100644 --- a/packages/server/src/server/agent/providers/claude/project-dir.ts +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -1,5 +1,5 @@ -import { existsSync, readdirSync, realpathSync } from "node:fs"; -import { realpath } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { realpath, access, readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -35,21 +35,21 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti // one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the // cwd under another name (a Windows binary under WSL). Returns the expected path when no // transcript exists anywhere. -export function claudeTranscriptPathSync(input: { +export async function claudeTranscriptPath(input: { cwd: string; sessionId: string; configDir?: string; -}): string { +}): Promise { const options = { configDir: input.configDir }; const fileName = `${input.sessionId}.jsonl`; - const expected = join(claudeProjectDirSync(input.cwd, options), fileName); - if (existsSync(expected)) { + const expected = join(await claudeProjectDir(input.cwd, options), fileName); + if (await exists(expected)) { return expected; } const projectsRoot = join(resolveConfigDir(options), "projects"); let projectDirs: string[]; try { - projectDirs = readdirSync(projectsRoot); + projectDirs = await readdir(projectsRoot); } catch (error) { if (isMissingDirectoryError(error)) { return expected; @@ -58,13 +58,23 @@ export function claudeTranscriptPathSync(input: { } for (const projectDir of projectDirs) { const candidate = join(projectsRoot, projectDir, fileName); - if (existsSync(candidate)) { + if (await exists(candidate)) { return candidate; } } return expected; } +async function exists(file: string): Promise { + try { + await access(file); + return true; + } catch (error) { + if (isMissingDirectoryError(error)) return false; + throw error; + } +} + function isMissingDirectoryError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException | null)?.code; return code === "ENOENT" || code === "ENOTDIR"; diff --git a/packages/server/src/server/agent/providers/claude/query.ts b/packages/server/src/server/agent/providers/claude/query.ts index ccd511816a..0f9525d5b1 100644 --- a/packages/server/src/server/agent/providers/claude/query.ts +++ b/packages/server/src/server/agent/providers/claude/query.ts @@ -2,11 +2,10 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:chi import { query, type Options, type Query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk"; import { - createProviderEnv, createProviderEnvSpec, type ProviderRuntimeSettings, } from "../../provider-launch-config.js"; -import { buildSelfNodeCommand } from "../../../paseo-env.js"; +import { buildSelfNodeCommand, createExternalProcessEnv } from "../../../paseo-env.js"; import { spawnProcess } from "../../../../utils/spawn.js"; // Keep the raw SDK query import in this module only. Claude process launch behavior @@ -74,11 +73,10 @@ function applyRuntimeSettingsToClaudeOptions( runtimeSettings, overlays: [launchEnv], }); - const providerEnv = createProviderEnv({ - baseEnv: spawnOptions.env, - runtimeSettings, - overlays: [launchEnv], - }); + // Session-open hooks may explicitly supply a client identity. Keep inherited + // SDK/parent identities cleared while honoring this launch-only contribution. + providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = launchEnv?.CLAUDE_CODE_ENTRYPOINT; + const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay); const selfNodeCommand = isDefaultRuntime ? buildSelfNodeCommand(resolved.args, providerEnv) : null; diff --git a/packages/server/src/server/agent/providers/claude/rewind.test.ts b/packages/server/src/server/agent/providers/claude/rewind.test.ts index 2d6d3853c2..226f41d39c 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.test.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.test.ts @@ -1,11 +1,20 @@ import { randomUUID } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { + findSteeredMessageParent, revertClaudeConversation, revertClaudeConversationAndFiles, revertClaudeFiles, @@ -217,4 +226,96 @@ describe("Claude rewind against the Claude SDK", () => { "first reply", ]); }); + + test("forks just before a message steered into a running turn", async () => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-")); + delete process.env.CLAUDE_CONFIG_DIR; + const projectDir = path.join(tempRoot, "projects", "-repo"); + mkdirSync(projectDir, { recursive: true }); + const sessionId = randomUUID(); + const firstUser = randomUUID(); + const toolUse = randomUUID(); + const toolResult = randomUUID(); + const steeredMessage = randomUUID(); + const queuedCommand = randomUUID(); + const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`); + writeTranscript(transcriptPath, sessionId, [ + { type: "user", uuid: firstUser, parentUuid: null, text: "first" }, + { type: "assistant", uuid: toolUse, parentUuid: firstUser, text: "running a tool" }, + { type: "user", uuid: toolResult, parentUuid: toolUse, text: "tool output" }, + // What Claude Code writes when it reads a steered message between two tool calls. + JSON.stringify({ + type: "attachment", + uuid: queuedCommand, + parentUuid: toolResult, + sessionId, + attachment: { + type: "queued_command", + prompt: [{ type: "text", text: "steered" }], + source_uuid: steeredMessage, + }, + }), + { type: "assistant", uuid: randomUUID(), parentUuid: queuedCommand, text: "reply to steer" }, + ]); + + const parent = await findSteeredMessageParent(transcriptPath, steeredMessage); + expect(parent).toBe(toolResult); + expect(await findSteeredMessageParent(transcriptPath, randomUUID())).toBeNull(); + + let currentSessionId = sessionId; + await revertClaudeConversation({ + sdk: realClaudeRewindSdk, + sessionId, + transcriptPath, + messageId: parent!, + setSessionId: (nextSessionId) => { + currentSessionId = nextSessionId; + }, + }); + expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([ + "first", + "running a tool", + "tool output", + ]); + }); }); + +test.runIf(process.platform === "linux")( + "releases the transcript descriptor after an early steer match", + async () => { + const artifacts = path.resolve(".artifacts"); + mkdirSync(artifacts, { recursive: true }); + const root = mkdtempSync(path.join(artifacts, "claude-rewind-descriptor-")); + const transcriptPath = path.join(root, "session.jsonl"); + const matchingEntry = { + type: "attachment", + parentUuid: "before-steer", + attachment: { type: "queued_command", source_uuid: "steered-message" }, + }; + const unreadEntry = JSON.stringify({ type: "assistant", message: "x".repeat(1024) }); + writeFileSync( + transcriptPath, + JSON.stringify(matchingEntry) + "\n" + (unreadEntry + "\n").repeat(1200), + ); + const openDescriptors = () => + readdirSync("/proc/self/fd").filter((descriptor) => { + try { + return readlinkSync(path.join("/proc/self/fd", descriptor)) === transcriptPath; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }).length; + try { + expect(openDescriptors()).toBe(0); + for (let attempt = 0; attempt < 5; attempt++) { + await expect(findSteeredMessageParent(transcriptPath, "steered-message")).resolves.toBe( + "before-steer", + ); + } + await expect.poll(openDescriptors, { timeout: 1000 }).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/agent/providers/claude/rewind.ts b/packages/server/src/server/agent/providers/claude/rewind.ts index 3b228aacc6..75607c184b 100644 --- a/packages/server/src/server/agent/providers/claude/rewind.ts +++ b/packages/server/src/server/agent/providers/claude/rewind.ts @@ -1,5 +1,7 @@ +import { createReadStream } from "node:fs"; import { appendFile, readFile } from "node:fs/promises"; import path from "node:path"; +import { createInterface } from "node:readline"; import { forkSession as claudeForkSession, type Query, @@ -70,13 +72,61 @@ function isSessionStoreEntry(value: unknown): value is SessionStoreEntry { ); } +/** + * The entry a message steered into a running turn follows. Claude Code gives such a message no + * entry of its own: it records a queued_command attachment, under the uuid Paseo minted for the + * message, hung off the entry it was read after. Forking at that entry drops the message and + * everything after it. Null when the transcript holds no such attachment. + */ +export async function findSteeredMessageParent( + transcriptPath: string, + messageId: string, +): Promise { + const input = createReadStream(transcriptPath, { encoding: "utf8" }); + const lines = createInterface({ + input, + crlfDelay: Infinity, + }); + try { + for await (const line of lines) { + if (!line.includes(messageId) || !line.includes('"queued_command"')) continue; + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + continue; + } + const record = entry as { + type?: unknown; + parentUuid?: unknown; + attachment?: { type?: unknown; source_uuid?: unknown }; + }; + if ( + record.type === "attachment" && + record.attachment?.type === "queued_command" && + record.attachment.source_uuid === messageId && + typeof record.parentUuid === "string" + ) { + return record.parentUuid; + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } finally { + lines.close(); + input.destroy(); + } + return null; +} + export async function revertClaudeConversation(input: { sdk: ClaudeRewindSdk; sessionId: string | null; transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise; - setSessionId: (sessionId: string) => void; + setSessionId: (sessionId: string) => void | Promise; }): Promise { if (!input.sessionId || !input.transcriptPath) { throw new Error("Claude session is not ready for rewind"); @@ -86,7 +136,7 @@ export async function revertClaudeConversation(input: { upToMessageId: messageId, transcriptPath: input.transcriptPath, }); - input.setSessionId(fork.sessionId); + await input.setSessionId(fork.sessionId); } export async function revertClaudeFiles(input: { @@ -108,7 +158,7 @@ export async function revertClaudeConversationAndFiles(input: { transcriptPath: string | null; messageId: string; resolveMessageId?: (messageId: string) => string | Promise; - setSessionId: (sessionId: string) => void; + setSessionId: (sessionId: string) => void | Promise; }): Promise { await revertClaudeFiles({ query: input.query, diff --git a/packages/server/src/server/agent/providers/claude/runtime-safety.ts b/packages/server/src/server/agent/providers/claude/runtime-safety.ts new file mode 100644 index 0000000000..0c32804a57 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/runtime-safety.ts @@ -0,0 +1,42 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { parseClaudeCodeVersion } from "./model-manifest.js"; + +/** Process-local background ownership. Completion delivery can follow an empty roster. */ +export class ClaudeRuntimeSafety { + private supported = false; + private liveTasks = new Set(); + private readonly awaitingNotifications = new Set(); + + reset(): void { + this.supported = false; + this.liveTasks.clear(); + this.awaitingNotifications.clear(); + } + + observe(message: SDKMessage): void { + if (message.type !== "system") return; + if (message.subtype === "init") { + // 2.1.292 is a known roster-capable release. Older binaries can demonstrate + // support by sending a roster; a fresh process starts with an empty roster. + const version = parseClaudeCodeVersion(message.claude_code_version ?? ""); + if ( + version && + (version[0] > 2 || + (version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292)))) + ) + this.supported = true; + } + if (message.subtype === "background_tasks_changed") { + this.supported = true; + this.liveTasks = new Set(message.tasks.map((task) => task.task_id)); + for (const id of this.liveTasks) this.awaitingNotifications.add(id); + } + if (message.subtype === "task_notification") { + this.awaitingNotifications.delete(message.task_id); + } + } + + canStopRuntime(): boolean { + return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0; + } +} diff --git a/packages/server/src/server/agent/providers/claude/settings-argv.test.ts b/packages/server/src/server/agent/providers/claude/settings-argv.test.ts new file mode 100644 index 0000000000..36d1b9d794 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/settings-argv.test.ts @@ -0,0 +1,46 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { ClaudeAgentClient } from "./agent.js"; + +test("the real SDK passes the user's settings file contents alongside Paseo flags", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-argv-")); + const capturePath = path.join(cwd, "argv.json"); + const binaryPath = path.join(cwd, "capture.mjs"); + const settingsPath = path.join(cwd, "settings.json"); + const userSettings = { + env: { ENABLE_TOOL_SEARCH: "false" }, + permissions: { deny: ["Monitor"] }, + }; + try { + // A CLI fixture records the real SDK's arguments without making a model request. + await fs.writeFile( + binaryPath, + `import fs from "node:fs"; fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(process.argv.slice(2)));`, + ); + await fs.writeFile(settingsPath, JSON.stringify(userSettings)); + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + resolveBinary: async () => binaryPath, + }); + const session = await client.createSession({ + provider: "claude", + cwd, + model: "claude-opus-5-5", + providerOptions: { extraArgs: { settings: settingsPath } }, + }); + try { + await session.startTurn("hello"); + } finally { + await session.close(); + } + const argv: string[] = JSON.parse(await fs.readFile(capturePath, "utf8")); + const settingsIndex = argv.indexOf("--settings"); + expect(settingsIndex).toBeGreaterThanOrEqual(0); + expect(JSON.parse(argv[settingsIndex + 1]!)).toEqual({ ...userSettings, fastMode: false }); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index a17c1cd58c..427f279bea 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; describe("ClaudeSidechainTracker", () => { - it("retains a background child's actions across the parent finishing", () => { + it("keeps a background child's card across the parent finishing", () => { const tracker = new ClaudeSidechainTracker({ getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }), isDescriptorOwnedElsewhere: () => true, @@ -15,27 +15,67 @@ describe("ClaudeSidechainTracker", () => { parent_tool_use_id: "task-1", message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] }, }) as unknown as SDKMessage; - tracker.handleMessage(action("ls", "ls -1"), "task-1"); + const parentCards = (events: ReturnType) => + events.filter((event) => event.type === "timeline"); + const childCallIds = (events: ReturnType) => + events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "tool_call" + ? [event.event.item.callId] + : [], + ); + + expect(parentCards(tracker.handleMessage(action("ls", "ls -1"), "task-1"))).toHaveLength(1); expect(tracker.finishAll("completed")).toEqual([]); - const events = tracker.handleMessage(action("pwd", "pwd"), "task-1"); - expect(events).toContainEqual( - expect.objectContaining({ - type: "timeline", - item: expect.objectContaining({ - detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }), - }), - }), - ); + const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1"); + expect(parentCards(afterFinish)).toEqual([]); + expect(childCallIds(afterFinish)).toEqual(["pwd"]); + tracker.delete("task-1"); const next = tracker.handleMessage(action("next", "ls"), "task-1"); - expect(next).toContainEqual( - expect.objectContaining({ - type: "timeline", - item: expect.objectContaining({ - detail: expect.objectContaining({ log: "[Bash] ls" }), - }), - }), - ); + expect(parentCards(next)).toHaveLength(1); + expect(childCallIds(next)).toEqual(["next"]); + }); + + it("shows a streamed child tool call's input once the assistant message brings it", () => { + const tracker = new ClaudeSidechainTracker({ getToolInput: () => null }); + const frames = [ + { + type: "stream_event", + parent_tool_use_id: "task-1", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} }, + }, + }, + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { + content: [ + { type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } }, + ], + }, + }, + ] as unknown as SDKMessage[]; + + const childCards = frames + .flatMap((frame) => tracker.handleMessage(frame, "task-1")) + .flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "tool_call" + ? [event.event.item] + : [], + ); + + expect(childCards.at(-1)).toMatchObject({ + callId: "bash-1", + status: "running", + detail: { type: "shell", command: "echo child" }, + }); }); it("uses Claude's native agent name for the provider subagent title", () => { diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index 083b82cea6..5ca79641f9 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -5,7 +5,6 @@ import { mapClaudeFailedToolCall, mapClaudeRunningToolCall, } from "./tool-call-mapper.js"; -import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { isClaudeSubagentHandbackToolName, readClaudeSubagentHandback, @@ -20,21 +19,17 @@ interface ClaudeContentChunk { type ClaudeToolCallItem = Extract; -interface SubAgentActionEntry { - index: number; +interface SubAgentChildCall { toolName: string; input: unknown; - summary?: string; } interface SubAgentActivityState { name?: string; subAgentType?: string; description?: string; - actions: SubAgentActionEntry[]; - actionKeys: string[]; - nextActionIndex: number; - actionIndexByKey: Map; + /** The child's tool calls awaiting results, which carry no input of their own. */ + runningCalls: Map; completedActionKeys: Set; } @@ -44,7 +39,6 @@ interface SubAgentActionCandidate { input: unknown; } -const MAX_SUB_AGENT_LOG_ENTRIES = 200; const MAX_SUB_AGENT_SUMMARY_CHARS = 160; function readTrimmedString(value: unknown): string | undefined { @@ -79,8 +73,7 @@ export class ClaudeSidechainTracker { needsSyntheticParentToolCard?: (toolUseId: string) => boolean; /** * The parent's tool call once Claude has answered it. A background child streams its frames - * after that answer, so its card keeps the settled status and output and gains the child's - * action log. + * after that answer, so its card keeps the settled status and output. */ getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined; }) { @@ -91,28 +84,27 @@ export class ClaudeSidechainTracker { } handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] { + const existingState = this.activeSidechains.get(parentToolUseId); const state = - this.activeSidechains.get(parentToolUseId) ?? + existingState ?? ({ - actions: [], - actionKeys: [], - nextActionIndex: 1, - actionIndexByKey: new Map(), + runningCalls: new Map(), completedActionKeys: new Set(), } satisfies SubAgentActivityState); this.activeSidechains.set(parentToolUseId, state); - const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); + // The parent card is a launch marker: it changes only when the child is first seen or the + // task input names it differently, never with the child's activity. + const cardUpdated = + this.updateSubAgentContextFromTaskInput(state, parentToolUseId) || !existingState; const actionCandidates = this.extractSubAgentActionCandidates(message); const childTimelineItems = [ ...this.extractSubAgentTimelineItems(message, state), ...this.extractSubAgentToolResults(message, state), ]; - let actionUpdated = false; for (const action of actionCandidates) { if (state.completedActionKeys.has(action.key)) continue; - if (this.appendSubAgentAction(state, action)) { - actionUpdated = true; + if (this.trackChildCall(state, action)) { const toolCall = mapClaudeRunningToolCall({ name: action.toolName, callId: action.key, @@ -125,7 +117,7 @@ export class ClaudeSidechainTracker { } } - if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) { + if (!cardUpdated && childTimelineItems.length === 0) { return []; } @@ -145,11 +137,8 @@ export class ClaudeSidechainTracker { type: "sub_agent", ...(state.subAgentType ? { subAgentType: state.subAgentType } : {}), ...(state.description ? { description: state.description } : {}), - log: state.actions - .map((action) => - action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`, - ) - .join("\n"), + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", actions: [], }; @@ -170,18 +159,19 @@ export class ClaudeSidechainTracker { }, ]; - const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId) - ? [ - { - type: "timeline", - item: { - ...toolCall, - detail, + const parentCard: AgentStreamEvent[] = + cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId) + ? [ + { + type: "timeline", + item: { + ...toolCall, + detail, + }, + provider: "claude", }, - provider: "claude", - }, - ] - : []; + ] + : []; return [ ...descriptorEvents, @@ -303,8 +293,7 @@ export class ClaudeSidechainTracker { if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue; const callId = readTrimmedString(block.tool_use_id); if (!callId || state.completedActionKeys.has(callId)) continue; - const actionIndex = state.actionIndexByKey.get(callId); - const action = actionIndex === undefined ? undefined : state.actions[actionIndex]; + const action = state.runningCalls.get(callId); const toolName = action?.toolName ?? readTrimmedString(block.tool_name); if (!toolName) continue; const params = { @@ -317,6 +306,7 @@ export class ClaudeSidechainTracker { ? mapClaudeFailedToolCall({ ...params, error: block }) : mapClaudeCompletedToolCall(params); if (toolCall) { + state.runningCalls.delete(callId); state.completedActionKeys.add(callId); items.push(toolCall); } @@ -412,13 +402,12 @@ export class ClaudeSidechainTracker { const key = readTrimmedString(block.id) ?? `stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`; - return [ - { - key, - toolName: block.name, - input: block.input ?? null, - }, - ]; + // A streamed call starts with `{}` until its input arrives, so an empty input is not final. + const input = + block.input && typeof block.input === "object" && Object.keys(block.input).length > 0 + ? block.input + : null; + return [{ key, toolName: block.name, input }]; } private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { @@ -448,84 +437,20 @@ export class ClaudeSidechainTracker { return []; } - private appendSubAgentAction( + /** Returns whether the call is new or has gained its input, so its running card changes. */ + private trackChildCall( state: SubAgentActivityState, candidate: SubAgentActionCandidate, ): boolean { - const normalizedToolName = readTrimmedString(candidate.toolName); - if (!normalizedToolName) { + const toolName = readTrimmedString(candidate.toolName); + if (!toolName) { return false; } - - const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input); - const existingIndex = state.actionIndexByKey.get(candidate.key); - - if (existingIndex !== undefined) { - const existing = state.actions[existingIndex]; - if (!existing) { - return false; - } - const nextSummary = existing.summary ?? summary; - if (existing.toolName === normalizedToolName && existing.summary === nextSummary) { - return false; - } - state.actions[existingIndex] = { - ...existing, - toolName: normalizedToolName, - input: existing.input ?? candidate.input, - ...(nextSummary ? { summary: nextSummary } : {}), - }; - return true; + const existing = state.runningCalls.get(candidate.key); + if (existing && (existing.input !== null || candidate.input === null)) { + return false; } - - state.actions.push({ - index: state.nextActionIndex, - toolName: normalizedToolName, - input: candidate.input, - ...(summary ? { summary } : {}), - }); - state.nextActionIndex += 1; - state.actionKeys.push(candidate.key); - this.trimSubAgentTail(state); - this.rebuildSubAgentActionIndex(state); + state.runningCalls.set(candidate.key, { toolName, input: candidate.input }); return true; } - - private trimSubAgentTail(state: SubAgentActivityState): void { - while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) { - state.actions.shift(); - const removedKey = state.actionKeys.shift(); - if (removedKey) state.completedActionKeys.delete(removedKey); - } - } - - private rebuildSubAgentActionIndex(state: SubAgentActivityState): void { - state.actionIndexByKey.clear(); - for (let index = 0; index < state.actionKeys.length; index += 1) { - const key = state.actionKeys[index]; - if (key) { - state.actionIndexByKey.set(key, index); - } - } - } - - private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined { - const runningToolCall = mapClaudeRunningToolCall({ - name: toolName, - callId: `sub-agent-summary-${toolName}`, - input, - output: null, - }); - if (!runningToolCall) { - return undefined; - } - const display = buildToolCallDisplayModel({ - name: runningToolCall.name, - status: runningToolCall.status, - error: runningToolCall.error, - detail: runningToolCall.detail, - metadata: runningToolCall.metadata, - }); - return this.normalizeSubAgentText(display.summary); - } } diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts index b0bfc3d755..b12ab8eb6a 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts @@ -156,6 +156,42 @@ describe("ClaudeTaskProtocolSource", () => { ]); }); + it("resumes a child a reopened session restored on its original row", () => { + const source = new ClaudeTaskProtocolSource(); + source.restore([ + { + taskId: "a1730a6215e1f5cf6", + id: "toolu_original", + presentation: { title: "general-purpose", model: "claude-sonnet-5", effort: "high" }, + }, + ]); + + // Restored is not running: a canceled turn and an unannounced frame leave it alone. + expect(source.cancelRunningForegroundTasks()).toEqual([]); + expect(source.resolveSubagentId("toolu_original")).toBeUndefined(); + + expect( + source.observe(taskStarted({ tool_use_id: "toolu_resumed", prompt: "Resumed prompt" })), + ).toEqual([ + { kind: "status", id: "toolu_original", status: "running" }, + { + kind: "timeline", + id: "toolu_original", + item: { type: "user_message", text: "Resumed prompt" }, + }, + ]); + expect(source.resolveSubagentId("toolu_original")).toBe("toolu_original"); + expect(source.resolveSubagentId("toolu_resumed")).toBe("toolu_original"); + expect( + source.observeSidechainFrame( + { type: "assistant", message: { model: "claude-opus-5" } } as unknown as SDKMessage, + "toolu_original", + ), + ).toEqual([ + { kind: "subtitle", id: "toolu_original", subtitle: "general-purpose · Opus 5 · High" }, + ]); + }); + it("declares a distinct task separately", () => { const source = new ClaudeTaskProtocolSource(); source.observe(taskStarted({ task_id: "first", tool_use_id: "toolu_first" })); diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index 2149fae139..b7b3201897 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -1,7 +1,8 @@ +import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; +import { mapTaskStatus } from "./task-status.js"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import type { AgentMetadata } from "../../../agent-sdk-types.js"; -import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; import { resolveObservedClaudeModelId } from "../models.js"; import type { SubagentObservation } from "./observation.js"; import { @@ -121,25 +122,13 @@ function readString(value: unknown): string | undefined { } /** - * The descriptor has no paused or pending state, and neither is terminal, so both read as - * running. `killed` is a cancellation, not a failure — the child was stopped, it did not error. + * A subagent a reopened session rebuilt from its transcript. Its `taskId` (the transcript's + * agentId) is the task id Claude announces again when SendMessage resumes the child. */ -function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined { - switch (status) { - case "pending": - case "running": - case "paused": - return "running"; - case "completed": - return "completed"; - case "failed": - return "failed"; - case "killed": - case "stopped": - return "canceled"; - default: - return undefined; - } +export interface ClaudeRestoredSubagent { + taskId: string; + id: string; + presentation: ClaudeSubagentPresentationFacts; } export interface ClaudeTaskProtocolSourceInput { @@ -183,6 +172,14 @@ export class ClaudeTaskProtocolSource { private readonly backgroundedIds = new Set(); /** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */ private readonly lastStatusById = new Map(); + /** + * Children a reopened session restored from its transcript, by task id. Claude resumes one under + * its old task id with a new tool id, while its frames keep the original Task call as parent. + * Without this the resume reads as a new task: a second row, and every frame dropped as + * undeclared. Held apart from the declared table so a restored child is left alone until + * Claude announces it again. + */ + private readonly restoredByTaskId = new Map(); /** Claude facts stay inside the provider boundary; clients receive one compact subtitle. */ private readonly presentationById = new Map(); private readonly lastSubtitleById = new Map(); @@ -267,6 +264,14 @@ export class ClaudeTaskProtocolSource { } } + /** Remember the children a reopened session restored, for Claude to resume by task id. */ + restore(subagents: Iterable): void { + for (const subagent of subagents) { + if (this.subagentIdByTaskId.has(subagent.taskId)) continue; + this.restoredByTaskId.set(subagent.taskId, subagent); + } + } + /** * Forget the session. * @@ -280,6 +285,7 @@ export class ClaudeTaskProtocolSource { this.ownerSubagentIdByToolUseId.clear(); this.ownerSubagentIdByTaskId.clear(); this.declaredIds.clear(); + this.restoredByTaskId.clear(); this.workflowTaskIds.clear(); this.lastWorkflowResultByTaskId.clear(); this.idsWithExistingParentToolCard.clear(); @@ -336,7 +342,8 @@ export class ClaudeTaskProtocolSource { if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return []; this.sawTaskStarted = true; - const existingId = this.subagentIdByTaskId.get(message.task_id); + const existingId = + this.subagentIdByTaskId.get(message.task_id) ?? this.adoptRestored(message.task_id); this.recordBackgrounded(existingId ?? id, message.is_backgrounded); if (existingId) { return this.observeExistingTaskStart(message, id, existingId); @@ -345,6 +352,20 @@ export class ClaudeTaskProtocolSource { return this.observeNewTaskStart(message, id, parentSubagentId); } + /** Declare a restored child under the id its transcript gave it, with what it already showed. */ + private adoptRestored(taskId: string): string | undefined { + const restored = this.restoredByTaskId.get(taskId); + if (!restored) return undefined; + this.restoredByTaskId.delete(taskId); + this.subagentIdByTaskId.set(taskId, restored.id); + this.canonicalIdByToolUseId.set(restored.id, restored.id); + this.declaredIds.add(restored.id); + this.presentationById.set(restored.id, restored.presentation); + const subtitle = buildClaudeSubagentSubtitle(restored.presentation); + if (subtitle) this.lastSubtitleById.set(restored.id, subtitle); + return restored.id; + } + private observeExistingTaskStart( message: TaskStartedMessage, toolUseId: string, diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts index e28d15b8c1..fc48e9db3a 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts @@ -1,3 +1,9 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../../provider-image-output.js"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, it } from "vitest"; @@ -30,7 +36,7 @@ function parentWithTaskCall(failed = false): ClaudeReplayParentFacts { ], ]), linksByAgentId: new Map(), - outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed }]]), + outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: failed ? "failed" : "completed" }]]), }; } @@ -45,7 +51,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc } describe("parseClaudeSubagentMeta", () => { - it("reads the real sidecar shape", () => { + it("reads the real sidecar shape", async () => { expect( parseClaudeSubagentMeta( '{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}', @@ -68,7 +74,7 @@ describe("parseClaudeSubagentMeta", () => { expect(parseClaudeSubagentMeta(contents)).toBeNull(); }); - it("keeps the good fields when one field has the wrong type", () => { + it("keeps the good fields when one field has the wrong type", async () => { expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({ agentType: "Explore", description: undefined, @@ -79,12 +85,58 @@ describe("parseClaudeSubagentMeta", () => { }); describe("observeReplaySubagents", () => { - it("links a subagent to its Task call through meta.toolUseId", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + it("restores a resumed child on its original row after consuming async history", async () => { + async function* entries(): AsyncGenerator { + yield { type: "assistant", message: { model: "claude-opus-5" } }; + yield { + type: "assistant", + effort: "high", + message: { model: "claude-sonnet-5", usage: { input_tokens: 10, output_tokens: 2 } }, + }; + } + const replay = await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: entries() }], parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + convertEntry: async () => [], + }); + expect(replay.restored).toEqual([ + { + taskId: AGENT_ID, + id: TOOL_USE_ID, + presentation: { + title: "general-purpose", + model: "claude-sonnet-5", + effort: "high", + usage: { totalTokens: 12 }, + }, + }, + ]); + const live = new ClaudeTaskProtocolSource(); + live.restore(replay.restored); + expect( + live.observe({ + type: "system", + subtype: "task_started", + task_id: AGENT_ID, + tool_use_id: "resumed-tool", + description: "Resume the captured task", + task_type: "local_agent", + is_backgrounded: true, + uuid: "00000000-0000-0000-0000-000000000000", + session_id: "reopened-session", + }), + ).toEqual([{ kind: "status", id: TOOL_USE_ID, status: "running" }]); + expect(live.resolveSubagentId("resumed-tool")).toBe(TOOL_USE_ID); + }); + + it("links a subagent to its Task call through meta.toolUseId", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations[0]).toMatchObject({ kind: "declared", @@ -95,31 +147,33 @@ describe("observeReplaySubagents", () => { }); }); - it("replays descendants beneath the sidechain that declared their Task", () => { + it("replays descendants beneath the sidechain that declared their Task", async () => { const nestedToolUseId = "toolu_012rzYnFZA"; - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, - entries: [], - parentFacts: { - toolCalls: new Map([ - [nestedToolUseId, { title: "Explore", description: "Nested audit" }], - ]), - linksByAgentId: new Map(), - outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]), + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, + entries: [], + parentFacts: { + toolCalls: new Map([ + [nestedToolUseId, { title: "Explore", description: "Nested audit" }], + ]), + linksByAgentId: new Map(), + outcomesByToolCallId: new Map([[nestedToolUseId, { status: "completed" }]]), + }, }, - }, - { - agentId: "a6acb4b898", - meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 }, - entries: [], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + { + agentId: "a6acb4b898", + meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 }, + entries: [], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations).toContainEqual( expect.objectContaining({ @@ -130,89 +184,143 @@ describe("observeReplaySubagents", () => { ); }); - it("keeps a subagent recorded before spawnDepth existed", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("keeps a subagent recorded before spawnDepth existed", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID }); }); - it("drops a meta-linked subagent when the parent never declared its Task", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" }, - entries: [], - }, - ], - parent: emptyParent(), - convertEntry: () => [], - }).observations; + it("drops a meta-linked subagent when the parent never declared its Task", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" }, + entries: [], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations; expect(observations).toEqual([]); }); - it("falls back to the scraped link when there is no meta file", () => { + it("falls back to the scraped link when there is no meta file", async () => { const parent: ClaudeReplayParentFacts = { toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), - linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]), - outcomesByToolCallId: new Map(), + linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]), + outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "completed" }]]), }; - const [declared, ...rest] = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], - parent, - convertEntry: () => [], - }).observations; + const [declared, ...rest] = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent, + convertEntry: () => [], + }) + ).observations; expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" }); expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" }); }); - it("drops an unlinkable subagent", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], - parent: emptyParent(), - convertEntry: () => [], - }).observations; + it("drops an unlinkable subagent", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations; expect(observations).toEqual([]); }); - it("drops a terminal subagent the parent has no Task call for", () => { + it("drops a terminal subagent the parent has no Task call for", async () => { // A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so // no tool_result for it can ever reach the parent. Left running, it never stops. const descriptor = applyToStore( - observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: "end_turn" }, - }, - ], - }, - ], - parent: emptyParent(), - convertEntry: () => [], - }).observations, + ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations, ); expect(descriptor).toBeNull(); }); - it("drops a terminal skill-spawned subagent that has no link at all", () => { + it("drops a terminal skill-spawned subagent that has no link at all", async () => { // A /code-review run: no Task tool_use exists to name it, so its sidecar carries only // agentType and its id appears nowhere in the parent transcript. const descriptor = applyToStore( - observeReplaySubagents({ + ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { agentType: "general-purpose" }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }) + ).observations, + ); + + expect(descriptor).toBeNull(); + }); + + it("reports a scraped link's failure as failed", async () => { + const descriptor = applyToStore( + ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent: { + toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), + linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]), + outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "failed" }]]), + }, + convertEntry: () => [], + }) + ).observations, + ); + + expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" }); + }); + + it("drops a non-terminal linkless subagent", async () => { + const observations = ( + await observeReplaySubagents({ subagents: [ { agentId: AGENT_ID, @@ -221,111 +329,81 @@ describe("observeReplaySubagents", () => { { type: "assistant", timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: "end_turn" }, + message: { stop_reason: null }, }, ], }, ], parent: emptyParent(), convertEntry: () => [], - }).observations, - ); - - expect(descriptor).toBeNull(); - }); - - it("reports a scraped link's failure as failed", () => { - const descriptor = applyToStore( - observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], - parent: { - toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), - linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]), - outcomesByToolCallId: new Map(), - }, - convertEntry: () => [], - }).observations, - ); - - expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" }); - }); - - it("drops a non-terminal linkless subagent", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { agentType: "general-purpose" }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: null }, - }, - ], - }, - ], - parent: emptyParent(), - convertEntry: () => [], - }).observations; + }) + ).observations; expect(observations).toEqual([]); }); - it("finishes a terminal Task child when the parent has not recorded its outcome", () => { + it("finishes a terminal Task child when the parent has not recorded its outcome", async () => { const descriptor = applyToStore( - observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-31T03:41:00.000Z", - message: { stop_reason: "end_turn" }, - }, - ], - }, - ], - parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, - convertEntry: () => [], - }).observations, + ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-31T03:41:00.000Z", + message: { stop_reason: "end_turn" }, + }, + ], + }, + ], + parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, + convertEntry: () => [], + }) + ).observations, ); expect(descriptor).toMatchObject({ status: "completed" }); }); - it("leaves a subagent with no recorded outcome running", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, - convertEntry: () => [], - }).observations; + it("leaves a subagent with no recorded outcome running", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, + convertEntry: () => [], + }) + ).observations; expect(observations.some((o) => o.kind === "status")).toBe(false); }); - it("reports a failed outcome as failed", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(true), - convertEntry: () => [], - }).observations; + it("reports a failed outcome as failed", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(true), + convertEntry: () => [], + }) + ).observations; expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" }); }); - it("carries entry timestamps onto timeline observations", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [{ type: "reasoning", text: "thinking" }], - }).observations; + it("carries entry timestamps onto timeline observations", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [{ type: "reasoning", text: "thinking" }], + }) + ).observations; expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({ kind: "timeline", @@ -336,18 +414,20 @@ describe("observeReplaySubagents", () => { }); describe("replay runtime", () => { - it("reads the model and effort off the subagent's own transcript", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("reads the model and effort off the subagent's own transcript", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.find((o) => o.kind === "subtitle")).toEqual({ kind: "subtitle", @@ -356,49 +436,55 @@ describe("replay runtime", () => { }); }); - it("takes the last observed values when the model changes mid-transcript", () => { - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { type: "assistant", effort: "high", message: { model: "claude-opus-5" } }, - { type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } }, - ], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("takes the last observed values when the model changes mid-transcript", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { type: "assistant", effort: "high", message: { model: "claude-opus-5" } }, + { type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } }, + ], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ subtitle: "general-purpose · Sonnet 5 · Low", }); }); - it("reports nothing when the transcript never names a model or effort", () => { - const observations = observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + it("reports nothing when the transcript never names a model or effort", async () => { + const observations = ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.some((o) => o.kind === "subtitle")).toBe(false); }); - it("passes through a model the manifest does not know", () => { + it("passes through a model the manifest does not know", async () => { // Claude Code is an Anthropic-compatible client; subagents legitimately report other models. - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [{ type: "assistant", message: { model: "glm-5.1" } }], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", message: { model: "glm-5.1" } }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ subtitle: "general-purpose · glm-5.1", }); @@ -406,20 +492,24 @@ describe("replay runtime", () => { }); describe("replay usage", () => { - function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined { - return observeReplaySubagents({ - subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations.find((observation) => observation.kind === "subtitle"); + async function subtitleOf( + entries: ClaudeReplayEntry[], + ): Promise { + return ( + await observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations.find((observation) => observation.kind === "subtitle"); } - it("derives the counters Claude Code itself reports, from a real transcript's shape", () => { + it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => { // Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a // subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win // and must not be added in. expect( - subtitleOf([ + await subtitleOf([ { type: "assistant", timestamp: "2026-07-23T23:18:43.023Z", @@ -462,9 +552,9 @@ describe("replay usage", () => { }); }); - it("formats tokens for a child that only answered", () => { + it("formats tokens for a child that only answered", async () => { expect( - subtitleOf([ + await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", @@ -479,12 +569,12 @@ describe("replay usage", () => { ).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" }); }); - it("emits nothing for a transcript with no entries", () => { - expect(subtitleOf([])).toBeUndefined(); + it("emits nothing for a transcript with no entries", async () => { + expect(await subtitleOf([])).toBeUndefined(); }); - it("keeps duration out of the compact subtitle", () => { - const observation = subtitleOf([ + it("keeps duration out of the compact subtitle", async () => { + const observation = await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", @@ -494,8 +584,8 @@ describe("replay usage", () => { expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" }); }); - it("omits totalTokens when no entry carries a usage block", () => { - const observation = subtitleOf([ + it("omits totalTokens when no entry carries a usage block", async () => { + const observation = await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } }, { type: "assistant", @@ -506,9 +596,9 @@ describe("replay usage", () => { expect(observation).toBeUndefined(); }); - it("keeps the last usage-bearing entry when the final assistant entry has none", () => { + it("keeps the last usage-bearing entry when the final assistant entry has none", async () => { expect( - subtitleOf([ + await subtitleOf([ { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", @@ -519,34 +609,36 @@ describe("replay usage", () => { ).toMatchObject({ subtitle: "general-purpose · 1k tokens" }); }); - it("only reports counters the transcript actually shows", () => { + it("only reports counters the transcript actually shows", async () => { // A user-only transcript says nothing about cost: no assistant turn, so no tool count either. - const observation = subtitleOf([ + const observation = await subtitleOf([ { type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } }, { type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } }, ]); expect(observation).toBeUndefined(); }); - it("lands the complete provider-owned subtitle on the descriptor", () => { + it("lands the complete provider-owned subtitle on the descriptor", async () => { const store = new ProviderSubagentStore(); - const observations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { toolUseId: TOOL_USE_ID }, - entries: [ - { - type: "assistant", - timestamp: "2026-07-27T16:42:14.698Z", - message: { content: [], usage: { input_tokens: 7 } }, - }, - ], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + const observations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-27T16:42:14.698Z", + message: { content: [], usage: { input_tokens: 7 } }, + }, + ], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; for (const event of foldSubagentObservations(observations)) { store.apply("parent", "claude", event); } @@ -559,7 +651,7 @@ describe("live and replay agree", () => { // The property the design exists to guarantee: one session, two sources, one descriptor. // This is inexpressible against the inference implementation, where the paths share no // vocabulary and each derives identity and status its own way. - it("produces identical descriptor state from the wire and from disk", () => { + it("produces identical descriptor state from the wire and from disk", async () => { const live = new ClaudeTaskProtocolSource(); const liveObservations = [ ...live.observe({ @@ -579,21 +671,23 @@ describe("live and replay agree", () => { } as unknown as SDKMessage), ]; - const replayObservations = observeReplaySubagents({ - subagents: [ - { - agentId: AGENT_ID, - meta: { - toolUseId: TOOL_USE_ID, - agentType: "general-purpose", - description: "Summarize hover and unistyles docs", + const replayObservations = ( + await observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { + toolUseId: TOOL_USE_ID, + agentType: "general-purpose", + description: "Summarize hover and unistyles docs", + }, + entries: [], }, - entries: [], - }, - ], - parent: parentWithTaskCall(), - convertEntry: () => [], - }).observations; + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }) + ).observations; const fromWire = applyToStore(liveObservations); const fromDisk = applyToStore(replayObservations); @@ -616,3 +710,34 @@ describe("live and replay agree", () => { }); }); }); + +it("awaits image materialization before publishing replay observations", async () => { + const bytes = Buffer.from("async replay image fixture"); + const convertEntry = async () => { + const item = await renderProviderImageOutputAsAssistantMarkdown( + { data: bytes.toString("base64"), mimeType: "image/png" }, + { materialize: materializeProviderImage }, + ); + return item ? [item] : []; + }; + const { observations } = await observeReplaySubagents({ + parent: parentWithTaskCall(), + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", message: { content: [] } }], + }, + ], + convertEntry, + }); + const image = observations.find( + (observation) => + observation.kind === "timeline" && observation.item.type === "assistant_message", + ); + if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message") + throw new Error("Expected replay image"); + const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1]; + expect(source).toBeTruthy(); + expect(await readFile(fileURLToPath(source!))).toEqual(bytes); +}); diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 23ba35c0fe..51e5ff9c09 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -3,7 +3,9 @@ import { z } from "zod"; import type { AgentTimelineItem } from "../../../agent-sdk-types.js"; import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js"; import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; +import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js"; import { resolveObservedClaudeModelId } from "../models.js"; +import type { ClaudeRestoredSubagent } from "./live-source.js"; import type { SubagentObservation } from "./observation.js"; import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js"; @@ -130,7 +132,7 @@ function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null { export interface ClaudeReplaySubagentInput { agentId: string; meta: ClaudeSubagentMeta | null; - entries: Iterable; + entries: Iterable | AsyncIterable; /** Task declarations made by this subagent, used to attach its direct children. */ parentFacts?: ClaudeReplayParentFacts; } @@ -139,9 +141,12 @@ export interface ClaudeReplayParentFacts { /** Task `tool_use` id -> identity declared in the parent's tool input. */ toolCalls: ReadonlyMap; /** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */ - linksByAgentId: ReadonlyMap; - /** Task `tool_use` id -> outcome. Usable once meta.json supplies the link directly. */ - outcomesByToolCallId: ReadonlyMap; + linksByAgentId: ReadonlyMap; + /** + * Task `tool_use` id -> outcome, whichever way the child was linked. A background launch has + * none: its immediate result only says the child started. + */ + outcomesByToolCallId: ReadonlyMap; } interface ParentLink { @@ -177,27 +182,14 @@ function resolveParentLink( parent: ClaudeReplayParentFacts, ): ParentLink | null { const metaToolUseId = subagent.meta?.toolUseId?.trim(); - if (metaToolUseId && parent.toolCalls.has(metaToolUseId)) { - const outcome = parent.outcomesByToolCallId.get(metaToolUseId); - let status: ProviderSubagentStatus | null = null; - if (outcome) status = outcome.failed ? "failed" : "completed"; - return { - id: metaToolUseId, - toolCallId: metaToolUseId, - status, - }; - } + const toolCallId = + metaToolUseId && parent.toolCalls.has(metaToolUseId) + ? metaToolUseId + : parent.linksByAgentId.get(subagent.agentId)?.toolCallId; + if (!toolCallId || !parent.toolCalls.has(toolCallId)) return null; - const scraped = parent.linksByAgentId.get(subagent.agentId); - if (scraped && parent.toolCalls.has(scraped.toolCallId)) { - return { - id: scraped.toolCallId, - toolCallId: scraped.toolCallId, - status: scraped.failed ? "failed" : "completed", - }; - } - - return null; + const status = parent.outcomesByToolCallId.get(toolCallId)?.status ?? null; + return { id: toolCallId, toolCallId, status }; } /** @@ -250,13 +242,14 @@ function observeSubtitle( }; } -function observeSubagent( +async function observeSubagent( subagent: ClaudeReplaySubagentInput, parent: ClaudeReplayParentFacts, - convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[], + convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise, toolOwners: Map, + restored: ClaudeRestoredSubagent[], parentSubagentId?: string, -): SubagentObservation[] { +): Promise { const link = resolveParentLink(subagent, parent); if (!link) return []; const toolCall = parent.toolCalls.get(link.toolCallId); @@ -269,31 +262,30 @@ function observeSubagent( let childTerminalStatus: ProviderSubagentStatus | null = null; // Consume each source once for presentation and timeline; keep no raw transcript records. - for (const entry of subagent.entries) { + for await (const entry of subagent.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); if (!seenEntry) firstTimestamp = timestamp; seenEntry = true; lastTimestamp = timestamp; Object.assign(runtime, readRuntime(entry)); usage = readUsage(entry) ?? usage; - if (entry.type === "assistant") { - childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null; - } + childTerminalStatus = readChildTerminalStatus(entry, childTerminalStatus); recordReplayToolOwners(toolOwners, entry, link.id); - for (const item of convertEntry(entry)) { + for (const item of await convertEntry(entry)) { timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) }); } } const observations = [ declareSubagent(subagent, link, toolCall, parentSubagentId, firstTimestamp), ]; - const subtitle = observeSubtitle( - link, - toolCall?.title ?? subagent.meta?.agentType, - runtime, - usage, - lastTimestamp, - ); + const title = toolCall?.title ?? subagent.meta?.agentType; + // The agentId is the task_id Claude resumes this child under, so live can find it again. + restored.push({ + taskId: subagent.agentId, + id: link.id, + presentation: { ...(title ? { title } : {}), ...runtime, ...(usage ? { usage } : {}) }, + }); + const subtitle = observeSubtitle(link, title, runtime, usage, lastTimestamp); if (subtitle) observations.push(subtitle); for (const observation of timeline) observations.push(observation); const terminalStatus = link.status ?? childTerminalStatus; @@ -308,6 +300,30 @@ function observeSubagent( return observations; } +/** Advance the child's fallback outcome without retaining transcript entries. */ +function readChildTerminalStatus( + entry: ClaudeReplayEntry, + previous: ProviderSubagentStatus | null, +): ProviderSubagentStatus | null { + if (entry.type === "assistant") { + return entry.message?.stop_reason === "end_turn" ? "completed" : null; + } + if (entry.type !== "user") return previous; + const texts = readUserTexts(entry); + if (texts.length === 0) return previous; + return texts.some(isClaudeInterruptPlaceholderText) ? "canceled" : null; +} + +/** Tool results carry no user text and do not change the child's terminal status. */ +function readUserTexts(entry: ClaudeReplayEntry): string[] { + const content = entry.message?.content; + if (typeof content === "string") return [content]; + if (!Array.isArray(content)) return []; + return content.flatMap((block: { type?: unknown; text?: unknown } | null) => + block?.type === "text" && typeof block.text === "string" ? [block.text] : [], + ); +} + function recordReplayToolOwners( owners: Map, entry: ClaudeReplayEntry, @@ -320,13 +336,18 @@ function recordReplayToolOwners( } } -export function observeReplaySubagents(input: { +export async function observeReplaySubagents(input: { subagents: readonly ClaudeReplaySubagentInput[]; parent: ClaudeReplayParentFacts; - convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; -}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap } { + convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise; +}): Promise<{ + observations: SubagentObservation[]; + toolOwners: ReadonlyMap; + restored: readonly ClaudeRestoredSubagent[]; +}> { const observations: SubagentObservation[] = []; const toolOwners = new Map(); + const restored: ClaudeRestoredSubagent[] = []; const unresolved = [...input.subagents].sort( (left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1), ); @@ -346,11 +367,12 @@ export function observeReplaySubagents(input: { const { ownerId, parent, link } = resolved; // Only proven descendants may own notifications; ambient sidecars cannot claim them. - for (const observation of observeSubagent( + for (const observation of await observeSubagent( subagent, parent, input.convertEntry, toolOwners, + restored, ownerId, )) { observations.push(observation); @@ -360,7 +382,7 @@ export function observeReplaySubagents(input: { madeProgress = true; } } - return { observations, toolOwners }; + return { observations, toolOwners, restored }; } function resolveReplayOwner( diff --git a/packages/server/src/server/agent/providers/claude/subagents/task-status.ts b/packages/server/src/server/agent/providers/claude/subagents/task-status.ts new file mode 100644 index 0000000000..a488ce12d7 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagents/task-status.ts @@ -0,0 +1,23 @@ +import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; + +/** + * The descriptor has no paused or pending state, and neither is terminal, so both read as + * running. `killed` is a cancellation, not a failure — the child was stopped, it did not error. + */ +export function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined { + switch (status) { + case "pending": + case "running": + case "paused": + return "running"; + case "completed": + return "completed"; + case "failed": + return "failed"; + case "killed": + case "stopped": + return "canceled"; + default: + return undefined; + } +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts index 59e1f23557..edb673c664 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.test.ts @@ -1,3 +1,9 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../../provider-image-output.js"; import { describe, expect, it } from "vitest"; import { ProviderSubagentStore } from "../../../provider-subagents/store.js"; @@ -35,7 +41,7 @@ function parentEntries( } describe("Claude workflow replay", () => { - it("parses the persisted run summary observed from Claude Code 2.1.220", () => { + it("parses the persisted run summary observed from Claude Code 2.1.220", async () => { expect( parseClaudeWorkflowRun( JSON.stringify({ @@ -62,8 +68,8 @@ describe("Claude workflow replay", () => { }); }); - it("rebuilds the same generic completed descriptor through the parent Workflow call", () => { - const observations = observeReplayWorkflows({ + it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => { + const observations = await observeReplayWorkflows({ workflows: [ { runId: RUN_ID, @@ -91,8 +97,8 @@ describe("Claude workflow replay", () => { }); }); - it("rebuilds a workflow when Claude persists its result as structured text blocks", () => { - const observations = observeReplayWorkflows({ + it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => { + const observations = await observeReplayWorkflows({ workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }], parentEntries: parentEntries([ { @@ -114,7 +120,7 @@ describe("Claude workflow replay", () => { }); }); - it("replays the workflow result without exposing its script or child prompt", () => { + it("replays the workflow result without exposing its script or child prompt", async () => { const workflow = parseClaudeWorkflowRun( JSON.stringify({ runId: RUN_ID, @@ -128,7 +134,7 @@ describe("Claude workflow replay", () => { expect(workflow).not.toBeNull(); if (!workflow) throw new Error("expected workflow"); - const observations = observeReplayWorkflows({ + const observations = await observeReplayWorkflows({ workflows: [workflow], parentEntries: parentEntries(), }); @@ -145,9 +151,9 @@ describe("Claude workflow replay", () => { expect(JSON.stringify(observations)).not.toContain("SECRET CHILD PROMPT"); }); - it("does not duplicate a result already present in the child transcript", () => { + it("does not duplicate a result already present in the child transcript", async () => { const report = "What Paseo is\nA local-first coding-agent environment."; - const observations = observeReplayWorkflows({ + const observations = await observeReplayWorkflows({ workflows: [ { runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } }, ], @@ -166,8 +172,8 @@ describe("Claude workflow replay", () => { ).toHaveLength(1); }); - it("fails a nonterminal persisted run because its former runtime cannot update it", () => { - const observations = observeReplayWorkflows({ + it("fails a nonterminal persisted run because its former runtime cannot update it", async () => { + const observations = await observeReplayWorkflows({ workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }], parentEntries: parentEntries(), }); @@ -179,9 +185,9 @@ describe("Claude workflow replay", () => { }); }); - it("drops a run that no parent Workflow tool call declared", () => { + it("drops a run that no parent Workflow tool call declared", async () => { expect( - observeReplayWorkflows({ + await observeReplayWorkflows({ workflows: [{ runId: RUN_ID, status: "completed" }], parentEntries: [], }), @@ -195,3 +201,29 @@ describe("Claude workflow replay", () => { }, ); }); + +it("awaits image materialization before publishing replay observations", async () => { + const bytes = Buffer.from("async replay image fixture"); + const convertEntry = async () => { + const item = await renderProviderImageOutputAsAssistantMarkdown( + { data: bytes.toString("base64"), mimeType: "image/png" }, + { materialize: materializeProviderImage }, + ); + return item ? [item] : []; + }; + const observations = await observeReplayWorkflows({ + workflows: [{ runId: RUN_ID, status: "completed" }], + parentEntries: parentEntries(), + entriesByRunId: new Map([[RUN_ID, [{ type: "assistant", message: { content: [] } }]]]), + convertEntry, + }); + const image = observations.find( + (observation) => + observation.kind === "timeline" && observation.item.type === "assistant_message", + ); + if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message") + throw new Error("Expected replay image"); + const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1]; + expect(source).toBeTruthy(); + expect(await readFile(fileURLToPath(source!))).toEqual(bytes); +}); diff --git a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts index 1d7ff2b1a8..17a278d00f 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/workflow-replay-source.ts @@ -45,14 +45,17 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu * provider runtime was recreated, so replay terminalizes it as failed instead of resurrecting a * row that can never receive another update. */ -export function observeReplayWorkflows(input: { +export async function observeReplayWorkflows(input: { workflows: readonly ClaudeWorkflowRun[]; - parentEntries: Iterable; + parentEntries: Iterable | AsyncIterable; /** Repeatable entries in chronological order, supplied by the transcript reader. */ - entriesByRunId?: ReadonlyMap>; - convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; -}): SubagentObservation[] { - const toolCallIdByRunId = readWorkflowLinks(input.parentEntries); + entriesByRunId?: ReadonlyMap< + string, + Iterable | AsyncIterable + >; + convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise; +}): Promise { + const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries); const observations: SubagentObservation[] = []; for (const workflow of input.workflows) { @@ -79,7 +82,7 @@ export function observeReplayWorkflows(input: { }); } - for (const observation of observeWorkflowTimeline({ + for (const observation of await observeWorkflowTimeline({ id, result: workflow.result, entries: input.entriesByRunId?.get(workflow.runId) ?? [], @@ -115,19 +118,19 @@ export function observeReplayWorkflows(input: { return observations; } -function observeWorkflowTimeline(input: { +async function observeWorkflowTimeline(input: { id: string; result: unknown; - entries: Iterable; - convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; + entries: Iterable | AsyncIterable; + convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise; finishedAt?: string; -}): SubagentObservation[] { +}): Promise { const observations: SubagentObservation[] = []; const replayedAssistantText = new Set(); - for (const entry of input.entries) { + for await (const entry of input.entries) { const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); - const items = input.convertEntry?.(entry) ?? []; + const items = (await input.convertEntry?.(entry)) ?? []; for (const item of items) { if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim()); observations.push({ @@ -150,11 +153,13 @@ function observeWorkflowTimeline(input: { return observations; } -function readWorkflowLinks(entries: Iterable): Map { +async function readWorkflowLinks( + entries: Iterable | AsyncIterable, +): Promise> { const workflowToolCallIds = new Set(); const toolCallIdByRunId = new Map(); - for (const entry of entries) { + for await (const entry of entries) { const content = entry.message?.content; if (!Array.isArray(content)) continue; for (const value of content) { @@ -165,7 +170,7 @@ function readWorkflowLinks(entries: Iterable): Map(entries: Iterable): Generator { +export async function* assignTaskNotificationReplayIds( + entries: AsyncIterable, +): AsyncGenerator { const occurrences = new Map>(); - for (const entry of entries) { + for await (const entry of entries) { const record = TaskNotificationHistoryRecordSchema.safeParse(entry); if (!record.success) { yield entry; @@ -405,19 +407,21 @@ export function mapTaskNotificationSystemRecordToToolCall( } export function readTaskNotificationToolUseIdFromHistoryRecord(record: unknown): string | null { + return readTaskNotificationFromHistoryRecord(record)?.toolUseId ?? null; +} + +export function readTaskNotificationFromHistoryRecord( + record: unknown, +): TaskNotificationEnvelope | null { const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record); - if (!parsedRecord.success) { - return null; - } + if (!parsedRecord.success) return null; if (parsedRecord.data.type === "user" && parsedRecord.data.message) { - return ( - parseTaskNotificationFromUserContent({ - content: parsedRecord.data.message.content, - messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id, - })?.toolUseId ?? null - ); + return parseTaskNotificationFromUserContent({ + content: parsedRecord.data.message.content, + messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id, + }); } - return parseTaskNotificationFromSystemRecord(record)?.toolUseId ?? null; + return parseTaskNotificationFromSystemRecord(record); } export function coerceTaskNotificationHistoryRecordToSystemMessage( diff --git a/packages/server/src/server/agent/providers/claude/transcript-history.ts b/packages/server/src/server/agent/providers/claude/transcript-history.ts index f8663f83d0..09882b76dd 100644 --- a/packages/server/src/server/agent/providers/claude/transcript-history.ts +++ b/packages/server/src/server/agent/providers/claude/transcript-history.ts @@ -1,5 +1,6 @@ import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js"; -import fs from "node:fs"; +import fs, { type FileHandle } from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import path from "node:path"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; import { @@ -24,11 +25,11 @@ interface RecordLocation { } /** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */ -function* locatedRecords( +async function* locatedRecords( file: string, - fd: number, -): Generator<{ entry: TranscriptEntry; location: RecordLocation }> { - const stat = fs.fstatSync(fd); + fd: FileHandle, +): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> { + const stat = await fd.stat(); // Opening a directory succeeds on some platforms; it must never look like empty history. if (stat.isDirectory()) { throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), { @@ -43,7 +44,7 @@ function* locatedRecords( let length = 0; while (remaining > 0) { const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining)); - const count = fs.readSync(fd, buffer, 0, buffer.length, null); + const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null); if (count === 0) break; remaining -= count; let start = 0; @@ -64,6 +65,8 @@ function* locatedRecords( if (entry) yield { entry, location }; start = end + 1; } + // Bound parsing work as well as IO; microtasks alone do not service timers or sockets. + await setImmediate(); if (start < count) { pieces.push(buffer.subarray(start, count)); length += count - start; @@ -85,20 +88,25 @@ function* locatedRecords( /** Indexed sources parse only their own records using descriptors owned by this replay. */ function indexedRecords( locations: readonly RecordLocation[], - descriptors: ReadonlyMap, -): Iterable { + descriptors: ReadonlyMap, +): AsyncIterable { return { - *[Symbol.iterator]() { + async *[Symbol.asyncIterator]() { yield* assignTaskNotificationReplayIds(readEntries()); }, }; - function* readEntries(): Generator { + async function* readEntries(): AsyncGenerator { for (const location of locations) { const fd = descriptors.get(location.file)!; const buffer = Buffer.allocUnsafe(location.length); let read = 0; while (read < buffer.length) { - const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read); + const { bytesRead: count } = await fd.read( + buffer, + read, + buffer.length - read, + location.offset + read, + ); if (count === 0) throw new Error(`Claude transcript was truncated during replay: ${location.file}`); read += count; @@ -127,9 +135,9 @@ function parseRecord(line: string): TranscriptEntry | null { } } -function readOptionalFile(file: string): string | null { +async function readOptionalFile(file: string): Promise { try { - return fs.readFileSync(file, "utf8"); + return await fs.readFile(file, "utf8"); } catch { return null; } @@ -138,31 +146,40 @@ function readOptionalFile(file: string): string | null { /** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded * by the replay ownership resolver; workflow children retain their separate run ownership. */ export interface ClaudeReplayHistory { - parentEntries: Iterable; + parentEntries: AsyncIterable; subagents: { agentId: string; meta: ClaudeSubagentMeta | null; - entries: Iterable; + entries: AsyncIterable; }[]; workflows: ClaudeWorkflowRun[]; - workflowEntriesByRunId: Map>; + workflowEntriesByRunId: Map>; } /** Own descriptors through every pass so unlink or rename cannot invalidate discovered history. * Sources must be consumed before the callback returns. No transcript copies are needed. */ -export function withClaudeReplayHistory( +export async function withClaudeReplayHistory( historyPath: string, - consume: (history: ClaudeReplayHistory) => T, -): T { - const descriptors = new Map(); + consume: (history: ClaudeReplayHistory) => Promise, +): Promise { + const descriptors = new Map(); try { - return consume(indexHistory(historyPath, descriptors)); + return await consume(await indexHistory(historyPath, descriptors)); } finally { - for (const fd of descriptors.values()) fs.closeSync(fd); + await closeReplaySources(descriptors.values()); } } -function indexHistory(historyPath: string, descriptors: Map): ClaudeReplayHistory { +async function closeReplaySources(descriptors: Iterable): Promise { + const results = await Promise.allSettled([...descriptors].map((fd) => fd.close())); + const failure = results.find((result) => result.status === "rejected"); + if (failure) throw failure.reason; +} + +async function indexHistory( + historyPath: string, + descriptors: Map, +): Promise { const sessionDirectory = path.join( path.dirname(historyPath), path.basename(historyPath, ".jsonl"), @@ -174,8 +191,7 @@ function indexHistory(historyPath: string, descriptors: Map): Cl const directories = [sidechainDirectory]; while (directories.length) { const directory = directories.pop()!; - if (!fs.existsSync(directory)) continue; - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + for (const entry of await optionalDirectory(directory)) { const file = path.join(directory, entry.name); if (entry.isDirectory()) { directories.push(file); @@ -194,23 +210,23 @@ function indexHistory(historyPath: string, descriptors: Map): Cl } const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1]; if (!agentId) continue; - const contents = readOptionalFile(file); + const contents = await readOptionalFile(file); const meta = contents === null ? null : parseClaudeSubagentMeta(contents); if (meta) metaByAgentId.set(agentId, meta); } } const parentLocations: RecordLocation[] = []; const locationsByAgentId = new Map(); - function scan( + async function scan( file: string, visit: (entry: TranscriptEntry, location: RecordLocation) => void, - ): void { - const fd = fs.openSync(file, "r"); + ): Promise { + const fd = await fs.open(file, "r"); descriptors.set(file, fd); - for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location); + for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location); } for (const file of sidechainFiles) { - scan(file, (entry, location) => { + await scan(file, (entry, location) => { if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location); if (entry.isSidechain !== true || typeof entry.agentId !== "string") return; const locations = locationsByAgentId.get(entry.agentId) ?? []; @@ -218,11 +234,11 @@ function indexHistory(historyPath: string, descriptors: Map): Cl locationsByAgentId.set(entry.agentId, locations); }); } - const workflowEntriesByRunId = new Map>(); + const workflowEntriesByRunId = new Map>(); for (const [runId, files] of workflowFiles) { const locations: RecordLocation[] = []; for (const file of files) - scan(file, (entry, location) => { + await scan(file, (entry, location) => { if (entry.type !== "user" || isToolResult(entry)) locations.push(location); }); locations.sort((a, b) => { @@ -240,21 +256,28 @@ function indexHistory(historyPath: string, descriptors: Map): Cl meta: metaByAgentId.get(agentId) ?? null, entries: indexedRecords(locations, descriptors), })), - workflows: readWorkflows(sessionDirectory), + workflows: await readWorkflows(sessionDirectory), workflowEntriesByRunId, }; } -function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] { +async function readWorkflows(sessionDirectory: string): Promise { const workflows: ClaudeWorkflowRun[] = []; const workflowDirectory = path.join(sessionDirectory, "workflows"); - if (fs.existsSync(workflowDirectory)) { - for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith(".json")) continue; - const contents = readOptionalFile(path.join(workflowDirectory, entry.name)); - const workflow = contents === null ? null : parseClaudeWorkflowRun(contents); - if (workflow) workflows.push(workflow); - } + for (const entry of await optionalDirectory(workflowDirectory)) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const contents = await readOptionalFile(path.join(workflowDirectory, entry.name)); + const workflow = contents === null ? null : parseClaudeWorkflowRun(contents); + if (workflow) workflows.push(workflow); } return workflows; } + +async function optionalDirectory(directory: string) { + try { + return await fs.readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts index 0469d9fbe1..dc7f920993 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts @@ -264,6 +264,98 @@ describe("Codex app-server provider (local e2e)", () => { 30_000, ); + test.runIf(isCodexInstalled())( + "reopens a promptless agent repeatedly before its first model request", + async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-promptless-cwd-")); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(cwd, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new CodexAppServerAgentClient(logger) }, + registry: storage, + logger, + }); + let agentId: string | undefined; + try { + const created = await manager.createAgent( + { provider: "codex", cwd, modeId: "auto", model: "mock-model" }, + undefined, + { workspaceId: undefined }, + ); + agentId = created.id; + // A real app-server rejects an unsaved thread on resume. No model request is + // needed to prove that reopening the agent remains safe before its first turn. + for (let i = 0; i < 2; i++) { + const reopened = await manager.reloadAgentSession(agentId); + expect(reopened.lifecycle).toBe("idle"); + expect(reopened.persistence).toBeNull(); + } + await storage.flush(); + expect((await storage.get(agentId))?.persistence).toBeNull(); + } finally { + if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId); + await storage.flush(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 30_000, + ); + + test.runIf(isCodexInstalled())( + "reopens an agent that was never prompted without resuming its unsaved thread", + async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-cwd-")); + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-home-")); + const mockServer = await startMockResponsesServer([ + assistantMessageSse("first"), + assistantMessageSse("second"), + ]); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(cwd, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new CodexAppServerAgentClient(logger) }, + registry: storage, + logger, + }); + let agentId: string | undefined; + try { + writeMockCodexConfig(codexHome, mockServer.url); + vi.stubEnv("CODEX_HOME", codexHome); + const created = await manager.createAgent( + { provider: "codex", cwd, modeId: "auto", model: "mock-model" }, + undefined, + { workspaceId: undefined }, + ); + agentId = created.id; + // Codex writes a thread's rollout at its first turn, so there is nothing to resume yet. + expect(manager.getAgent(agentId)?.persistence).toBeNull(); + await storage.flush(); + expect((await storage.get(agentId))?.persistence).toBeNull(); + + await manager.reloadAgentSession(agentId); + await manager.runAgent(agentId, "first prompt after reopening"); + const threadId = manager.getAgent(agentId)?.persistence?.sessionId; + expect(threadId).toEqual(expect.any(String)); + await storage.flush(); + expect((await storage.get(agentId))?.persistence?.sessionId).toBe(threadId); + + const reloaded = await manager.reloadAgentSession(agentId); + expect(reloaded.persistence?.sessionId).toBe(threadId); + await manager.runAgent(agentId, "continue the saved thread"); + expect(manager.getAgent(agentId)?.lifecycle).toBe("idle"); + expect(mockServer.requestBodies.at(-1)).toContain("first prompt after reopening"); + } finally { + if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId); + await storage.flush(); + vi.unstubAllEnvs(); + await mockServer.close(); + rmSync(cwd, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + } + }, + 30_000, + ); + test.runIf(isCodexInstalled())( "surfaces request_user_input from the app-server as question permissions and timeline tool calls", async () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts index 5a6592862c..f564b7bf94 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts @@ -77,12 +77,19 @@ CHILD_SENTINEL in your own response. const completedChildIndex = events.findIndex( (event) => - event.type === "timeline" && - event.item.type === "tool_call" && - event.item.status === "completed" && - event.item.detail.type === "sub_agent" && - event.item.detail.log.includes("CHILD_SENTINEL"), + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.status === "completed", ); + const childAssistantText = events + .flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "assistant_message" + ? [event.event.item.text] + : [], + ) + .join(""); const rootMessageIndexes = events.flatMap((event, index) => event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [], ); @@ -105,6 +112,7 @@ CHILD_SENTINEL in your own response. .map((item) => item.text) .join(""); expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL"); + expect(childAssistantText).toContain("CHILD_SENTINEL"); expect(completedChildIndex).toBeGreaterThanOrEqual(0); expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex); expect(terminalIndexes).toHaveLength(1); 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 8fca5f24c0..9986357cf4 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 @@ -16,6 +16,7 @@ import type { AgentSessionConfig, AgentSlashCommand, AgentStreamEvent, + AgentTimelineItem, } from "../agent-sdk-types.js"; import { buildCodexAppServerEnv, @@ -87,7 +88,9 @@ describe("Codex executable discovery", () => { import { CodexAppServerClient } from "./codex/app-server-transport.js"; import { createFakeCodexAppServer, + fakeCodexThreadHistory, type FakeCodexAppServer, + waitForNextEvent, waitForNextPermission, waitForNextTimelineItem, waitForProviderSubagent, @@ -113,8 +116,7 @@ interface CodexSessionTestAccess { }; ensureThreadLoaded(): Promise; handleToolApprovalRequest(params: unknown): Promise; - handleNotification(method: string, params: unknown): void; - loadPersistedHistory(client: CodexClientLike | null): Promise; + handleNotification(method: string, params: unknown): Promise; refreshResolvedCollaborationMode(): void; serviceTier: "fast" | null; planModeEnabled: boolean; @@ -212,6 +214,7 @@ async function startPublicSteeringSession( const started = await session.startTurn("first"); await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" }); + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setTimeout(resolve, 0)); return { session, paseoTurnId: started.turnId }; } @@ -225,6 +228,64 @@ function deferred() { } describe("Codex active-turn steering admission", () => { + test("steers an accepted turn whose native id arrives after startTurn returns", async () => { + const appServer = createFakeCodexAppServer({ + "turn/steer": () => ({ turn: { id: "native-A" } }), + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + try { + const { turnId } = await session.startTurn("FIRST"); + const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId }); + appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" }); + + await expect(steer).resolves.toEqual({ status: "accepted" }); + expect( + appServer.requests().filter((request) => request.method === "turn/start"), + ).toHaveLength(1); + expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual( + [], + ); + expect(appServer.requests().find((request) => request.method === "turn/steer")).toMatchObject( + { + params: { + threadId: "thread-1", + expectedTurnId: "native-A", + input: [{ type: "text", text: "SECOND" }], + }, + }, + ); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("releases a waiting steer when the accepted turn ends before identification", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + try { + const { turnId } = await session.startTurn("FIRST"); + const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId }); + appServer.completeTurn(); + + await expect(steer).resolves.toEqual({ status: "unavailable" }); + expect(appServer.requests().filter((request) => request.method === "turn/steer")).toEqual([]); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("a steer without the clearing contract leaves permissions open", async () => { const appServer = createFakeCodexAppServer({ "turn/steer": () => ({ turn: { id: "native-A" } }), @@ -354,9 +415,11 @@ describe("Codex active-turn steering admission", () => { }); await resolverEntered.promise; appServer.completeTurn({ threadId: "thread-1" }); + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setTimeout(resolve, 0)); const startedB = await session.startTurn("second"); appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" }); + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setTimeout(resolve, 0)); commandResolution.resolve(null); @@ -478,6 +541,7 @@ async function startCompactionTurnTest(): Promise<{ await session.startTurn("exercise compaction lifecycle"); appServer.startsTurn({ threadId: "thread-1", turnId: "codex-turn-1" }); + await new Promise((resolve) => setImmediate(resolve)); return { appServer, session, events, terminalEvent }; } @@ -499,6 +563,81 @@ function archivedThreadErrorMessage(threadId: string): string { ); } +function codexUserItem(id: string, text: string) { + return { type: "userMessage", id, content: [{ type: "text", text }] }; +} + +function codexAgentItem(text: string) { + return { type: "agentMessage", id: `agent-${text}`, text }; +} + +// An AgentManager over a real Codex provider whose every spawn gets a fresh fake +// app-server serving `threads` from thread/read. +function createReopenableCodexAgent(threads: Record) { + const appServers: FakeCodexAppServer[] = []; + const provider = new CodexAppServerAgentClient(createTestLogger()); + const internals = castInternals<{ + goalsEnabledPromise: Promise | null; + autoReviewEnabledPromise: Promise | null; + spawnAppServer: () => Promise; + }>(provider); + internals.goalsEnabledPromise = Promise.resolve(false); + internals.autoReviewEnabledPromise = Promise.resolve(false); + internals.spawnAppServer = async () => { + const appServer = createFakeCodexAppServer({ + "thread/read": (params) => { + const { threadId } = params as { threadId: string }; + return { thread: { id: threadId, turns: threads[threadId] ?? [] } }; + }, + }); + appServers.push(appServer); + return appServer.child; + }; + // The fake app-server stands in for the Codex CLI, which test machines may not have. + const client: AgentClient = { + provider: "codex", + capabilities: provider.capabilities, + isAvailable: async () => true, + fetchCatalog: async () => ({ models: [], modes: [] }), + createSession: async () => { + throw new Error("Unexpected create"); + }, + resumeSession: (...args) => provider.resumeSession(...args), + }; + const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() }); + const agentId = "00000000-0000-4000-8000-000000000001"; + const handle = { provider: "codex", sessionId: "saved-thread" }; + const config = createConfig({ cwd: process.cwd() }); + + async function open(): Promise { + await manager.resumeAgentFromPersistence(handle, config, agentId); + await manager.hydrateTimelineFromProvider(agentId); + } + + return { + manager, + agentId, + latestAppServer: () => appServers[appServers.length - 1], + threadMethods: (index: number) => + appServers[index] + .requests() + .map((request) => request.method) + .filter((method) => typeof method === "string" && method.startsWith("thread/")), + // Cold-loads the agent, closes its runtime, and reopens it while Paseo keeps its timeline. + async loadCloseAndReopen(): Promise { + await open(); + const projected = manager.getTimeline(agentId); + await manager.closeAgent(agentId); + await open(); + return projected; + }, + async close(): Promise { + await manager.closeAgent(agentId); + for (const appServer of appServers) appServer.assertNoErrors(); + }, + }; +} + function asInternals(session: CodexTestSession): CodexSessionTestAccess { return castInternals(session); } @@ -1149,6 +1288,9 @@ describe("Codex app-server provider", () => { ); await firstSession.startTurn("first turn"); await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" }); + const saved = waitForNextEvent(firstSession, "thread_started"); + beforeRestart.startsTurn({ threadId: "thread-1" }); + await saved; const handle = firstSession.describePersistence()!; await firstSession.close(); @@ -1181,6 +1323,9 @@ describe("Codex app-server provider", () => { try { await session.startTurn("persist the configured session"); await appServer.waitForTurnStart(); + const saved = waitForNextEvent(session, "thread_started"); + appServer.startsTurn({ threadId: "thread-1" }); + await saved; const handle = session.describePersistence()!; expect(handle.metadata).not.toHaveProperty("providerOptions"); appServer.assertNoErrors(); @@ -1189,6 +1334,55 @@ describe("Codex app-server provider", () => { } }); + test("reports a new thread only after Codex announces its first turn", async () => { + const appServer = createFakeCodexAppServer(); + const session = await createProviderWithFakeAppServer(appServer).createSession(createConfig()); + try { + // Codex has started the thread but writes its rollout at the first turn, so another + // app-server could not resume it yet. + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: null }); + expect(session.describePersistence()).toBeNull(); + expect( + appServer.requests().filter((request) => request.method === "thread/start"), + ).toHaveLength(1); + + await session.startTurn("first prompt"); + await appServer.waitForTurnStart(); + expect(session.describePersistence()).toBeNull(); + + const saved = waitForNextEvent(session, "thread_started"); + appServer.startsTurn({ threadId: "thread-1" }); + await expect(saved).resolves.toMatchObject({ + type: "thread_started", + provider: "codex", + sessionId: "thread-1", + }); + expect(session.describePersistence()).toMatchObject({ + sessionId: "thread-1", + nativeHandle: "thread-1", + }); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: "thread-1" }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("reports a resumed thread before its next turn", async () => { + const appServer = createFakeCodexAppServer(); + const session = await createProviderWithFakeAppServer(appServer).resumeSession( + { provider: "codex", sessionId: "saved-thread" }, + createConfig(), + ); + try { + expect(session.describePersistence()).toMatchObject({ sessionId: "saved-thread" }); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: "saved-thread" }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("preapproves only granted tools on the injected Codex MCP server", async () => { const session = createSession({ modeId: undefined, @@ -1460,7 +1654,9 @@ describe("Codex app-server provider", () => { command: "gh pr merge 2030 --squash", cwd: "/workspace/project", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", text: "Merged." }); + await new Promise((resolve) => setImmediate(resolve)); await expect(nextTimelineItem).resolves.toEqual({ type: "timeline", @@ -1504,6 +1700,7 @@ describe("Codex app-server provider", () => { command: "gh pr merge 2030 --squash", cwd: "/workspace/project", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(nextTimelineItem).resolves.toEqual({ type: "timeline", @@ -1548,6 +1745,7 @@ describe("Codex app-server provider", () => { processId: "4242", text: "gh pr merge 2030 --squash\n", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(nextTimelineItem).resolves.toEqual({ type: "timeline", @@ -1576,6 +1774,7 @@ describe("Codex app-server provider", () => { command: "sleep 30", output: "Process running with session id 4242", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(relabeledTerminal).resolves.toEqual({ type: "timeline", @@ -1623,6 +1822,7 @@ describe("Codex app-server provider", () => { processId: "73", command, }); + await new Promise((resolve) => setImmediate(resolve)); const terminalWrite = waitForTimelineToolCall(session, "terminal-session-73-1"); appServer.typesIntoTerminal({ @@ -1632,6 +1832,7 @@ describe("Codex app-server provider", () => { processId: "73", text: "print(1)\n", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(terminalWrite).resolves.toMatchObject({ item: { @@ -1668,7 +1869,9 @@ describe("Codex app-server provider", () => { processId: "4242", text: "git status\n", }); + await new Promise((resolve) => setImmediate(resolve)); + await firstTimelineItem; const secondTimelineItem = waitForNextTimelineItem(session); appServer.typesIntoTerminal({ threadId: "thread-1", @@ -1677,6 +1880,7 @@ describe("Codex app-server provider", () => { processId: "4242", text: "git push\n", }); + await new Promise((resolve) => setImmediate(resolve)); const [first, second] = await Promise.all([firstTimelineItem, secondTimelineItem]); expect(first.item).toMatchObject({ @@ -1844,7 +2048,7 @@ describe("Codex app-server provider", () => { purpose: "history", }); - expect(threadRequests).toEqual(["thread/read"]); + expect(threadRequests).toEqual(["thread/read", "thread/read"]); await session.close(); appServer.assertNoErrors(); }); @@ -1883,12 +2087,148 @@ describe("Codex app-server provider", () => { "thread/resume", "thread/unarchive", "thread/resume", - "thread/read", ]); await session.close(); appServer.assertNoErrors(); }); + test("resumes a Codex thread without asking for its turns", async () => { + const appServer = createFakeCodexAppServer(); + const provider = createProviderWithFakeAppServer(appServer); + + const session = await provider.resumeSession({ sessionId: "saved-thread" }); + + expect( + appServer + .requests() + .filter((request) => request.method === "thread/resume") + .map((request) => request.params), + ).toEqual([expect.objectContaining({ threadId: "saved-thread", excludeTurns: true })]); + await session.close(); + appServer.assertNoErrors(); + }); + + test("reopening an agent whose timeline Paseo still holds does not read Codex history", async () => { + const codex = createReopenableCodexAgent({ + "saved-thread": [ + { id: "turn-1", items: [codexUserItem("user-1", "Hello"), codexAgentItem("Hi there.")] }, + ], + }); + try { + const projected = await codex.loadCloseAndReopen(); + + expect(projected).toEqual([ + expect.objectContaining({ type: "user_message", messageId: "user-1", text: "Hello" }), + expect.objectContaining({ type: "assistant_message", text: "Hi there." }), + ]); + expect(codex.threadMethods(0)).toEqual([ + "thread/loaded/list", + "thread/resume", + "thread/read", + "thread/read", + ]); + expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]); + expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected); + } finally { + await codex.close(); + } + }); + + test("rewinds an agent reopened without reading its Codex history", async () => { + const firstTurn = { + id: "turn-1", + items: [codexUserItem("user-1", "First"), codexAgentItem("First answer.")], + }; + const codex = createReopenableCodexAgent({ + "saved-thread": [ + firstTurn, + { id: "turn-2", items: [codexUserItem("user-2", "Second"), codexAgentItem("Second.")] }, + ], + "forked-thread": [firstTurn], + }); + try { + await codex.loadCloseAndReopen(); + + await codex.manager.rewind(codex.agentId, "user-2", "conversation"); + + expect(codex.latestAppServer().recordedRollbacks).toEqual([ + { threadId: "forked-thread", numTurns: 1 }, + ]); + expect(codex.manager.getTimeline(codex.agentId)).toEqual([ + expect.objectContaining({ type: "user_message", messageId: "user-1", text: "First" }), + expect.objectContaining({ type: "assistant_message", text: "First answer." }), + ]); + } finally { + await codex.close(); + } + }); + + test("keeps a reopened agent's interaction with an earlier sub-agent on its original card", async () => { + const codex = createReopenableCodexAgent({ + "saved-thread": [ + { + id: "turn-1", + items: [ + codexUserItem("user-1", "Delegate"), + { + type: "subAgentActivity", + id: "spawn-child", + kind: "started", + agentThreadId: "child-thread", + agentPath: "/root/child", + }, + ], + }, + ], + "child-thread": [{ id: "child-turn-1", items: [codexAgentItem("Child started.")] }], + }); + try { + await codex.loadCloseAndReopen(); + const appServer = codex.latestAppServer(); + + const run = codex.manager.runAgent(codex.agentId, "Ask the child again"); + await appServer.waitForTurnStart(); + appServer.startsTurn({ threadId: "saved-thread", turnId: "turn-2" }); + appServer.beginsSubAgentActivity({ + callId: "interact-child", + threadId: "child-thread", + parentThreadId: "saved-thread", + kind: "interacted", + agentPath: "/root/child", + }); + appServer.says({ threadId: "child-thread", itemId: "child-reply", text: "Child replied." }); + appServer.completesSubAgentActivity({ + callId: "interact-child", + threadId: "child-thread", + parentThreadId: "saved-thread", + kind: "interacted", + agentPath: "/root/child", + }); + appServer.completeTurn({ threadId: "saved-thread" }); + await run; + + const toolCalls = codex.manager + .getTimeline(codex.agentId) + .filter((item) => item.type === "tool_call"); + expect(new Set(toolCalls.map((item) => item.callId))).toEqual(new Set(["spawn-child"])); + expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child", status: "completed" }); + expect(codex.manager.getProviderSubagent(codex.agentId, "child-thread")).toMatchObject({ + toolCallId: "spawn-child", + status: "completed", + }); + expect( + codex.manager + .fetchProviderSubagentTimeline(codex.agentId, "child-thread") + .rows.map((row) => row.item), + ).toEqual([ + expect.objectContaining({ type: "assistant_message", text: "Child started." }), + expect.objectContaining({ type: "assistant_message", text: "Child replied." }), + ]); + } finally { + await codex.close(); + } + }); + test("closes Codex app-server when an interactive resume fails", async () => { const appServer = createFakeCodexAppServer({ "thread/resume": () => Promise.reject(new Error("thread resume failed")), @@ -2075,9 +2415,13 @@ describe("Codex app-server provider", () => { await session.startTurn("remember first"); emitCodexUserMessage(appServer, { id: "codex-first", text: "remember first" }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", text: "remember second" }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-first" }); @@ -2116,6 +2460,8 @@ describe("Codex app-server provider", () => { turnId: "turn-first", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", @@ -2123,6 +2469,8 @@ describe("Codex app-server provider", () => { turnId: "turn-second", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-second" }); @@ -2137,7 +2485,7 @@ describe("Codex app-server provider", () => { cwd: "/workspace/project", model: "gpt-5.4", serviceTier: null, - excludeTurns: false, + excludeTurns: true, persistExtendedHistory: true, }, ]); @@ -2151,8 +2499,9 @@ describe("Codex app-server provider", () => { test("rewinds a paginated conversation through the public session capability", async () => { const appServer = createFakeCodexAppServer({ - "thread/read": () => ({ - thread: { id: "thread-1", historyMode: "paginated", turns: [] }, + ...fakeCodexThreadHistory({ + "thread-1": { historyMode: "paginated", turns: [] }, + "forked-thread": { historyMode: "paginated", turns: [] }, }), "thread/rollback": () => { throw new Error("paginated threads do not support thread/rollback"); @@ -2172,6 +2521,8 @@ describe("Codex app-server provider", () => { turnId: "turn-first", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", @@ -2179,6 +2530,8 @@ describe("Codex app-server provider", () => { turnId: "turn-second", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-first" }); @@ -2193,7 +2546,7 @@ describe("Codex app-server provider", () => { cwd: "/workspace/project", model: "gpt-5.4", serviceTier: null, - excludeTurns: false, + excludeTurns: true, persistExtendedHistory: true, }, ]); @@ -2210,11 +2563,10 @@ describe("Codex app-server provider", () => { async (historyMode) => { const appServer = createFakeCodexAppServer( historyMode === "paginated" - ? { - "thread/read": () => ({ - thread: { id: "thread-1", historyMode: "paginated", turns: [] }, - }), - } + ? fakeCodexThreadHistory({ + "thread-1": { historyMode: "paginated", turns: [] }, + "forked-thread": { historyMode: "paginated", turns: [] }, + }) : undefined, ); const customCodexConfig = { @@ -2249,6 +2601,8 @@ describe("Codex app-server provider", () => { turnId: "turn-first", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.startTurn("remember second"); emitCodexUserMessage(appServer, { id: "codex-second", @@ -2256,6 +2610,8 @@ describe("Codex app-server provider", () => { turnId: "turn-second", }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.revertConversation({ messageId: "codex-first" }); @@ -2290,7 +2646,7 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); await session.startTurn("remember this", { clientMessageId: "client-message" }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "thread-1", turn: { id: "turn-1" }, }); @@ -2304,8 +2660,15 @@ describe("Codex app-server provider", () => { clientMessageId: "client-message", }, }); - expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]); + // The first turn of a new thread also reports the thread, now that Codex has saved it. + expect(events.slice(0, 3).map((event) => event.type)).toEqual([ + "thread_started", + "turn_started", + "timeline", + ]); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); await session.close(); }); @@ -2321,9 +2684,9 @@ describe("Codex app-server provider", () => { OPENAI_BASE_URL: "https://custom-relay.example.com", }); expect(capturedThreadStartConfig(capturedRequests)).toEqual({ - model_provider: "codex-iisb", + model_provider: "paseo:codex-iisb", model_providers: { - "codex-iisb": { + "paseo:codex-iisb": { name: "Custom Codex", base_url: "https://custom-relay.example.com/v1", env_key: "OPENAI_API_KEY", @@ -2341,15 +2704,30 @@ describe("Codex app-server provider", () => { ); expect(capturedThreadStartConfig(capturedRequests)).toEqual({ - model_provider: "codex-custom", + model_provider: "paseo:codex-custom", model_providers: { - "codex-custom": expect.objectContaining({ + "paseo:codex-custom": expect.objectContaining({ base_url: "https://custom-relay.example.com/v1", }), }, }); }); + test("namespaces provider ids that collide with Codex built-ins", async () => { + // `ollama` is a reserved Codex provider id: without namespacing, Codex rejects + // the generated config outright and the agent never starts. + const capturedRequests = await runCustomCodexProviderTurn("ollama", "https://ollama.com"); + + expect(capturedThreadStartConfig(capturedRequests)).toEqual({ + model_provider: "paseo:ollama", + model_providers: { + "paseo:ollama": expect.objectContaining({ + base_url: "https://ollama.com/v1", + }), + }, + }); + }); + test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => { const threadRequests: string[] = []; const appServer = createFakeCodexAppServer({ @@ -3181,12 +3559,12 @@ describe("Codex app-server provider", () => { ]); }); - test("converts Codex collab agent notifications through the normal timeline path", () => { + test("converts Codex collab agent notifications through the normal timeline path", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -3222,12 +3600,12 @@ describe("Codex app-server provider", () => { ]); }); - test("folds child-thread Codex activity into the parent sub-agent tool call", () => { + test("keeps child-thread Codex activity out of the parent sub-agent card", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -3241,12 +3619,12 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "child-thread-1", itemId: "child-message-1", delta: "Found", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-1", item: { type: "agentMessage", @@ -3254,13 +3632,12 @@ describe("Codex app-server provider", () => { text: "Found the path.", }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "child-thread-1", turn: { status: "completed" }, }); const timelineEvents = events.filter((event) => event.type === "timeline"); - expect(timelineEvents).toHaveLength(4); expect(timelineEvents.every((event) => event.item.type === "tool_call")).toBe(true); const finalItem = timelineEvents.at(-1)?.item; expect(finalItem).toMatchObject({ @@ -3272,7 +3649,7 @@ describe("Codex app-server provider", () => { type: "sub_agent", subAgentType: "Sub-agent", description: "Report findings.", - log: "[Assistant] Found the path.", + log: "", actions: [], }, }); @@ -3332,12 +3709,15 @@ describe("Codex app-server provider", () => { threadId: "settled-child-thread", agentPath: "/root/settled-child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "settled-child-thread" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "settled-child-thread", itemId: "late-child-message", text: "Late trailing output.", }); + await new Promise((resolve) => setImmediate(resolve)); const providerUpserts = events.flatMap((event) => event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], @@ -3346,13 +3726,9 @@ describe("Codex app-server provider", () => { id: "settled-child-thread", status: "completed", }); - expect(events.at(-1)).toMatchObject({ + expect(events.findLast((event) => event.type === "timeline")).toMatchObject({ type: "timeline", - item: { - callId: "call-settled-child", - status: "completed", - detail: { type: "sub_agent", log: "[Assistant] Late trailing output." }, - }, + item: { callId: "call-settled-child", status: "completed" }, }); appServer.completesSubAgentActivity({ @@ -3361,6 +3737,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/settled-child", kind: "interacted", }); + await new Promise((resolve) => setImmediate(resolve)); expect( events.findLast( (event) => event.type === "provider_subagent" && event.event.type === "upsert", @@ -3371,6 +3748,7 @@ describe("Codex app-server provider", () => { threadId: "settled-child-thread", turnId: "next-child-turn", }); + await new Promise((resolve) => setImmediate(resolve)); expect( events.findLast( (event) => event.type === "provider_subagent" && event.event.type === "upsert", @@ -3378,7 +3756,9 @@ describe("Codex app-server provider", () => { ).toMatchObject({ event: { id: "settled-child-thread", status: "running" } }); appServer.completeTurn({ threadId: "settled-child-thread" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -3386,12 +3766,12 @@ describe("Codex app-server provider", () => { } }); - test("updates a registered child with its later native activity name", () => { + test("updates a registered child with its later native activity name", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -3405,7 +3785,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3440,11 +3820,11 @@ describe("Codex app-server provider", () => { }); }); - test("renders child MCP image results in the provider subagent timeline", () => { + test("renders child MCP image results in the provider subagent timeline", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3455,7 +3835,7 @@ describe("Codex app-server provider", () => { }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "image-child-thread", item: { id: "child-mcp-image", @@ -3488,11 +3868,11 @@ describe("Codex app-server provider", () => { rmSync(source, { force: true }); }); - test("renders a child user message once across lifecycle notifications", () => { + test("renders a child user message once across lifecycle notifications", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3508,11 +3888,11 @@ describe("Codex app-server provider", () => { content: [{ type: "text", text: "Inspect this path." }], }; - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "user-child-thread", item: childUserMessage, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "user-child-thread", item: childUserMessage, }); @@ -3546,19 +3926,24 @@ describe("Codex app-server provider", () => { threadId: "child-thread-1", agentPath: "/root/child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "child-thread-1", itemId: "child-message-1", text: "Child findings.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "child-thread-1" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", itemId: "parent-message-1", text: "Parent report.", chunks: ["Parent ", "report."], }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Parent report."); @@ -3592,13 +3977,16 @@ describe("Codex app-server provider", () => { itemId: "first-parent-message", text: "First report.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", itemId: "second-parent-message", text: "Second report.", chunks: ["", "Second report."], }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Second report."); @@ -3623,8 +4011,11 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.says({ threadId: "thread-1", text: "First report." }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", text: "Second report." }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Second report."); @@ -3642,29 +4033,37 @@ describe("Codex app-server provider", () => { createTestLogger(), async () => appServer.child, ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); try { const resultPromise = session.run("Delegate the investigation, then report the result."); await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "child-thread-early" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "child-thread-early", itemId: "child-message-early", text: "Early child findings.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "child-thread-early" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.startsSubAgent({ callId: "spawn-child-early", threadId: "child-thread-early", agentPath: "/root/early-child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "thread-1", itemId: "parent-message-after-early-child", text: "Parent report after replay.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; expect(result.finalText).toBe("Parent report after replay."); @@ -3679,11 +4078,18 @@ describe("Codex app-server provider", () => { type: "tool_call", callId: "spawn-child-early", status: "completed", - detail: { - type: "sub_agent", - log: "[Assistant] Early child findings.", - }, + detail: { type: "sub_agent", log: "" }, }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + id: "child-thread-early", + item: expect.objectContaining({ type: "assistant_message" }), + }), + }), + ); appServer.assertNoErrors(); } finally { await session.close(); @@ -3708,36 +4114,43 @@ describe("Codex app-server provider", () => { threadId: "child-thread-stable", agentPath: "/root/stable-child", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.beginsSubAgentActivity({ callId: "message-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interacted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesSubAgentActivity({ callId: "message-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interacted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.says({ threadId: "child-thread-stable", itemId: "stable-child-message", text: "Still on the same card.", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.beginsSubAgentActivity({ callId: "interrupt-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interrupted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesSubAgentActivity({ callId: "interrupt-child-stable", threadId: "child-thread-stable", agentPath: "/root/stable-child", kind: "interrupted", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3747,10 +4160,7 @@ describe("Codex app-server provider", () => { expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child-stable", status: "canceled", - detail: { - type: "sub_agent", - log: "[Assistant] Still on the same card.", - }, + detail: { type: "sub_agent", log: "" }, }); appServer.assertNoErrors(); } finally { @@ -3772,6 +4182,7 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.completeTurn({ threadId: "child-thread-fast" }); + await new Promise((resolve) => setImmediate(resolve)); const activity = { callId: "spawn-child-fast", threadId: "child-thread-fast", @@ -3779,8 +4190,11 @@ describe("Codex app-server provider", () => { kind: "started" as const, }; appServer.beginsSubAgentActivity(activity); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesSubAgentActivity(activity); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3809,24 +4223,27 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); appServer.completeTurn({ threadId: "child-late-compaction" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCompaction({ threadId: "child-late-compaction", itemId: "late-child-compaction", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.startsSubAgent({ callId: "spawn-child-late-compaction", threadId: "child-late-compaction", agentPath: "/root/late-compaction", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); - expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed", "completed"]); + expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]); expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child-late-compaction", status: "completed", - detail: { type: "sub_agent", log: "[Compacted]" }, }); appServer.assertNoErrors(); } finally { @@ -3834,7 +4251,7 @@ describe("Codex app-server provider", () => { } }); - test("projects legacy child tools into one stable sub-agent log", async () => { + test("routes legacy child tools to the child timeline", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( createConfig({ cwd: "/workspace/project" }), @@ -3842,6 +4259,8 @@ describe("Codex app-server provider", () => { createTestLogger(), async () => appServer.child, ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); try { const resultPromise = session.run("Delegate the implementation."); @@ -3852,6 +4271,7 @@ describe("Codex app-server provider", () => { threadId: "legacy-tool-child", agentPath: "/root/legacy-tool-child", }); + await new Promise((resolve) => setImmediate(resolve)); const command = { threadId: "legacy-tool-child", callId: "legacy-child-command", @@ -3859,15 +4279,20 @@ describe("Codex app-server provider", () => { output: "child output", }; appServer.runsLegacyCommand(command); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCommand(command); + await new Promise((resolve) => setImmediate(resolve)); appServer.appliesLegacyPatch({ threadId: "legacy-tool-child", callId: "legacy-child-patch", path: "/workspace/project/src/child.ts", diff: "@@\n-old\n+new\n", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ threadId: "legacy-tool-child" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); const result = await resultPromise; const toolCalls = result.timeline.filter((item) => item.type === "tool_call"); @@ -3878,24 +4303,31 @@ describe("Codex app-server provider", () => { expect(finalToolCall).toMatchObject({ callId: "spawn-legacy-tool-child", status: "completed", - detail: { type: "sub_agent" }, + detail: { type: "sub_agent", log: "" }, }); - if (finalToolCall?.detail.type === "sub_agent") { - expect(finalToolCall.detail.log.match(/\[Shell\]/g)).toHaveLength(1); - expect(finalToolCall.detail.log).toContain("[Edit]"); - } + const childToolCalls = events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === "legacy-tool-child" && + event.event.item.type === "tool_call" + ? [event.event.item] + : [], + ); + expect(new Set(childToolCalls.map((item) => item.detail.type))).toEqual( + new Set(["shell", "edit"]), + ); appServer.assertNoErrors(); } finally { await session.close(); } }); - test("keeps nested MultiAgentV2 output inside the root sub-agent card", () => { + test("keeps nested MultiAgentV2 output out of the root sub-agent card", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3905,7 +4337,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/child", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-root", item: { type: "subAgentActivity", @@ -3915,12 +4347,12 @@ describe("Codex app-server provider", () => { agentPath: "/root/child/grandchild", }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "grandchild-thread", itemId: "grandchild-message", delta: "Grandchild findings.", }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "grandchild-thread", turn: { status: "completed" }, }); @@ -3950,10 +4382,24 @@ describe("Codex app-server provider", () => { expect(beforeParentCompletes.at(-1)).toMatchObject({ callId: "spawn-child-root", status: "running", - detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") }, + detail: { type: "sub_agent", log: "" }, }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: { + type: "timeline", + id: "grandchild-thread", + item: { + type: "assistant_message", + messageId: "grandchild-message", + text: "Grandchild findings.", + }, + }, + }), + ); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "child-thread-root", turn: { status: "completed" }, }); @@ -3963,12 +4409,12 @@ describe("Codex app-server provider", () => { }); }); - test("keeps a nested child under its spawning parent after a root interaction", () => { + test("keeps a nested child under its spawning parent after a root interaction", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -3978,7 +4424,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/child", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-root", item: { type: "subAgentActivity", @@ -3988,7 +4434,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/child/grandchild", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -4001,7 +4447,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "grandchild-thread", itemId: "grandchild-after-wait", delta: "Still nested.", @@ -4018,37 +4464,44 @@ describe("Codex app-server provider", () => { "child-thread-root", "child-thread-root", ]); - expect(events.at(-1)).toMatchObject({ - type: "timeline", - item: { - callId: "spawn-child-root", - detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") }, - }, - }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: { + type: "timeline", + id: "grandchild-thread", + item: { + type: "assistant_message", + messageId: "grandchild-after-wait", + text: "Still nested.", + }, + }, + }), + ); }); - test("never treats an unmapped foreign terminal as the root terminal", () => { + test("never treats an unmapped foreign terminal as the root terminal", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "unmapped-child-thread", turn: { status: "completed" }, }); expect(events).toEqual([]); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { threadId: "test-thread", turn: { status: "completed" }, }); expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "test-thread", turn: { id: "next-root-turn" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4064,12 +4517,12 @@ describe("Codex app-server provider", () => { }); }); - test("routes msg-scoped legacy Codex events to their child thread", () => { + test("routes msg-scoped legacy Codex events to their child thread", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4079,7 +4532,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/legacy-envelope-child", }, }); - asInternals(session).handleNotification("codex/event/exec_command_begin", { + await asInternals(session).handleNotification("codex/event/exec_command_begin", { msg: { type: "exec_command_begin", threadId: "legacy-envelope-child", @@ -4087,7 +4540,7 @@ describe("Codex app-server provider", () => { command: "pwd", }, }); - asInternals(session).handleNotification("codex/event/task_complete", { + await asInternals(session).handleNotification("codex/event/task_complete", { msg: { type: "task_complete", thread_id: "legacy-envelope-child", @@ -4125,7 +4578,7 @@ describe("Codex app-server provider", () => { }, }); - asInternals(session).handleNotification("codex/event/task_complete", { + await asInternals(session).handleNotification("codex/event/task_complete", { msg: { type: "task_complete" }, }); expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); @@ -4147,11 +4600,13 @@ describe("Codex app-server provider", () => { const spawn = waitForTimelineToolCall(session, "spawn-legacy-only-child"); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-with-legacy-only-child" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.startsLegacyOnlySubAgent({ callId: "spawn-legacy-only-child", threadId: "legacy-only-child-thread", agentPath: "/root/legacy-only-child", }); + await new Promise((resolve) => setImmediate(resolve)); await expect(child).resolves.toMatchObject({ type: "provider_subagent", @@ -4179,6 +4634,7 @@ describe("Codex app-server provider", () => { }); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4203,10 +4659,12 @@ describe("Codex app-server provider", () => { const resultPromise = session.run("Wait for the child."); await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-waiting-for-child" }); + await new Promise((resolve) => setImmediate(resolve)); await expect(session.interrupt()).rejects.toThrow("A foreground turn is already active"); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4231,10 +4689,12 @@ describe("Codex app-server provider", () => { const resultPromise = session.run("Wait for the child."); await appServer.waitForTurnStart(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-already-idle" }); + await new Promise((resolve) => setImmediate(resolve)); await expect(session.interrupt()).resolves.toBeUndefined(); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4263,10 +4723,12 @@ describe("Codex app-server provider", () => { const interruptPromise = session.interrupt(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-identified-late" }); + await new Promise((resolve) => setImmediate(resolve)); await interruptPromise; expect(interruptedTurns).toEqual([{ threadId: "thread-1", turnId: "turn-identified-late" }]); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; appServer.assertNoErrors(); } finally { @@ -4294,6 +4756,7 @@ describe("Codex app-server provider", () => { await appServer.waitForTurnStart(); const interruptPromise = session.interrupt(); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await expect(interruptPromise).resolves.toBeUndefined(); await resultPromise; @@ -4353,7 +4816,9 @@ describe("Codex app-server provider", () => { const nativeStart = await appServer.waitForTurnStart().catch(() => null); if (nativeStart) { appServer.startsTurn({ threadId: "thread-1", turnId: "cleanup-turn" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); } await resultPromise.catch(() => undefined); await session.close(); @@ -4371,7 +4836,7 @@ describe("Codex app-server provider", () => { }, }; - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "test-thread", turn: { id: "autonomous-turn" }, }); @@ -4387,13 +4852,13 @@ describe("Codex app-server provider", () => { }); }); - test("never replaces the root identity with an early child thread start", () => { + test("never replaces the root identity with an early child thread start", async () => { const session = createSession(); - asInternals(session).handleNotification("thread/started", { + await asInternals(session).handleNotification("thread/started", { thread: { id: "child-thread-started-early" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4407,12 +4872,12 @@ describe("Codex app-server provider", () => { expect(session.currentThreadId).toBe("test-thread"); }); - test("does not leak aggregate child telemetry into the root timeline", () => { + test("does not leak aggregate child telemetry into the root timeline", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4424,7 +4889,7 @@ describe("Codex app-server provider", () => { }); const eventCountAfterSpawn = events.length; - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { threadId: "child-thread-telemetry", plan: [{ step: "Child-only plan", status: "inProgress" }], }); @@ -4432,12 +4897,12 @@ describe("Codex app-server provider", () => { expect(events).toHaveLength(eventCountAfterSpawn); }); - test("keeps child context compaction inside the child card", () => { + test("keeps child context compaction on the child timeline", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4447,11 +4912,11 @@ describe("Codex app-server provider", () => { agentPath: "/root/compacting-child", }, }); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "child-thread-compaction", item: { type: "contextCompaction", id: "child-compaction" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-compaction", item: { type: "contextCompaction", id: "child-compaction" }, }); @@ -4465,18 +4930,78 @@ describe("Codex app-server provider", () => { (item) => item.type === "tool_call" && item.callId === "spawn-child-compaction", ), ).toBe(true); - expect(timelineItems.at(-1)).toMatchObject({ - type: "tool_call", - detail: { type: "sub_agent", log: "[Compacted]" }, - }); + expect( + events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [], + ), + ).toEqual([ + { + type: "timeline", + id: "child-thread-compaction", + item: { type: "compaction", status: "loading" }, + }, + { + type: "timeline", + id: "child-thread-compaction", + item: { type: "compaction", status: "completed" }, + }, + ]); }); - test("keeps the parent sub-agent running when a child command fails during the child turn", () => { + test("settles a child compaction once when the item completion is missing or duplicated", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + for (const child of ["compacted-child", "turn-ended-child"]) { + await asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: `spawn-${child}`, + kind: "started", + agentThreadId: child, + agentPath: `/root/${child}`, + }, + }); + await asInternals(session).handleNotification("item/started", { + threadId: child, + item: { type: "contextCompaction", id: `${child}-compaction` }, + }); + } + await asInternals(session).handleNotification("thread/compacted", { + threadId: "compacted-child", + }); + await asInternals(session).handleNotification("item/completed", { + threadId: "compacted-child", + item: { type: "contextCompaction", id: "compacted-child-compaction" }, + }); + await asInternals(session).handleNotification("turn/completed", { + threadId: "turn-ended-child", + turn: { status: "completed" }, + }); + + const compactions = events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" + ? [ + `${event.event.id} ${event.event.item.type === "compaction" ? event.event.item.status : ""}`, + ] + : [], + ); + expect(compactions).toEqual([ + "compacted-child loading", + "turn-ended-child loading", + "compacted-child completed", + "turn-ended-child completed", + ]); + }); + + test("keeps the parent sub-agent running when a child command fails during the child turn", async () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -4490,7 +5015,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "child-thread-1", item: { type: "commandExecution", @@ -4503,7 +5028,7 @@ describe("Codex app-server provider", () => { }, }); - expect(events.at(-1)?.item).toMatchObject({ + expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({ type: "tool_call", callId: "call-sub-agent-child-command-failure", name: "Sub-agent", @@ -4517,12 +5042,12 @@ describe("Codex app-server provider", () => { }); }); - test("does not synthesize a parent sub-agent failure from child error state alone", () => { + test("does not synthesize a parent sub-agent failure from child error state alone", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "collabAgentToolCall", @@ -4584,14 +5109,13 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); } expect(requests.map((request) => [request.method, request.params])).toEqual([ + ["thread/read", { threadId: "test-thread", includeTurns: false }], ["thread/read", { threadId: "test-thread", includeTurns: true }], ]); expect(history).toEqual([ @@ -4638,7 +5162,7 @@ describe("Codex app-server provider", () => { })), }; - await asInternals(session).loadPersistedHistory(session.client); + await Array.fromAsync(session.streamHistory()); expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({ index: 0, @@ -4707,8 +5231,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -4773,11 +5295,11 @@ describe("Codex app-server provider", () => { const liveEvents: AgentStreamEvent[] = []; session.subscribe((event) => liveEvents.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "v2-child-thread", turn: { id: "v2-child-turn-after-resume" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "subAgentActivity", @@ -4787,7 +5309,7 @@ describe("Codex app-server provider", () => { agentPath: "/root/v2-child", }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "v2-child-thread", itemId: "v2-child-message-after-resume", delta: "More findings after resume.", @@ -4801,25 +5323,25 @@ describe("Codex app-server provider", () => { ); expect(liveToolCalls.at(-1)).toMatchObject({ status: "running", - detail: { type: "sub_agent", log: "[Assistant] More findings after resume." }, + detail: { type: "sub_agent", log: "" }, }); liveEvents.length = 0; - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { threadId: "legacy-child-thread", turn: { id: "legacy-child-turn-after-resume" }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "legacy-child-thread", itemId: "legacy-child-message-after-resume", delta: "Legacy findings after resume.", }); - expect(liveEvents.at(-1)).toMatchObject({ + expect(liveEvents.findLast((event) => event.type === "timeline")).toMatchObject({ type: "timeline", item: { callId: "legacy-spawn-history", status: "running", - detail: { type: "sub_agent", log: "[Assistant] Legacy findings after resume." }, + detail: { type: "sub_agent", log: "" }, }, }); }); @@ -4869,8 +5391,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -4934,8 +5454,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -5058,6 +5576,7 @@ describe("Codex app-server provider", () => { threadId: "child-thread", agentPath: "/root/child", }); + await new Promise((resolve) => setImmediate(resolve)); await child; appServer.beginsSubAgentActivity({ callId: "parent-interacted", @@ -5066,7 +5585,9 @@ describe("Codex app-server provider", () => { kind: "interacted", agentPath: "/root", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await resultPromise; expect(providerSubagentIds).toEqual(new Set(["child-thread"])); @@ -5108,8 +5629,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -5169,8 +5688,6 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -5198,7 +5715,7 @@ describe("Codex app-server provider", () => { ]); }); - test("captures live Codex user message ids from item events", () => { + test("captures live Codex user message ids from item events", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -5209,11 +5726,11 @@ describe("Codex app-server provider", () => { content: [{ type: "text", text: "Use the native Codex id." }], }; - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: userMessageItem, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: userMessageItem, }); @@ -5232,19 +5749,19 @@ describe("Codex app-server provider", () => { ]); }); - test("emits Codex context compaction markers from live thread items", () => { + test("emits Codex context compaction markers from live thread items", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "contextCompaction", id: "compact-live", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "contextCompaction", @@ -5282,7 +5799,9 @@ describe("Codex app-server provider", () => { threadId: "thread-1", itemId: "compact-without-completion", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5304,11 +5823,14 @@ describe("Codex app-server provider", () => { threadId: "thread-1", itemId: "compact-completed-normally", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCompaction({ threadId: "thread-1", itemId: "compact-completed-normally", }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5327,8 +5849,11 @@ describe("Codex app-server provider", () => { try { appServer.startsCompaction({ threadId: "thread-1", itemId: "current-compaction" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completesCompaction({ threadId: "thread-1", itemId: "older-compaction" }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5350,10 +5875,12 @@ describe("Codex app-server provider", () => { try { appServer.startsCompaction({ threadId: "thread-1", itemId: `compact-${status}` }); + await new Promise((resolve) => setImmediate(resolve)); appServer.completeTurn({ status, error: status === "failed" ? { message: "Compaction failed" } : null, }); + await new Promise((resolve) => setImmediate(resolve)); await terminalEvent; expect( @@ -5367,17 +5894,17 @@ describe("Codex app-server provider", () => { } }); - test("emits and dedupes Codex thread/compacted notifications", () => { + test("emits and dedupes Codex thread/compacted notifications", async () => { const session = createSession(); session.activeForegroundTurnId = null; const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("thread/compacted", { + await asInternals(session).handleNotification("thread/compacted", { threadId: "test-thread", turnId: "legacy-compact-turn", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "contextCompaction", @@ -5398,17 +5925,17 @@ describe("Codex app-server provider", () => { ]); }); - test("emits consecutive Codex thread/compacted notifications", () => { + test("emits consecutive Codex thread/compacted notifications", async () => { const session = createSession(); session.activeForegroundTurnId = null; const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("thread/compacted", { + await asInternals(session).handleNotification("thread/compacted", { threadId: "test-thread", turnId: "legacy-compact-turn-1", }); - asInternals(session).handleNotification("thread/compacted", { + await asInternals(session).handleNotification("thread/compacted", { threadId: "test-thread", turnId: "legacy-compact-turn-2", }); @@ -5462,7 +5989,10 @@ describe("Codex app-server provider", () => { expect(session.currentThreadId).toBe("archived-thread-id"); expect(requests).toEqual([ { method: "thread/loaded/list", params: {} }, - { method: "thread/resume", params: { threadId: "archived-thread-id" } }, + { + method: "thread/resume", + params: { threadId: "archived-thread-id", excludeTurns: true }, + }, ]); }); @@ -5534,14 +6064,14 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); await handler?.run({ emit: (event) => events.push(event) }); - asInternals(session).handleNotification("item/started", { + await asInternals(session).handleNotification("item/started", { threadId: "test-thread", item: { type: "contextCompaction", id: "manual-compact", }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { type: "contextCompaction", @@ -5656,23 +6186,23 @@ describe("Codex app-server provider", () => { }); }); - test("emits a synthetic plan approval permission after a successful Codex plan turn", () => { + test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-1" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [ { step: "Inspect the existing auth flow", status: "completed" }, { step: "Implement the button behavior", status: "pending" }, ], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5726,24 +6256,24 @@ describe("Codex app-server provider", () => { }); }); - test("does not complete Codex plan timeline cards while plan approval is pending", () => { + test("does not complete Codex plan timeline cards while plan approval is pending", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-thread-item" }, }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "plan-item-1", type: "plan", text: "- Inspect README\n- Add a short note", }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5772,28 +6302,28 @@ describe("Codex app-server provider", () => { }); }); - test("replaces a pending synthetic plan approval when a later plan completes", () => { + test("replaces a pending synthetic plan approval when a later plan completes", async () => { const session = createSession({ featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-first" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the first plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-second" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the revised plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5812,13 +6342,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); const pendingPlan = session.getPendingPermissions()[0]; @@ -5852,13 +6382,13 @@ describe("Codex app-server provider", () => { featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); const pendingPlan = session.getPendingPermissions()[0]; @@ -5906,13 +6436,13 @@ describe("Codex app-server provider", () => { featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -5937,10 +6467,10 @@ describe("Codex app-server provider", () => { const startTurn = session.startTurn("Revise the plan"); await promptRequested; - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the newer plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); acceptPrompt?.(); @@ -5956,13 +6486,13 @@ describe("Codex app-server provider", () => { featureValues: { plan_mode: true }, }); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-pending" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the original plan", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); const pendingPlan = session.getPendingPermissions()[0]; @@ -5982,12 +6512,12 @@ describe("Codex app-server provider", () => { expect(session.getPendingPermissions()).toEqual([]); }); - test("emits imageView paths with spaces as valid assistant markdown images", () => { + test("emits imageView paths with spaces as valid assistant markdown images", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "image-view-1", type: "imageView", @@ -6013,12 +6543,12 @@ describe("Codex app-server provider", () => { ["saved_path", { saved_path: "/tmp/generated-snake.png" }, "file:///tmp/generated-snake.png"], ])( "emits imageGeneration thread items with %s as assistant markdown images", - (_fieldName, imageFields, expectedPath) => { + async (_fieldName, imageFields, expectedPath) => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: `image-generation-${_fieldName}`, type: "imageGeneration", @@ -6041,12 +6571,12 @@ describe("Codex app-server provider", () => { }, ); - test("materializes imageGeneration base64 results before rendering markdown", () => { + test("materializes imageGeneration base64 results before rendering markdown", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "image-generation-base64", type: "imageGeneration", @@ -6194,21 +6724,22 @@ describe("Codex app-server provider", () => { } }); - test("ignores incomplete imageGeneration thread items without failing the turn", () => { + test("ignores incomplete imageGeneration thread items without failing the turn", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - expect(() => - asInternals(session).handleNotification("item/completed", { - item: { - id: "image-generation-incomplete", - type: "imageGeneration", - status: "in_progress", - }, - }), + expect( + async () => + await asInternals(session).handleNotification("item/completed", { + item: { + id: "image-generation-incomplete", + type: "imageGeneration", + status: "in_progress", + }, + }), ).not.toThrow(); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6231,12 +6762,12 @@ describe("Codex app-server provider", () => { ["no failure details", null, { message: "Image generation failed" }], ])( "emits failed imageGeneration items with %s as failed tool calls", - (_label, failure, expectedError) => { + async (_label, failure, expectedError) => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "image-generation-failed", type: "imageGeneration", @@ -6296,8 +6827,6 @@ describe("Codex app-server provider", () => { })), }; - await asInternals(session).loadPersistedHistory(session.client); - const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { history.push(event); @@ -6323,12 +6852,12 @@ describe("Codex app-server provider", () => { ]); }); - test("emits usage_updated on token usage updates and keeps usage on turn completion", () => { + test("emits usage_updated on token usage updates and keeps usage on turn completion", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("thread/tokenUsage/updated", { + await asInternals(session).handleNotification("thread/tokenUsage/updated", { tokenUsage: { model_context_window: 200000, last: { @@ -6339,7 +6868,7 @@ describe("Codex app-server provider", () => { }, }, }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6369,20 +6898,20 @@ describe("Codex app-server provider", () => { }); }); - test("streams Codex assistant message deltas and does not replay completed text", () => { + test("streams Codex assistant message deltas and does not replay completed text", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-1", delta: "Hel", }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-1", delta: "lo", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "assistant-item-1", type: "agentMessage", @@ -6406,20 +6935,20 @@ describe("Codex app-server provider", () => { ]); }); - test("emits only the missing assistant suffix when completed text extends streamed deltas", () => { + test("emits only the missing assistant suffix when completed text extends streamed deltas", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-2", delta: "Hel", }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-2", delta: "lo", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "assistant-item-2", type: "agentMessage", @@ -6449,24 +6978,24 @@ describe("Codex app-server provider", () => { ]); }); - test("emits a markdown divider when a new Codex assistant item starts after the previous one completed", () => { + test("emits a markdown divider when a new Codex assistant item starts after the previous one completed", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-3", delta: "I’m in the waiting phase now. The next read is intentionally delayed so we get meaningful CI state instead of churn.", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "assistant-item-3", type: "agentMessage", text: "I’m in the waiting phase now. The next read is intentionally delayed so we get meaningful CI state instead of churn.", }, }); - asInternals(session).handleNotification("item/agentMessage/delta", { + await asInternals(session).handleNotification("item/agentMessage/delta", { itemId: "assistant-item-4", delta: "CI is still cooking. I’m staying on the current run rather than jumping around, because the first red job will tell us exactly whether anything else needs work.", @@ -6496,20 +7025,20 @@ describe("Codex app-server provider", () => { ]); }); - test("streams Codex reasoning deltas and does not replay completed reasoning", () => { + test("streams Codex reasoning deltas and does not replay completed reasoning", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-1", delta: "Think", }); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-1", delta: "ing", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "reasoning-item-1", type: "reasoning", @@ -6533,20 +7062,20 @@ describe("Codex app-server provider", () => { ]); }); - test("emits only the missing reasoning suffix when completed reasoning extends streamed deltas", () => { + test("emits only the missing reasoning suffix when completed reasoning extends streamed deltas", async () => { const session = createSession(); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-2", delta: "Think", }); - asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { + await asInternals(session).handleNotification("item/reasoning/summaryTextDelta", { itemId: "reasoning-item-2", delta: "ing", }); - asInternals(session).handleNotification("item/completed", { + await asInternals(session).handleNotification("item/completed", { item: { id: "reasoning-item-2", type: "reasoning", @@ -6583,13 +7112,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-2" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the new flow", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6637,13 +7166,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-3" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the safe flow", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6705,13 +7234,13 @@ describe("Codex app-server provider", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); - asInternals(session).handleNotification("turn/started", { + await asInternals(session).handleNotification("turn/started", { turn: { id: "turn-plan-4" }, }); - asInternals(session).handleNotification("turn/plan/updated", { + await asInternals(session).handleNotification("turn/plan/updated", { plan: [{ step: "Implement the fast flow", status: "pending" }], }); - asInternals(session).handleNotification("turn/completed", { + await asInternals(session).handleNotification("turn/completed", { turn: { status: "completed", error: null }, }); @@ -6895,9 +7424,21 @@ describe("Codex importable sessions", () => { const threadsByModelProvider = [ { id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 }, { id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 }, + { + id: "new-custom-thread", + cwd: "/workspace/project-a", + modelProvider: "paseo:my-codex", + updatedAt: 3, + }, + { + id: "other-custom-thread", + cwd: "/workspace/project-a", + modelProvider: "paseo-my-codex", + updatedAt: 4, + }, ]; - test("a custom Codex provider lists the threads it created", async () => { + test("a custom Codex provider lists its old and namespaced threads without other providers", async () => { const appServer = createFakeCodexAppServer({ "thread/list": providerFilteringThreadListHandler(threadsByModelProvider), }); @@ -6910,10 +7451,40 @@ describe("Codex importable sessions", () => { const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); - expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]); + expect(sessions.map((session) => session.providerHandleId)).toEqual([ + "custom-thread", + "new-custom-thread", + ]); appServer.assertNoErrors(); }); + test.each(["openai", "ollama", "lmstudio", "amazon-bedrock", "amazon-bedrock-runtime", "gcp"])( + "a custom provider named %s excludes native built-in sessions", + async (providerId) => { + const appServer = createFakeCodexAppServer({ + "thread/list": providerFilteringThreadListHandler([ + { id: "native-thread", cwd: "/workspace/project-a", modelProvider: providerId }, + { + id: "custom-thread", + cwd: "/workspace/project-a", + modelProvider: `paseo:${providerId}`, + }, + ]), + }); + const provider = createProviderWithFakeAppServer(appServer, { + runtimeSettings: { + env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" }, + }, + customProvider: { id: providerId, label: "Custom Codex", extends: "codex" }, + }); + + const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]); + appServer.assertNoErrors(); + }, + ); + test("stock Codex keeps listing only its own threads", async () => { const appServer = createFakeCodexAppServer({ "thread/list": providerFilteringThreadListHandler(threadsByModelProvider), @@ -7167,3 +7738,93 @@ describe("Codex interactive resume startup", () => { }, ); }); + +test("Codex runtime safety queries live terminals and retains unsupported app servers", async () => { + let response: unknown = { data: [], nextCursor: null }; + const appServer = createFakeCodexAppServer({ "thread/backgroundTerminals/list": () => response }); + const session = new CodexAppServerAgentSession( + createConfig(), + null, + createTestLogger(), + async () => appServer.child, + ); + try { + await session.startTurn("hello"); + await appServer.waitForTurnStart(); + expect(await session.canStopRuntime()).toBe(false); + appServer.completeTurn({ threadId: "thread-1" }); + await new Promise((resolve) => setImmediate(resolve)); + await expect.poll(() => session.canStopRuntime()).toBe(true); + response = { data: [{ processId: "shell-1" }], nextCursor: null }; + expect(await session.canStopRuntime()).toBe(false); + response = {}; + await expect(session.canStopRuntime()).rejects.toThrow(); + response = { __jsonRpcError: { code: -32601, message: "Method not found" } }; + await expect(session.canStopRuntime()).rejects.toThrow("Method not found"); + expect( + appServer + .requests() + .findLast((request) => request.method === "thread/backgroundTerminals/list"), + ).toMatchObject({ params: { threadId: "thread-1" } }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } +}); + +test("publishes a materialized image before dispatching the following turn completion", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig(), + null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("generate an image"); + const data = Buffer.alloc(1024 * 1024, 47).toString("base64"); + const imageFrame = + JSON.stringify({ + method: "item/completed", + params: { + item: { + id: "ordered-image", + type: "imageGeneration", + status: "completed", + result: { data, mimeType: "image/png" }, + }, + }, + }) + "\n"; + const completed = new Promise((resolve) => + session.subscribe((event) => { + if (event.type === "turn_completed") resolve(); + }), + ); + appServer.child.stdout.write(imageFrame); + appServer.child.stdout.write( + JSON.stringify({ + method: "turn/completed", + params: { turn: { status: "completed", error: null } }, + }) + "\n", + ); + await completed; + await new Promise((resolve) => setTimeout(resolve, 5)); + const imageIndex = events.findIndex( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes("![Image]"), + ); + const completedIndex = events.findIndex((event) => event.type === "turn_completed"); + expect(imageIndex).toBeGreaterThanOrEqual(0); + expect(imageIndex).toBeLessThan(completedIndex); + const imageEvent = events[imageIndex]; + if (imageEvent.type !== "timeline" || imageEvent.item.type !== "assistant_message") + throw new Error("Expected image"); + expect(existsSync(markdownImageSource(imageEvent.item.text))).toBe(true); + } finally { + await session.close(); + } +}); 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 62ceb14c70..2aecd18e21 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 @@ -49,10 +49,10 @@ import { Dirent } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; 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, @@ -92,12 +92,18 @@ import { type CodexAppServerTraceContext, } from "./codex/app-server-transport.js"; import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js"; +import { + type CodexHistoryRow, + CodexThreadHistoryTooLargeError, + readCodexThreadHistory, +} from "./codex/thread-history.js"; import { materializeProviderImage, renderProviderImageOutputAsAssistantMarkdown, type ProviderImageOutput, } from "./provider-image-output.js"; import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js"; +import { limitAgentTimelineItemContent } from "../agent-timeline-content.js"; import { formatProviderDiagnostic, formatProviderDiagnosticError, @@ -145,6 +151,16 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea const TURN_START_TIMEOUT_MS = 90 * 1000; const INTERRUPT_TIMEOUT_MS = 2_000; const CODEX_PROVIDER = "codex" as const; +// These native provider identities belong to Codex, not to an older custom +// Paseo provider. Never include them in a custom provider's legacy import scan. +const CODEX_BUILTIN_MODEL_PROVIDER_IDS = new Set([ + "openai", + "ollama", + "lmstudio", + "amazon-bedrock", + "amazon-bedrock-runtime", + "gcp", +]); // Codex treats most app-server client names as the model-request originator. // This reserved Codex name is non-originating, so requests keep Codex's default // CLI identity instead of showing up as Paseo in provider usage logs. @@ -496,6 +512,11 @@ interface PersistedTimelineEntry { providerTurnId?: string; } +interface CodexThreadHistory { + timeline: PersistedTimelineEntry[]; + providerSubagentEvents: AgentStreamEvent[]; +} + interface PersistedSubAgentRoute { childThreadId: string; toolCall: ToolCallTimelineItem; @@ -1799,22 +1820,13 @@ function readCodexHistoryTimestamp(item: unknown): string | null { ); } -function readCodexTurnHistoryTimestamp( - turn: unknown, +/** Falls back to the row's start time for a user message and its end time otherwise. */ +function readCodexHistoryRowTimestamp( + row: CodexHistoryRow, timelineItem: AgentTimelineItem, ): string | null { - const record = toObjectRecord(turn); - if (!record) { - return null; - } - - const startedAt = - normalizeProviderReplayTimestamp(record.startedAt) ?? - normalizeProviderReplayTimestamp(record.started_at); - const completedAt = - normalizeProviderReplayTimestamp(record.completedAt) ?? - normalizeProviderReplayTimestamp(record.completed_at); - + const startedAt = normalizeProviderReplayTimestamp(row.startedAt); + const completedAt = normalizeProviderReplayTimestamp(row.completedAt); if (timelineItem.type === "user_message") { return startedAt ?? completedAt; } @@ -1939,12 +1951,12 @@ function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null }; } -function mapCodexThreadImageItem( +async function mapCodexThreadImageItem( normalizedType: string, normalizedItem: Record, -): AgentTimelineItem | null { +): Promise { if (normalizedType === "imageView") { - return renderProviderImageOutputAsAssistantMarkdown({ + return await renderProviderImageOutputAsAssistantMarkdown({ path: firstStringField(normalizedItem, ["path"]), }); } @@ -1960,7 +1972,7 @@ function mapCodexThreadImageItem( const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]); const result = codexImageOutputFromResult(normalizedItem.result); - return renderProviderImageOutputAsAssistantMarkdown( + return await renderProviderImageOutputAsAssistantMarkdown( { path: savedPath ?? result?.path ?? null, url: result?.url ?? null, @@ -1982,10 +1994,10 @@ function mapCodexAgentMessage(item: Record): AgentTimelineItem }; } -export function threadItemToTimeline( +export async function threadItemToTimeline( item: unknown, options?: { includeUserMessage?: boolean; cwd?: string | null }, -): AgentTimelineItem | null { +): Promise { const itemRecord = toObjectRecord(item); if (!itemRecord) return null; const includeUserMessage = options?.includeUserMessage ?? true; @@ -1999,7 +2011,7 @@ export function threadItemToTimeline( : itemRecord; if (normalizedType === "imageView" || normalizedType === "imageGeneration") { - return mapCodexThreadImageItem(normalizedType, normalizedItem); + return await mapCodexThreadImageItem(normalizedType, normalizedItem); } if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) { return mapCodexToolCallFromThreadItem(normalizedItem, { cwd }); @@ -2024,7 +2036,7 @@ export function threadItemToTimeline( } } -function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] { +async function mcpToolResultImagesToTimeline(item: unknown): Promise { const itemRecord = toObjectRecord(item); if (!itemRecord) { return []; @@ -2037,104 +2049,40 @@ function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] { } const { images } = splitCodexMcpToolResultImages(itemRecord.result); - return images - .map((image) => - renderProviderImageOutputAsAssistantMarkdown(image, { - materialize: materializeProviderImage, - }), - ) - .filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null); + const items: AgentTimelineItem[] = []; + for (const image of images) { + const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, { + materialize: materializeProviderImage, + }); + if (imageItem) items.push(imageItem); + } + return items; } -function threadItemToTimelineEntries( +async function threadItemToTimelineEntries( item: unknown, options?: { includeUserMessage?: boolean; cwd?: string | null }, -): AgentTimelineItem[] { - const timelineItem = threadItemToTimeline(item, options); +): Promise { + const timelineItem = await threadItemToTimeline(item, options); if (!timelineItem) { return []; } - return [timelineItem, ...mcpToolResultImagesToTimeline(item)]; -} - -const CodexThreadReadResponseSchema = z - .object({ - thread: z - .object({ - turns: z - .array( - z - .object({ - items: z.array(z.unknown()).default([]), - }) - .passthrough(), - ) - .default([]), - }) - .passthrough() - .default({ turns: [] }), - }) - .passthrough(); - -type CodexThreadReadResponse = z.infer; -type CodexThreadReadRequest = (threadId: string) => Promise; - -async function requestCodexThreadHistory( - requestThread: CodexThreadReadRequest, - threadId: string, -): Promise { - const response = await requestThread(threadId); - return CodexThreadReadResponseSchema.parse(response); + return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))]; } async function loadCodexThreadHistoryTimeline(params: { + client: CodexAppServerClientLike; threadId: string; cwd: string | null; - requestThread: CodexThreadReadRequest; }): Promise { - const response = await requestCodexThreadHistory(params.requestThread, params.threadId); const timeline: PersistedTimelineEntry[] = []; const subAgentTimelineIndexByThreadId = new Map(); - for (const turn of response.thread.turns) { - for (const item of turn.items) { - const historicalSubAgentActivity = readCodexSubAgentActivity(item); - if (historicalSubAgentActivity) { - const existingIndex = subAgentTimelineIndexByThreadId.get( - historicalSubAgentActivity.agentThreadId, - ); - if (existingIndex !== undefined) { - const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd }); - updateHistoricalSubAgentActivity( - timeline, - existingIndex, - historicalSubAgentActivity.kind, - activityTimelineItem?.type === "tool_call" && - activityTimelineItem.detail.type === "sub_agent" - ? activityTimelineItem.detail.subAgentType - : undefined, - ); - continue; - } - } - for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) { - const timestamp = - readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem); - const settledTimelineItem = - historicalSubAgentActivity && timelineItem.type === "tool_call" - ? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind) - : timelineItem; - timeline.push({ - item: settledTimelineItem, - timestamp: timestamp ?? undefined, - ...(timelineItem.type === "user_message" && typeof turn.id === "string" - ? { providerTurnId: turn.id } - : {}), - }); - for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) { - subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1); - } - } - } + try { + await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId }); + } catch (error) { + if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error; + // The session stays usable; the row says why the history stops here. + timeline.push({ item: { type: "notification", level: "warning", message: error.message } }); } const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap( ([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => { @@ -2147,11 +2095,55 @@ async function loadCodexThreadHistoryTimeline(params: { return { timeline, subAgentRoutes }; } -function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise { - return client.request("thread/read", { - threadId, - includeTurns: true, - }); +async function projectCodexThreadHistory( + params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null }, + projection: { + timeline: PersistedTimelineEntry[]; + subAgentTimelineIndexByThreadId: Map; + }, +): Promise { + const { timeline, subAgentTimelineIndexByThreadId } = projection; + for await (const row of readCodexThreadHistory(params.client, params.threadId)) { + const { item } = row; + const historicalSubAgentActivity = readCodexSubAgentActivity(item); + if (historicalSubAgentActivity) { + const existingIndex = subAgentTimelineIndexByThreadId.get( + historicalSubAgentActivity.agentThreadId, + ); + if (existingIndex !== undefined) { + const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd }); + updateHistoricalSubAgentActivity( + timeline, + existingIndex, + historicalSubAgentActivity.kind, + activityTimelineItem?.type === "tool_call" && + activityTimelineItem.detail.type === "sub_agent" + ? activityTimelineItem.detail.subAgentType + : undefined, + ); + continue; + } + } + for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) { + const timestamp = + readCodexHistoryTimestamp(item) ?? readCodexHistoryRowTimestamp(row, timelineItem); + const settledTimelineItem = + historicalSubAgentActivity && timelineItem.type === "tool_call" + ? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind) + : timelineItem; + timeline.push({ + // Cap each item as it is projected so a large history is never held uncapped. + item: limitAgentTimelineItemContent(settledTimelineItem), + timestamp: timestamp ?? undefined, + ...(timelineItem.type === "user_message" && row.turnId !== undefined + ? { providerTurnId: row.turnId } + : {}), + }); + for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) { + subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1); + } + } + } } export async function forkCodexThread( @@ -3290,10 +3282,12 @@ export async function codexAppServerTurnInputFromPrompt( } if (block.type === "image") { try { - const filePath = materializeProviderImage({ - data: block.data, - mimeType: block.mimeType, - }).path; + const filePath = ( + await materializeProviderImage({ + data: block.data, + mimeType: block.mimeType, + }) + ).path; output.push({ type: "localImage", path: filePath }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -3384,10 +3378,14 @@ function buildCodexCustomProviderConfig( providerConfig.env_key = "OPENAI_API_KEY"; providerConfig.requires_openai_auth = false; } + // Namespace Paseo ids so they cannot override Codex's built-ins. A colon is + // forbidden in Paseo ids, keeping these keys distinct from every legacy id + // when both generations are listed together. + const providerKey = `paseo:${customProvider.id}`; return { - model_provider: customProvider.id, + model_provider: providerKey, model_providers: { - [customProvider.id]: providerConfig, + [providerKey]: providerConfig, }, }; } @@ -3400,8 +3398,6 @@ interface CodexSubAgentCallState { activityItemIds: Set; pendingCommandOutputDeltas: Map; pendingFileChangeOutputDeltas: Map; - childItemOrder: string[]; - childItems: Map; childThreadIds: Set; } @@ -3439,6 +3435,9 @@ export class CodexAppServerAgentSession implements AgentSession { > | null = null; private resolvedSandboxPolicy: Record | null = null; private currentThreadId: string | null = null; + // Codex writes a new thread's rollout at its first turn. Until then the thread exists only + // in this app-server, so no other app-server can resume it and it is not reported. + private savedThreadId: string | null = null; private currentTurnId: string | null = null; private pendingForegroundTurnIdentification: { foregroundTurnId: string; @@ -3461,10 +3460,12 @@ export class CodexAppServerAgentSession implements AgentSession { private serviceTier: string | null = null; private speedModels: CodexModel[] = []; private planModeEnabled = false; - private historyPending = false; - private persistedHistory: PersistedTimelineEntry[] = []; - private loadingPersistedHistory = false; - private persistedProviderSubagentEvents: AgentStreamEvent[] = []; + // History read by rewind or an archived resume, held until streamHistory consumes it. + private stagedHistory: CodexThreadHistory | null = null; + // Collects provider subagent events emitted while a thread history read rebuilds routes. + private historySubagentEvents: AgentStreamEvent[] | null = null; + // A resumed thread's earlier turns and sub-agents are known only after reading its history. + private historyIndexed = true; private pendingPermissions = new Map(); private mcpElicitationPermissionIds = new Map(); private pendingPermissionHandlers = new Map(); @@ -3501,6 +3502,9 @@ export class CodexAppServerAgentSession implements AgentSession { private compactionTriggerByItemId = new Map(); private pendingRootCompactionItemIds = new Set(); private pendingAnonymousRootCompactions = 0; + // Child threads with a compaction shown as loading. Some Codex builds settle it only through + // `thread/compacted` or the end of the child's turn. + private pendingChildCompactionThreadIds = new Set(); // Codex can report one completed compaction through both channels: // `thread/compacted` and a completed `contextCompaction` item. private unpairedCompactionNotificationCompletions = 0; @@ -3571,7 +3575,8 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.resumeHandle?.sessionId) { this.currentThreadId = this.resumeHandle.sessionId; - this.historyPending = true; + this.savedThreadId = this.resumeHandle.sessionId; + this.historyIndexed = false; // Resuming can start a goal before the caller receives the session. if (this.initialResumePurpose === "interactive") this.startupEvents = []; } @@ -3619,7 +3624,7 @@ export class CodexAppServerAgentSession implements AgentSession { private async establishConnection(): Promise { if (this.initialResumePurpose === "history") { - await this.readArchivedHistory(); + this.stagedHistory = await this.readArchivedHistory(); this.connectionState = "history-ready"; return; } @@ -3652,7 +3657,6 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.currentThreadId) { await this.ensureThreadLoaded(); - await this.loadPersistedHistory(this.client); await this.applyDefaultModelAndThinking(); } @@ -3967,39 +3971,40 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private async readArchivedHistory(): Promise { + 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); + return await this.readThreadHistory(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({ - threadId, + // Reads the thread and its sub-agent threads, rebuilding the turn index and + // sub-agent routes. The caller owns the returned history; nothing retains it. + private async readThreadHistory( + client: CodexAppServerClientLike | null, + ): Promise { + if (!client || !this.currentThreadId) { + return { timeline: [], providerSubagentEvents: [] }; + } + const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({ + client, + threadId: this.currentThreadId, cwd: this.config.cwd ?? null, - requestThread: (threadIdToRead) => { - return readCodexThread(client, threadIdToRead); - }, }); - const { timeline, subAgentRoutes } = history; this.subAgentCallsByCallId.clear(); this.subAgentCallIdByChildThreadId.clear(); this.pendingSubAgentNotificationsByThreadId.clear(); - this.persistedProviderSubagentEvents = []; - this.loadingPersistedHistory = true; + const providerSubagentEvents: AgentStreamEvent[] = []; + this.historySubagentEvents = providerSubagentEvents; try { await this.loadPersistedSubAgentHistories(client, subAgentRoutes); } finally { - this.loadingPersistedHistory = false; + this.historySubagentEvents = null; } this.resetCodexUserMessageTurns(); for (const entry of timeline) { @@ -4010,8 +4015,15 @@ export class CodexAppServerAgentSession implements AgentSession { this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId); } } - this.persistedHistory = timeline; - this.historyPending = timeline.length > 0; + this.historyIndexed = true; + return { timeline, providerSubagentEvents }; + } + + // Turns and rewind route by the thread's earlier turns and sub-agents. A resumed + // session whose projection Paseo already holds reads them here and drops the rest. + private async ensureHistoryIndexed(): Promise { + if (this.historyIndexed) return; + await this.readThreadHistory(this.client); } private async loadPersistedSubAgentHistories( @@ -4038,9 +4050,9 @@ export class CodexAppServerAgentSession implements AgentSession { }); try { const childHistory = await loadCodexThreadHistoryTimeline({ + client, threadId: next.route.childThreadId, cwd: this.config.cwd ?? null, - requestThread: (childThreadId) => readCodexThread(client, childThreadId), }); for (const entry of childHistory.timeline) { this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp); @@ -4063,7 +4075,13 @@ export class CodexAppServerAgentSession implements AgentSession { private async ensureThreadLoaded(): Promise { if (!this.client || !this.currentThreadId) return; - const params: Record = { threadId: this.currentThreadId }; + // Paseo reads history only when it replays it; the resume response needs no turns. + const params: Record = { threadId: this.currentThreadId, excludeTurns: true }; + if (this.deps.customCodexConfig) { + // Resume otherwise prefers the provider stored on the thread, including + // the unnamespaced key emitted by earlier Paseo versions. + params.modelProvider = this.deps.customCodexConfig.model_provider; + } const developerInstructions = composeSystemPromptParts( this.config.systemPrompt, this.config.daemonAppendSystemPrompt, @@ -4370,6 +4388,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.dismissPendingPlanApprovals("Dismissed by a new prompt"); try { + await this.pendingNotifications; await this.connect(); if (!this.client) { throw new Error("Codex client not initialized"); @@ -4382,6 +4401,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.currentThreadId) { await this.ensureThreadLoaded(); + await this.ensureHistoryIndexed(); } else { await this.ensureThread(); } @@ -4436,9 +4456,18 @@ export class CodexAppServerAgentSession implements AgentSession { ): Promise { const client = this.client; const threadId = this.currentThreadId; - const nativeTurnId = this.currentTurnId; + let nativeTurnId = this.currentTurnId; const foregroundTurnId = this.activeForegroundTurnId; - if (!client || !threadId || !nativeTurnId || foregroundTurnId !== options.expectedTurnId) { + if (!client || !threadId || foregroundTurnId !== options.expectedTurnId) { + return { status: "unavailable" }; + } + const pendingIdentification = this.pendingForegroundTurnIdentification; + // The start acknowledgement precedes the notification that identifies the native + // turn. Keep this steer attached to the accepted foreground turn while it arrives. + if (!nativeTurnId && pendingIdentification?.foregroundTurnId === foregroundTurnId) { + nativeTurnId = await pendingIdentification.promise; + } + if (!nativeTurnId) { return { status: "unavailable" }; } if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" }; @@ -4555,21 +4584,12 @@ export class CodexAppServerAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { - if ( - (!this.historyPending || this.persistedHistory.length === 0) && - this.persistedProviderSubagentEvents.length === 0 - ) { - return; - } - const history = this.persistedHistory; - const providerSubagents = this.persistedProviderSubagentEvents; - this.persistedHistory = []; - this.persistedProviderSubagentEvents = []; - this.historyPending = false; - for (const event of providerSubagents) { + const history = this.stagedHistory ?? (await this.readThreadHistory(this.client)); + this.stagedHistory = null; + for (const event of history.providerSubagentEvents) { yield event; } - for (const entry of history) { + for (const entry of history.timeline) { yield { type: "timeline", provider: CODEX_PROVIDER, @@ -4589,7 +4609,7 @@ export class CodexAppServerAgentSession implements AgentSession { } const info: AgentRuntimeInfo = { provider: CODEX_PROVIDER, - sessionId: this.currentThreadId, + sessionId: this.savedCurrentThreadId(), model: this.config.model ?? null, thinkingOptionId: normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null, modeId: this.currentMode ?? null, @@ -4920,17 +4940,18 @@ export class CodexAppServerAgentSession implements AgentSession { nativeHandle: string; metadata: Record; } | null { - if (!this.currentThreadId) return null; + const threadId = this.savedCurrentThreadId(); + if (!threadId) return null; const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null; return { provider: CODEX_PROVIDER, - sessionId: this.currentThreadId, - nativeHandle: this.currentThreadId, + sessionId: threadId, + nativeHandle: threadId, metadata: { provider: CODEX_PROVIDER, cwd: this.config.cwd, title: this.config.title ?? null, - threadId: this.currentThreadId, + threadId, modeId: this.config.modeId, model: this.config.model ?? null, thinkingOptionId, @@ -4942,6 +4963,12 @@ export class CodexAppServerAgentSession implements AgentSession { }; } + private savedCurrentThreadId(): string | null { + return this.currentThreadId !== null && this.currentThreadId === this.savedThreadId + ? this.currentThreadId + : null; + } + async revertConversation(input: { messageId: string }): Promise { await this.connect(); if (!this.client) { @@ -4952,6 +4979,7 @@ export class CodexAppServerAgentSession implements AgentSession { } else { await this.ensureThread(); } + await this.ensureHistoryIndexed(); await revertCodexConversation({ client: this.client, @@ -4965,11 +4993,11 @@ export class CodexAppServerAgentSession implements AgentSession { threadRollbackAvailable: this.threadRollbackAvailable, setThreadId: async (threadId) => { this.currentThreadId = threadId; + // Codex writes a forked thread's rollout when it forks it, even before any turn. + this.savedThreadId = threadId; this.cachedRuntimeInfo = null; - this.persistedHistory = []; - this.historyPending = false; - await this.loadPersistedHistory(this.client); - this.reconcileAsyncQuestionsAfterRewind(); + this.stagedHistory = await this.readThreadHistory(this.client); + this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline); }, }); } @@ -5029,6 +5057,34 @@ export class CodexAppServerAgentSession implements AgentSession { } } + async canStopRuntime(): Promise { + if ( + !this.client || + this.client.hasPendingRequests() || + !this.currentThreadId || + this.activeForegroundTurnId || + this.currentTurnId || + this.pendingForegroundStart || + this.pendingPermissionHandlers.size || + this.getPendingPermissions().length || + this.pendingSubAgentNotificationsByThreadId.size + ) + return false; + // Older app servers reject this request. A failed or malformed probe must retain + // the process: command completion alone does not mean its terminal has exited. + const loaded = z + .object({ data: z.array(z.string()) }) + .parse(await this.client.request("thread/loaded/list", {}, 10_000)); + const threadIds = new Set([this.currentThreadId, ...loaded.data]); + for (const threadId of threadIds) { + const response = z + .object({ data: z.array(z.unknown()), nextCursor: z.string().nullable() }) + .parse(await this.client.request("thread/backgroundTerminals/list", { threadId }, 10_000)); + if (response.data.length > 0 || response.nextCursor !== null) return false; + } + return !this.client.hasPendingRequests(); + } + async close(): Promise { this.closed = true; this.clearPendingPermissions(); @@ -5179,6 +5235,8 @@ export class CodexAppServerAgentSession implements AgentSession { await this.connect(); if (this.currentThreadId) { await this.ensureThreadLoaded(); + // A goal runs turns of its own. + await this.ensureHistoryIndexed(); } else { await this.ensureThread(); } @@ -5372,8 +5430,8 @@ export class CodexAppServerAgentSession implements AgentSession { } private emitEvent(event: AgentStreamEvent): void { - if (this.loadingPersistedHistory && event.type === "provider_subagent") { - this.persistedProviderSubagentEvents.push(event); + if (this.historySubagentEvents && event.type === "provider_subagent") { + this.historySubagentEvents.push(event); return; } this.notifySubscribers(event); @@ -5409,7 +5467,21 @@ export class CodexAppServerAgentSession implements AgentSession { return `codex-turn-${this.nextTurnOrdinal++}`; } - private handleNotification(method: string, params: unknown): void { + private pendingNotifications: Promise = Promise.resolve(); + + private handleNotification(method: string, params: unknown): Promise { + // An image must exist before its event, and completion must follow that event. + // Transport callbacks cannot await: the session owns their ordered drain. + const next = this.pendingNotifications.then(() => { + return this.closed ? undefined : this.processNotification(method, params); + }); + this.pendingNotifications = next.catch((error: unknown) => { + this.handleUnexpectedTermination(error instanceof Error ? error : new Error(String(error))); + }); + return this.pendingNotifications; + } + + private async processNotification(method: string, params: unknown): Promise { const notificationParams = toObjectRecord(params); if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") { const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId); @@ -5439,13 +5511,16 @@ export class CodexAppServerAgentSession implements AgentSession { return; } if (route.kind === "sub_agent") { - this.dispatchSubAgentNotification(parsed, route.callId); + await this.dispatchSubAgentNotification(parsed, route.callId); return; } - this.dispatchParsedNotification(parsed); + await this.dispatchParsedNotification(parsed); } - private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void { + private async dispatchSubAgentNotification( + parsed: ParsedCodexNotification, + callId: string, + ): Promise { switch (parsed.kind) { case "thread_started": this.emitSubAgentActivityUpdate(callId, "running", { reopen: true }); @@ -5456,7 +5531,7 @@ export class CodexAppServerAgentSession implements AgentSession { case "reasoning_delta": case "item_started": case "item_completed": - this.dispatchParsedNotification(parsed); + await this.dispatchParsedNotification(parsed); return; case "exec_command_output_delta": case "file_change_output_delta": @@ -5474,6 +5549,9 @@ export class CodexAppServerAgentSession implements AgentSession { case "patch_apply_completed": this.handlePatchApplyCompletedNotification(parsed, callId); return; + case "context_compacted": + this.settleChildCompaction(parsed.threadId); + return; default: // Aggregate child telemetry is redundant and must not leak into the // root timeline. Concrete legacy tools are projected above for Codex @@ -5482,7 +5560,7 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private dispatchParsedNotification(parsed: ParsedCodexNotification): void { + private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise { if (isCodexDeltaNotification(parsed)) { this.handleCodexDeltaNotification(parsed); return; @@ -5527,10 +5605,10 @@ export class CodexAppServerAgentSession implements AgentSession { this.handlePatchApplyCompletedNotification(parsed); return; case "item_completed": - this.handleItemCompletedNotification(parsed); + await this.handleItemCompletedNotification(parsed); return; case "item_started": - this.handleItemStartedNotification(parsed); + await this.handleItemStartedNotification(parsed); return; case "invalid_payload": this.warnInvalidNotificationPayload(parsed.method, parsed.params); @@ -5572,7 +5650,7 @@ export class CodexAppServerAgentSession implements AgentSession { pending.push(parsed); } - private replayPendingSubAgentNotifications(threadIds: readonly string[]): void { + private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise { for (const threadId of threadIds) { const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId); if (!pending) { @@ -5584,7 +5662,7 @@ export class CodexAppServerAgentSession implements AgentSession { continue; } for (const parsed of pending) { - this.dispatchSubAgentNotification(parsed, callId); + await this.dispatchSubAgentNotification(parsed, callId); } } } @@ -5650,20 +5728,10 @@ export class CodexAppServerAgentSession implements AgentSession { activityItemIds: new Set(), pendingCommandOutputDeltas: new Map(), pendingFileChangeOutputDeltas: new Map(), - childItemOrder: [], - childItems: new Map(), childThreadIds: new Set(), } satisfies CodexSubAgentCallState); - state.toolCall = { - ...timelineItem, - detail: { - ...timelineItem.detail, - log: - timelineItem.detail.log || - (state.toolCall.detail.type === "sub_agent" ? state.toolCall.detail.log : ""), - }, - }; + state.toolCall = timelineItem; state.parentCallId ??= parentCallId; state.parentSubagentId ??= parentSubagentId; const activity = readCodexSubAgentActivity(rawItem); @@ -5716,25 +5784,17 @@ export class CodexAppServerAgentSession implements AgentSession { const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, { cwd: this.config.cwd ?? null, }); - if ( - activityToolCall?.detail.type === "sub_agent" && - state.toolCall.detail.type === "sub_agent" - ) { - state.toolCall = { - ...state.toolCall, - detail: { - ...state.toolCall.detail, - subAgentType: activityToolCall.detail.subAgentType, - }, - }; - } + const subAgentType = + activityToolCall?.detail.type === "sub_agent" + ? activityToolCall.detail.subAgentType + : undefined; let nextStatus: ToolCallTimelineItem["status"] | undefined = "running"; if (activity.kind === "interrupted") { nextStatus = "canceled"; } else if (isTerminalSubAgentStatus(state.toolCall.status)) { nextStatus = undefined; } - this.emitSubAgentActivityUpdate(callId, nextStatus); + this.emitSubAgentActivityUpdate(callId, nextStatus, { subAgentType }); return true; } @@ -5775,7 +5835,13 @@ export class CodexAppServerAgentSession implements AgentSession { ): boolean { if ( childSubAgentCallId && - this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed") + parsed.threadId && + this.handleSubAgentContextCompactionItem( + childSubAgentCallId, + parsed.threadId, + parsed.item, + "completed", + ) ) { return true; } @@ -5785,17 +5851,6 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private upsertSubAgentChildItem(callId: string, itemId: string, item: AgentTimelineItem): void { - const state = this.subAgentCallsByCallId.get(callId); - if (!state) { - return; - } - if (!state.childItems.has(itemId)) { - state.childItemOrder.push(itemId); - } - state.childItems.set(itemId, item); - } - private emitCodexToolTimelineItem( timelineItem: ToolCallTimelineItem, subAgentCallId: string | null, @@ -5805,7 +5860,6 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); return; } - this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem); const state = this.subAgentCallsByCallId.get(subAgentCallId); if (state) { const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds; @@ -5819,26 +5873,15 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private getSubAgentChildTimeline(state: CodexSubAgentCallState): AgentTimelineItem[] { - return state.childItemOrder - .map((itemId) => state.childItems.get(itemId)) - .filter((item): item is AgentTimelineItem => Boolean(item)); - } - private emitSubAgentActivityUpdate( callId: string, status?: ToolCallTimelineItem["status"], - options?: { reopen?: boolean }, + options?: { reopen?: boolean; subAgentType?: string }, ): void { const state = this.subAgentCallsByCallId.get(callId); if (!state || state.toolCall.detail.type !== "sub_agent") { return; } - const childTimeline = this.getSubAgentChildTimeline(state); - const log = - childTimeline.length > 0 - ? curateAgentActivity(childTimeline, { labelAssistantMessages: true }) - : ""; let resolvedStatus = status ?? state.toolCall.status; if ( status === "running" && @@ -5847,16 +5890,12 @@ export class CodexAppServerAgentSession implements AgentSession { ) { resolvedStatus = state.toolCall.status; } - for (const childThreadId of state.childThreadIds) { - this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); - } - const baseToolCall = { - ...state.toolCall, - detail: { - ...state.toolCall.detail, - log, - }, - }; + const baseToolCall = options?.subAgentType + ? { + ...state.toolCall, + detail: { ...state.toolCall.detail, subAgentType: options.subAgentType }, + } + : state.toolCall; const nextToolCall: ToolCallTimelineItem = resolvedStatus === "failed" ? { @@ -5869,10 +5908,14 @@ export class CodexAppServerAgentSession implements AgentSession { status: resolvedStatus, error: null, }; + // The card is a launch marker: the child's activity streams to its own timeline, so the card + // is re-sent only when its status or label changes. + const cardChanged = !isDeepStrictEqual(nextToolCall, state.toolCall); state.toolCall = nextToolCall; - if (state.parentCallId && state.parentCallId !== callId) { - this.upsertSubAgentChildItem(state.parentCallId, state.callId, nextToolCall); - this.emitSubAgentActivityUpdate(state.parentCallId); + for (const childThreadId of state.childThreadIds) { + this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); + } + if (!cardChanged || (state.parentCallId && state.parentCallId !== callId)) { return; } this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall }); @@ -5980,7 +6023,6 @@ export class CodexAppServerAgentSession implements AgentSession { ): void { this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId); if (itemId) { - this.upsertSubAgentChildItem(callId, itemId, timelineItem); this.pendingAgentMessages.delete(itemId); this.pendingReasoning.delete(itemId); this.pendingCommandOutputDeltas.delete(itemId); @@ -5991,19 +6033,29 @@ export class CodexAppServerAgentSession implements AgentSession { private handleSubAgentContextCompactionItem( callId: string, + childThreadId: string, item: { id?: string; type?: string; [key: string]: unknown }, status: "loading" | "completed", ): boolean { if (!this.isContextCompactionItem(item)) { return false; } - if (item.id) { - this.upsertSubAgentChildItem(callId, item.id, { type: "compaction", status }); + if (status === "loading") { + this.pendingChildCompactionThreadIds.add(childThreadId); + this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status }); + } else { + this.settleChildCompaction(childThreadId); } this.emitSubAgentActivityUpdate(callId); return true; } + private settleChildCompaction(childThreadId: string): void { + if (this.pendingChildCompactionThreadIds.delete(childThreadId)) { + this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status: "completed" }); + } + } + private shouldSkipCompletedThreadItem( timelineItem: AgentTimelineItem, normalizedItemType: string | undefined, @@ -6034,11 +6086,6 @@ export class CodexAppServerAgentSession implements AgentSession { text: parsed.delta, }); } - this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { - type: "assistant_message", - messageId: parsed.itemId, - text, - }); this.emitSubAgentActivityUpdate(subAgentCallId, "running"); return; } @@ -6072,10 +6119,6 @@ export class CodexAppServerAgentSession implements AgentSession { text: parsed.delta, }); } - this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { - type: "reasoning", - text: prev.join(""), - }); this.emitSubAgentActivityUpdate(subAgentCallId, "running"); return; } @@ -6125,6 +6168,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true }); return; } + this.markCurrentThreadSaved(parsed.threadId); this.currentTurnId = parsed.turnId; const pendingIdentification = this.pendingForegroundTurnIdentification; if ( @@ -6138,11 +6182,23 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER }); } + // Codex has written the rollout by the time it announces a thread's turn, including a + // goal's or a compaction's. From here on the thread can be reported and resumed. + private markCurrentThreadSaved(threadId: string | null): void { + const current = this.currentThreadId; + if (!current || (threadId !== null && threadId !== current)) return; + if (this.savedThreadId === current) return; + this.savedThreadId = current; + this.cachedRuntimeInfo = null; + this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: current }); + } + private handleTurnCompletedNotification( parsed: Extract, ): void { const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (subAgentCallId) { + if (parsed.threadId) this.settleChildCompaction(parsed.threadId); let status: ToolCallTimelineItem["status"] = "completed"; if (parsed.status === "failed") { status = "failed"; @@ -6534,9 +6590,9 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private reconcileAsyncQuestionsAfterRewind(): void { + private reconcileAsyncQuestionsAfterRewind(timeline: readonly PersistedTimelineEntry[]): void { const retainedIds = new Set( - this.persistedHistory.flatMap(({ item }) => + timeline.flatMap(({ item }) => item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [], ), ); @@ -6550,9 +6606,13 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private handleItemCompletedNotification( + private ownsNotificationTurn(turnId: string | null): boolean { + return !this.closed && this.activeForegroundTurnId === turnId; + } + + private async handleItemCompletedNotification( parsed: Extract, - ): void { + ): Promise { // Codex emits mirrored lifecycle notifications via both `codex/event/item_*` // and canonical `item/*`. Render ordinary items only from the canonical // channel, but accept a legacy-only child announcement so it can establish @@ -6562,20 +6622,23 @@ export class CodexAppServerAgentSession implements AgentSession { } this.receiveAsyncQuestion(parsed.threadId, parsed.item); if (this.isUserMessageItem(parsed.item)) { - this.handleUserMessageItem(parsed); + await this.handleUserMessageItem(parsed); return; } const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) { return; } - const timelineItem = threadItemToTimeline(parsed.item, { + const foregroundTurnId = this.activeForegroundTurnId; + const timelineItem = await threadItemToTimeline(parsed.item, { includeUserMessage: false, cwd: this.config.cwd ?? null, }); if (!timelineItem) { return; } + const imageItems = await mcpToolResultImagesToTimeline(parsed.item); + if (!this.ownsNotificationTurn(foregroundTurnId)) return; const registeredChildThreadIds = timelineItem.type === "tool_call" ? this.registerSubAgentToolCall({ @@ -6585,12 +6648,11 @@ export class CodexAppServerAgentSession implements AgentSession { parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId), }) : []; - const imageItems = mcpToolResultImagesToTimeline(parsed.item); if (childSubAgentCallId) { this.emitCompletedProviderSubagentItem(parsed, timelineItem); this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem); this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems); - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } const normalizedItemType = normalizeCodexThreadItemType( @@ -6598,7 +6660,7 @@ export class CodexAppServerAgentSession implements AgentSession { ); const itemId = parsed.item.id; if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) { - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } if (this.consumeStreamedTextCompletion(timelineItem, itemId)) { @@ -6609,7 +6671,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.emittedItemCompletedIds.add(itemId); this.emittedItemStartedIds.delete(itemId); } - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId); @@ -6638,7 +6700,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingCommandOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId); } - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); } private consumeStreamedTextCompletion( @@ -6710,20 +6772,26 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private handleItemStartedNotification( + private async handleItemStartedNotification( parsed: Extract, - ): void { + ): Promise { if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } if (this.isUserMessageItem(parsed.item)) { - this.handleUserMessageItem(parsed); + await this.handleUserMessageItem(parsed); return; } const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if ( childSubAgentCallId && - this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading") + parsed.threadId && + this.handleSubAgentContextCompactionItem( + childSubAgentCallId, + parsed.threadId, + parsed.item, + "loading", + ) ) { return; } @@ -6739,7 +6807,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.handleRegisteredSubAgentActivity(parsed.item)) { return; } - const timelineItem = threadItemToTimeline(parsed.item, { + const timelineItem = await threadItemToTimeline(parsed.item, { includeUserMessage: false, cwd: this.config.cwd ?? null, }); @@ -6754,11 +6822,8 @@ export class CodexAppServerAgentSession implements AgentSession { }); if (childSubAgentCallId) { this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem); - if (parsed.item.id) { - this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem); - } this.emitSubAgentActivityUpdate(childSubAgentCallId, "running"); - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } const normalizedItemType = normalizeCodexThreadItemType( @@ -6782,14 +6847,14 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingCommandOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId); } - this.replayPendingSubAgentNotifications(registeredChildThreadIds); + await this.replayPendingSubAgentNotifications(registeredChildThreadIds); } - private handleUserMessageItem( + private async handleUserMessageItem( parsed: Extract, - ): void { + ): Promise { const itemId = parsed.item.id; - const timelineItem = threadItemToTimeline(parsed.item, { + const timelineItem = await threadItemToTimeline(parsed.item, { includeUserMessage: true, cwd: this.config.cwd ?? null, }); @@ -6810,9 +6875,6 @@ export class CodexAppServerAgentSession implements AgentSession { if (parsed.threadId) { this.emitProviderSubagentTimeline(parsed.threadId, timelineItem); } - if (itemId) { - this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem); - } this.emitSubAgentActivityUpdate(childSubAgentCallId, "running"); return; } @@ -7355,12 +7417,19 @@ export class CodexAppServerAgentClient implements AgentClient { const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit; // Codex records each thread under the model provider that ran it and, // unless told otherwise, lists only the provider its own config selects. - // A custom provider runs under its own id, so ask for exactly that one. + // Include legacy custom ids, but keep Codex built-in sessions separate. const customProviderConfig = this.customProviderConfig(); + let modelProviders: string[] | undefined; + if (customProviderConfig && this.deps.customProvider) { + modelProviders = [customProviderConfig.model_provider]; + if (!CODEX_BUILTIN_MODEL_PROVIDER_IDS.has(this.deps.customProvider.id)) { + modelProviders.push(this.deps.customProvider.id); + } + } const allThreads = await readCodexThreadWindow(client, this.logger, { limit: listLimit, cwd: options?.cwd, - ...(customProviderConfig ? { modelProviders: [customProviderConfig.model_provider] } : {}), + ...(modelProviders ? { modelProviders } : {}), }); const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); return threads.slice(0, limit).map((thread) => { diff --git a/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts index ebd8de5ab5..f546dc69e7 100644 --- a/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts @@ -7,7 +7,8 @@ import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import { spawnProcess } from "../../../utils/spawn.js"; +import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js"; // Runs the real `codex` binary against a local Responses endpoint standing in // for a custom provider's OPENAI_BASE_URL, so no login is needed. @@ -88,52 +89,106 @@ describe("Codex custom provider import list", () => { for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); }); - test("lists a custom provider's session under that provider and imports it there", async () => { - const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-")); - const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-")); - cleanups.push(() => rmSync(codexHome, { recursive: true, force: true })); - cleanups.push(() => rmSync(cwd, { recursive: true, force: true })); - const { baseUrl, server, requestCount } = await startResponsesEndpoint(); - cleanups.push(() => closeServer(server)); + test.each([ + { providerId: "my-codex", seedLegacy: false, seedBuiltin: false }, + { providerId: "ollama", seedLegacy: false, seedBuiltin: false }, + { providerId: "my-codex", seedLegacy: true, seedBuiltin: false }, + { providerId: "ollama", seedLegacy: false, seedBuiltin: true }, + ])( + "lists and imports sessions for $providerId (legacy: $seedLegacy, native: $seedBuiltin)", + async ({ providerId, seedLegacy, seedBuiltin }) => { + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-")); + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-")); + cleanups.push(() => rmSync(codexHome, { recursive: true, force: true })); + cleanups.push(() => rmSync(cwd, { recursive: true, force: true })); + const { baseUrl, server, requestCount } = await startResponsesEndpoint(); + cleanups.push(() => closeServer(server)); - const logger = createTestLogger(); - const customCodex = new CodexAppServerAgentClient( - logger, - { env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } }, - { customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } }, - ); - const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } }); + const logger = createTestLogger(); + if (seedLegacy || seedBuiltin) { + // Seed an old custom session or a native built-in session with real Codex. + const environment = { + ...process.env, + CODEX_HOME: codexHome, + CODEX_OSS_BASE_URL: baseUrl, + OPENAI_API_KEY: "test-key", + }; + const legacy = new CodexAppServerAgentSession( + { provider: "codex", cwd, model: "mock-model" }, + null, + logger, + async () => + spawnProcess("codex", ["app-server"], { cwd, env: environment, stdio: "pipe" }), + { + environment, + customCodexConfig: { + model_provider: providerId, + model_providers: seedBuiltin + ? {} + : { + [providerId]: { + name: "My Codex", + base_url: baseUrl, + wire_api: "responses", + env_key: "OPENAI_API_KEY", + requires_openai_auth: false, + }, + }, + }, + }, + ); + try { + await legacy.run("hello from the legacy provider"); + } finally { + await legacy.close(); + } + } + const customCodex = new CodexAppServerAgentClient( + logger, + { env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } }, + { customProvider: { id: providerId, label: "My Codex", extends: "codex" } }, + ); + const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } }); - const session = await customCodex.createSession({ - provider: "codex", - cwd, - model: "mock-model", - }); - try { - await session.run("hello from the custom provider"); - } finally { - await session.close(); - } + const session = await customCodex.createSession({ + provider: "codex", + cwd, + model: "mock-model", + }); + try { + await session.run("hello from the custom provider"); + } finally { + await session.close(); + } - const customSessions = await customCodex.listImportableSessions({ cwd }); - const stockSessions = await stockCodex.listImportableSessions({ cwd }); + const customSessions = await customCodex.listImportableSessions({ cwd }); + const stockSessions = await stockCodex.listImportableSessions({ cwd }); - expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([ - "hello from the custom provider", - ]); - expect(stockSessions).toEqual([]); + expect(customSessions.map((entry) => entry.firstPromptPreview).sort()).toEqual( + seedLegacy + ? ["hello from the custom provider", "hello from the legacy provider"] + : ["hello from the custom provider"], + ); + expect(stockSessions).toEqual([]); - const config = { provider: "codex", cwd, model: "mock-model" }; - const imported = await customCodex.importSession( - { providerHandleId: customSessions[0].providerHandleId, cwd }, - { config, storedConfig: config }, - ); - const requestsBeforeImportedTurn = requestCount(); - try { - await imported.session.run("hello again after import"); - } finally { - await imported.session.close(); - } - expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn); - }, 120_000); + const config = { provider: "codex", cwd, model: "mock-model" }; + const sessionToImport = seedLegacy + ? customSessions.find( + (entry) => entry.firstPromptPreview === "hello from the legacy provider", + )! + : customSessions[0]; + const imported = await customCodex.importSession( + { providerHandleId: sessionToImport.providerHandleId, cwd }, + { config, storedConfig: config }, + ); + const requestsBeforeImportedTurn = requestCount(); + try { + await imported.session.run("hello again after import"); + } finally { + await imported.session.close(); + } + expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn); + }, + 120_000, + ); }); diff --git a/packages/server/src/server/agent/providers/codex-history-string-limit.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-history-string-limit.real.e2e.test.ts new file mode 100644 index 0000000000..fba51b9e1d --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-history-string-limit.real.e2e.test.ts @@ -0,0 +1,53 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +// Regression for #3905. Real Codex, synthetic on-disk history, no model turns. +// Opt in because the oversized cases need approximately 2 GiB of memory and over +// 1 GiB of temporary storage. +const enabled = process.env.PASEO_CODEX_HISTORY_STRING_LIMIT_REPRO === "1"; +const script = fileURLToPath( + new URL("../../../../scripts/reproduce-codex-history-string-limit.mts", import.meta.url), +); + +test.runIf(enabled).each([ + { megabytes: 2, historyMode: "legacy", expected: "History loaded: 3 timeline rows" }, + { megabytes: 520, historyMode: "legacy", expected: "codex migrate-rollouts --apply --thread" }, + { megabytes: 520, historyMode: "paginated", expected: "History loaded: 521 timeline rows" }, +])( + "opening $megabytes MiB of $historyMode Codex history does not crash its hosting process", + async ({ megabytes, historyMode, expected }) => { + const root = await mkdtemp(path.join(process.cwd(), ".codex-history-test-")); + try { + const result = await new Promise<{ code: number; stdout: string; stderr: string }>( + (resolve) => { + const child = execFile( + process.execPath, + ["--import", "tsx", script, String(megabytes), historyMode], + { + cwd: root, + detached: true, + timeout: 120_000, + maxBuffer: 1024 * 1024, + }, + (error, stdout, stderr) => { + try { + if (child.pid) process.kill(-child.pid, "SIGKILL"); + } catch (killError) { + if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError; + } + resolve({ code: error ? Number(error.code ?? 1) : 0, stdout, stderr }); + }, + ); + }, + ); + expect(result.code, result.stderr).toBe(0); + expect(result.stdout).toContain(expected); + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }, + 180_000, +); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index cc32a3c013..84b82b431b 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -183,3 +183,56 @@ describe("Codex app-server transport", () => { codex.child.stdin.end(); }); }); + +describe("Codex stdout byte admission", () => { + test("an oversized response fails only its own request", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + const terminations: Error[] = []; + client.setUnexpectedTerminationHandler((error) => terminations.push(error)); + const oversized = client.request("thread/items/list", {}).catch((error: Error) => error); + const other = client.request("model/list", {}); + const chunk = Buffer.alloc(64 * 1024, "x"); + child.stdout.write('{"id":1,"result":"'); + for (let index = 0; index < 2049; index++) child.stdout.write(chunk); + child.stdout.write('"}\n{"id":2,"result":{"data":[]}}\n'); + + expect(await oversized).toMatchObject({ + name: "CodexAppServerResponseTooLargeError", + method: "thread/items/list", + limitBytes: 128 * 1024 * 1024, + }); + await expect(other).resolves.toEqual({ data: [] }); + const next = client.request("model/list", {}); + child.stdout.write('{"id":3,"result":{"data":["next"]}}\n'); + await expect(next).resolves.toEqual({ data: ["next"] }); + expect(terminations).toEqual([]); + await client.dispose(); + child.stdout.end(); + child.stderr.end(); + }); + + test("an oversized notification fails pending requests and the session", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + const terminations: Error[] = []; + client.setUnexpectedTerminationHandler((error) => terminations.push(error)); + const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error); + const chunk = Buffer.alloc(64 * 1024, "x"); + child.stdout.write('{"method":"item/completed","params":"'); + for (let index = 0; index < 2048; index++) child.stdout.write(chunk); + const error = await request; + expect(error).toMatchObject({ + name: "ProviderStreamLimitError", + code: "PROVIDER_STREAM_LINE_TOO_LARGE", + limitBytes: 128 * 1024 * 1024, + }); + expect(terminations).toEqual([error]); + expect(client.hasPendingRequests()).toBe(false); + child.stdout.write('"}\n{"id":1,"result":"late"}\n'); + await expect(client.request("model/list")).rejects.toThrow("closed"); + await client.dispose(); + child.stdout.end(); + child.stderr.end(); + }); +}); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index acb5e76341..5a218e2edb 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -1,10 +1,15 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { StringDecoder } from "node:string_decoder"; import type { Logger } from "pino"; import { z } from "zod"; import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; +import { ProviderLineReader, ProviderStreamLimitError } from "../jsonl-frame-decoder.js"; + +// A measured long-thread resume was 19.75 MB; preserve the existing 32 MiB fixture +// with 4x margin for longer threads. This is provider stdout, not the client protocol. +export const CODEX_PROVIDER_LINE_BYTES = 128 * 1024 * 1024; + const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000; const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; @@ -28,11 +33,26 @@ interface JsonRpcNotification { } interface PendingRequest { + method: string; resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; } +/** A response past the provider line cap. Only its request fails; the session stays up. */ +export class CodexAppServerResponseTooLargeError extends Error { + constructor( + readonly method: string, + readonly limitBytes: number, + ) { + super(`Codex app-server response to ${method} exceeds ${limitBytes} bytes`); + this.name = "CodexAppServerResponseTooLargeError"; + } +} + +// Codex serializes a response with `id` first, so its head names the request. +const OVERSIZED_RESPONSE_HEAD = /^\{"id":(\d+),"result":/; + export class CodexAppServerRpcError extends Error { constructor( message: string, @@ -167,41 +187,9 @@ function readProviderTurnId(params: unknown): string | undefined { return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined; } -/** - * Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029 - * as line terminators, but Codex emits those characters raw inside JSON strings, so readline - * would cut a single JSON-RPC message into pieces that fail to parse. - */ -function createNewlineLineReader( - input: NodeJS.ReadableStream, - onLine: (line: string) => void, -): { close(): void } { - const decoder = new StringDecoder("utf8"); - // Text after the last "\n", kept as parts and joined once its line ends, so a large line - // arriving in many chunks is scanned and copied once instead of once per chunk. - let unterminated: string[] = []; - const onData = (chunk: Buffer | string): void => { - const text = typeof chunk === "string" ? chunk : decoder.write(chunk); - let lineStart = 0; - let index = text.indexOf("\n"); - while (index !== -1) { - unterminated.push(text.slice(lineStart, index)); - const line = unterminated.join(""); - unterminated = []; - onLine(line.endsWith("\r") ? line.slice(0, -1) : line); - lineStart = index + 1; - index = text.indexOf("\n", lineStart); - } - if (lineStart < text.length) { - unterminated.push(text.slice(lineStart)); - } - }; - input.on("data", onData); - return { close: () => void input.off("data", onData) }; -} - export class CodexAppServerClient { private readonly stdoutLineReader: { close(): void }; + private activeProviderRequests = 0; private readonly pending = new Map(); private readonly requestHandlers = new Map(); private notificationHandler: NotificationHandler | null = null; @@ -215,11 +203,24 @@ export class CodexAppServerClient { private readonly logger: Logger, private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}), ) { - this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => { - void this.handleLine(line).catch((error) => { - this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line"); - }); - }); + // Split only on LF: U+2028/U+2029 are valid raw characters inside Codex JSON. + const reader = new ProviderLineReader( + CODEX_PROVIDER_LINE_BYTES, + (line) => { + void this.handleLine(line).catch((error) => { + this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line"); + }); + }, + (error) => this.handleStreamLimit(error), + ); + const onData = (chunk: Buffer | string) => reader.write(chunk); + child.stdout.on("data", onData); + this.stdoutLineReader = { + close: () => { + reader.close(); + child.stdout.off("data", onData); + }, + }; child.stderr.on("data", (chunk) => { this.stderrBuffer += chunk.toString(); @@ -255,6 +256,10 @@ export class CodexAppServerClient { this.requestHandlers.set(method, handler); } + hasPendingRequests(): boolean { + return this.pending.size > 0 || this.activeProviderRequests > 0; + } + request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { if (this.disposed) { return Promise.reject(new Error("Codex app-server client is closed")); @@ -268,7 +273,7 @@ export class CodexAppServerClient { this.pending.delete(id); reject(new Error(`Codex app-server request timed out for ${method}`)); }, timeoutMs); - this.pending.set(id, { resolve, reject, timer }); + this.pending.set(id, { method, resolve, reject, timer }); }); } @@ -313,6 +318,28 @@ export class CodexAppServerClient { } } + private handleStreamLimit(failure: ProviderStreamLimitError): void { + const responseId = OVERSIZED_RESPONSE_HEAD.exec(failure.lineHead)?.[1]; + const pending = responseId === undefined ? undefined : this.pending.get(Number(responseId)); + if (pending) { + clearTimeout(pending.timer); + this.pending.delete(Number(responseId)); + this.logger.warn( + { method: pending.method, limitBytes: failure.limitBytes }, + "Codex app-server response exceeded the line cap", + ); + pending.reject(new CodexAppServerResponseTooLargeError(pending.method, failure.limitBytes)); + return; + } + // A lost notification or server request cannot be replayed. Fail the session, + // including live turns with no pending RPC, rather than dropping a stream unit. + this.stdoutLineReader.close(); + this.handleUnexpectedTermination(failure); + void this.dispose().catch((err) => { + this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer"); + }); + } + private handleUnexpectedTermination(error: Error): void { if (this.disposed) { return; @@ -392,6 +419,7 @@ export class CodexAppServerClient { this.traceRawEvent(request); const handler = this.requestHandlers.get(request.method); try { + this.activeProviderRequests++; const result = handler ? await handler(request.params, request.id) : {}; this.writeJsonRpcResponse({ id: request.id, result }); } catch (error) { @@ -399,6 +427,8 @@ export class CodexAppServerClient { id: request.id, error: { message: error instanceof Error ? error.message : String(error) }, }); + } finally { + this.activeProviderRequests--; } return; } diff --git a/packages/server/src/server/agent/providers/codex/rewind.test.ts b/packages/server/src/server/agent/providers/codex/rewind.test.ts index da7fe6a8f5..7a0b2dc0f9 100644 --- a/packages/server/src/server/agent/providers/codex/rewind.test.ts +++ b/packages/server/src/server/agent/providers/codex/rewind.test.ts @@ -108,7 +108,7 @@ describe("Codex Rewind", () => { cwd: "/workspace/project", model: "gpt-5.4-mini", serviceTier: null, - excludeTurns: false, + excludeTurns: true, persistExtendedHistory: true, }, ]); @@ -190,7 +190,7 @@ describe("Codex Rewind", () => { cwd: "/workspace/project", model: "gpt-5.4-mini", serviceTier: null, - excludeTurns: false, + excludeTurns: true, persistExtendedHistory: true, }, ]); diff --git a/packages/server/src/server/agent/providers/codex/rewind.ts b/packages/server/src/server/agent/providers/codex/rewind.ts index 021d57645c..a22931b92b 100644 --- a/packages/server/src/server/agent/providers/codex/rewind.ts +++ b/packages/server/src/server/agent/providers/codex/rewind.ts @@ -8,6 +8,7 @@ import { parseCodexThreadForkResponse, parseCodexThreadRollbackResponse, } from "./app-server-transport.js"; +import { readCodexThreadHistoryMode } from "./thread-history.js"; export interface CodexRewindClient { forkThread?(params: CodexThreadForkParams): Promise; @@ -20,26 +21,6 @@ export interface CodexUserMessageTurnIndex { count(): number; } -type CodexThreadHistoryMode = "legacy" | "paginated"; - -async function readCodexThreadHistoryMode( - client: CodexRewindClient, - threadId: string, -): Promise { - const response = await client.request("thread/read", { threadId, includeTurns: false }); - if (typeof response !== "object" || response === null || !("thread" in response)) { - throw new Error("Codex thread/read did not return thread metadata"); - } - const thread = response.thread; - if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) { - return "legacy"; - } - if (thread.historyMode === "legacy" || thread.historyMode === "paginated") { - return thread.historyMode; - } - throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`); -} - async function forkCodexThread( client: CodexRewindClient, params: CodexThreadForkParams, @@ -95,7 +76,8 @@ export async function revertCodexConversation(input: { model: input.model ?? null, serviceTier: input.serviceTier ?? null, ...(input.config ? { config: input.config } : {}), - excludeTurns: false, + // Paseo re-reads the rewound thread through paged history, not the fork reply. + excludeTurns: true, persistExtendedHistory: true, }; 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 226eeb9738..99e6a73050 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 @@ -3,6 +3,10 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js"; +import { CODEX_PROVIDER_LINE_BYTES } from "../app-server-transport.js"; + +/** A handler result that the fake writes as a response line past the provider line cap. */ +export const OVERSIZED_RESPONSE = Symbol("oversized Codex response"); type JsonObject = Record; type FakeCodexAppServerHandler = (params: unknown) => unknown; @@ -229,8 +233,18 @@ export function createFakeCodexAppServer( return; } - Promise.resolve(handler(message.params)) + let response: unknown; + try { + response = handler(message.params); + } catch (error) { + response = Promise.reject(error); + } + Promise.resolve(response) .then((result) => { + if (result === OVERSIZED_RESPONSE) { + writeOversizedResponse(message.id as number); + return undefined; + } const rpcError = toJsonObject(result).__jsonRpcError; if (rpcError) { child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`); @@ -313,6 +327,16 @@ export function createFakeCodexAppServer( }); } + // Codex writes `id` first, then a result past the provider line cap. + function writeOversizedResponse(id: number): void { + child.stdout.write(`{"id":${id},"result":{"data":[{"item":{"text":"`); + const chunk = Buffer.alloc(1024 * 1024, "x"); + for (let written = 0; written <= CODEX_PROVIDER_LINE_BYTES; written += chunk.byteLength) { + child.stdout.write(chunk); + } + child.stdout.write('"}}]}}\n'); + } + function writeNotification(method: string, params: JsonObject): void { child.stdout.write(`${JSON.stringify({ method, params })}\n`); } @@ -696,3 +720,81 @@ export function waitForProviderSubagent( ): Promise { return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id); } + +export interface FakeCodexHistoryTurn { + id: string; + startedAt?: number; + completedAt?: number; + items: JsonObject[]; +} + +export interface FakeCodexHistoryThread { + /** Omitted on Codex builds older than 0.143, which only have legacy history. */ + historyMode?: "legacy" | "paginated"; + turns: FakeCodexHistoryTurn[]; +} + +export interface FakeCodexHistoryOptions { + /** Any response carrying one of these items is written past the line cap. */ + oversizedItemIds?: string[]; +} + +/** + * Codex's persisted thread history: `thread/read` for metadata and legacy full + * reads, `thread/items/list` for paginated threads. Paginated threads reject + * the full read as Codex 0.145 and 0.146 do. + */ +export function fakeCodexThreadHistory( + threads: Record, + options: FakeCodexHistoryOptions = {}, +): Record { + const oversizedItemIds = new Set(options.oversizedItemIds ?? []); + const isOversized = (items: JsonObject[]) => + items.some((item) => oversizedItemIds.has(String(item.id))); + + function readThread(params: unknown): { id: string; thread: FakeCodexHistoryThread } { + const id = String(toJsonObject(params).threadId); + const thread = threads[id]; + if (!thread) throw new Error(`thread not found: ${id}`); + return { id, thread }; + } + + return { + "thread/read": (params) => { + const { id, thread } = readThread(params); + if (!toJsonObject(params).includeTurns) { + return { + thread: { id, ...(thread.historyMode ? { historyMode: thread.historyMode } : {}) }, + }; + } + if (thread.historyMode === "paginated") { + throw new Error("paginated threads do not support thread/read(includeTurns=true)"); + } + if (isOversized(thread.turns.flatMap((turn) => turn.items))) return OVERSIZED_RESPONSE; + return { thread: { id, turns: thread.turns } }; + }, + "thread/items/list": (params) => { + const { thread } = readThread(params); + if (thread.historyMode !== "paginated") { + throw new Error("thread/items/list is not supported for legacy threads"); + } + const request = toJsonObject(params); + // Codex records each item's own times; the fake gives every item its turn's. + const rows = thread.turns.flatMap((turn) => + turn.items.map((item) => ({ + turnId: turn.id, + item, + startedAtMs: turn.startedAt === undefined ? null : turn.startedAt * 1000, + completedAtMs: turn.completedAt === undefined ? null : turn.completedAt * 1000, + })), + ); + const limit = Math.min(typeof request.limit === "number" ? request.limit : 25, 100); + const start = + typeof request.cursor === "string" ? Number(request.cursor.slice("cursor-".length)) : 0; + const end = Math.min(rows.length, start + limit); + const data = rows.slice(start, end); + if (isOversized(data.map((row) => row.item))) return OVERSIZED_RESPONSE; + return { data, nextCursor: end < rows.length ? `cursor-${end}` : null }; + }, + }; +} diff --git a/packages/server/src/server/agent/providers/codex/thread-history.test.ts b/packages/server/src/server/agent/providers/codex/thread-history.test.ts new file mode 100644 index 0000000000..8d3d8fce3d --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/thread-history.test.ts @@ -0,0 +1,220 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; +import { CodexAppServerAgentClient } from "../codex-app-server-agent.js"; +import { + createFakeCodexAppServer, + type FakeCodexAppServer, + type FakeCodexHistoryOptions, + type FakeCodexHistoryThread, + fakeCodexThreadHistory, +} from "./test-utils/fake-app-server.js"; + +function userItem(id: string, text: string) { + return { type: "userMessage", id, content: [{ type: "text", text }] }; +} + +function agentItem(id: string, text: string) { + return { type: "agentMessage", id, text }; +} + +async function openCodexThread( + thread: FakeCodexHistoryThread, + options?: FakeCodexHistoryOptions, +): Promise<{ appServer: FakeCodexAppServer; session: AgentSession }> { + const appServer = createFakeCodexAppServer( + fakeCodexThreadHistory({ "saved-thread": thread }, options), + ); + const provider = new CodexAppServerAgentClient(createTestLogger()); + const internals = provider as unknown as { + goalsEnabledPromise: Promise | null; + autoReviewEnabledPromise: Promise | null; + spawnAppServer: () => Promise; + }; + internals.goalsEnabledPromise = Promise.resolve(false); + internals.autoReviewEnabledPromise = Promise.resolve(false); + internals.spawnAppServer = async () => appServer.child; + const session = await provider.resumeSession({ sessionId: "saved-thread" }); + return { appServer, session }; +} + +async function readHistory(session: AgentSession): Promise { + const events: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) events.push(event); + return events; +} + +function timelineItems(events: AgentStreamEvent[]) { + return events.flatMap((event) => (event.type === "timeline" ? [event.item] : [])); +} + +function historyRequests(appServer: FakeCodexAppServer) { + return appServer + .requests() + .filter( + (request) => + request.method === "thread/read" || + request.method === "thread/turns/list" || + request.method === "thread/items/list", + ) + .map((request) => ({ method: request.method, params: request.params })); +} + +describe("Codex thread history", () => { + test("loads a paginated thread through pages", async () => { + const answers = Array.from({ length: 150 }, (_, index) => + agentItem(`agent-${index}`, `Answer ${index}`), + ); + const { appServer, session } = await openCodexThread({ + historyMode: "paginated", + turns: [ + { + id: "turn-1", + startedAt: 1_778_832_941, + completedAt: 1_778_833_000, + items: [userItem("user-1", "First"), ...answers.slice(0, 120)], + }, + { + id: "turn-2", + startedAt: 1_778_833_094, + completedAt: 1_778_833_100, + items: [userItem("user-2", "Second"), ...answers.slice(120)], + }, + ], + }); + try { + const events = await readHistory(session); + + expect(timelineItems(events)).toEqual([ + { type: "user_message", text: "First", messageId: "user-1" }, + ...answers.slice(0, 120).map((answer) => ({ + type: "assistant_message", + text: answer.text, + messageId: answer.id, + })), + { type: "user_message", text: "Second", messageId: "user-2" }, + ...answers.slice(120).map((answer) => ({ + type: "assistant_message", + text: answer.text, + messageId: answer.id, + })), + ]); + const timestamps = events.flatMap((event) => + event.type === "timeline" ? [event.timestamp] : [], + ); + expect(timestamps[0]).toBe("2026-05-15T08:15:41.000Z"); + expect(timestamps[1]).toBe("2026-05-15T08:16:40.000Z"); + expect(timestamps[121]).toBe("2026-05-15T08:18:14.000Z"); + const requests = historyRequests(appServer); + expect(requests).not.toContainEqual( + expect.objectContaining({ params: expect.objectContaining({ includeTurns: true }) }), + ); + const itemPages = requests.filter((request) => request.method === "thread/items/list"); + expect(itemPages.length).toBeGreaterThan(1); + for (const page of itemPages) { + expect(page.params).toMatchObject({ threadId: "saved-thread", sortDirection: "asc" }); + expect((page.params as { limit: number }).limit).toBeLessThanOrEqual(100); + } + } finally { + await session.close(); + } + appServer.assertNoErrors(); + }); + + test("loads a paginated thread whose full read Codex 0.145 rejects", async () => { + const { appServer, session } = await openCodexThread({ + historyMode: "paginated", + turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }], + }); + try { + expect(timelineItems(await readHistory(session))).toEqual([ + { type: "user_message", text: "Hello", messageId: "user-1" }, + { type: "assistant_message", text: "Hi", messageId: "agent-1" }, + ]); + } finally { + await session.close(); + } + appServer.assertNoErrors(); + }); + + test("a page too big to send shows a warning row and stops loading", async () => { + const answers = Array.from({ length: 120 }, (_, index) => + agentItem(`agent-${index}`, `Answer ${index}`), + ); + const { appServer, session } = await openCodexThread( + { + historyMode: "paginated", + turns: [ + { id: "turn-1", items: [userItem("user-1", "One"), ...answers] }, + { id: "turn-2", items: [userItem("user-2", "Two"), agentItem("huge", "never sent")] }, + ], + }, + { oversizedItemIds: ["huge"] }, + ); + const loaded = [ + { type: "user_message", text: "One", messageId: "user-1" }, + ...answers.slice(0, 99).map((answer) => ({ + type: "assistant_message", + text: answer.text, + messageId: answer.id, + })), + { + type: "notification", + level: "warning", + message: "The rest of this Codex thread's history is too large to load.", + }, + ]; + try { + expect(timelineItems(await readHistory(session))).toEqual(loaded); + // The oversized reply failed only its own request; the session still answers. + expect(timelineItems(await readHistory(session))).toEqual(loaded); + } finally { + await session.close(); + } + appServer.assertNoErrors(); + }); + + test("loads a legacy thread that fits in one read", async () => { + const { appServer, session } = await openCodexThread({ + historyMode: "legacy", + turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }], + }); + try { + expect(timelineItems(await readHistory(session))).toEqual([ + { type: "user_message", text: "Hello", messageId: "user-1" }, + { type: "assistant_message", text: "Hi", messageId: "agent-1" }, + ]); + expect(historyRequests(appServer).map((request) => request.method)).not.toContain( + "thread/items/list", + ); + } finally { + await session.close(); + } + appServer.assertNoErrors(); + }); + + test("a legacy thread too big for one read shows the migration command", async () => { + const { appServer, session } = await openCodexThread( + { + historyMode: "legacy", + turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("huge", "x")] }], + }, + { oversizedItemIds: ["huge"] }, + ); + const migrationRow = { + type: "notification", + level: "warning", + message: expect.stringContaining("codex migrate-rollouts --apply --thread saved-thread"), + }; + try { + expect(timelineItems(await readHistory(session))).toEqual([migrationRow]); + // The session stays up for the user to prompt until they archive it to migrate. + expect(timelineItems(await readHistory(session))).toEqual([migrationRow]); + } finally { + await session.close(); + } + appServer.assertNoErrors(); + }); +}); diff --git a/packages/server/src/server/agent/providers/codex/thread-history.ts b/packages/server/src/server/agent/providers/codex/thread-history.ts new file mode 100644 index 0000000000..bf7461deb6 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/thread-history.ts @@ -0,0 +1,168 @@ +import { z } from "zod"; + +import { CodexAppServerResponseTooLargeError } from "./app-server-transport.js"; + +// Codex clamps history pages to 100 rows. +const PAGE_SIZE = 100; + +export interface CodexThreadHistoryClient { + request(method: string, params?: unknown): Promise; +} + +export type CodexThreadHistoryMode = "legacy" | "paginated"; + +export interface CodexHistoryRow { + item: unknown; + turnId: string | undefined; + /** The item's times on a paginated thread; its turn's on a legacy thread. */ + startedAt: unknown; + completedAt: unknown; +} + +/** A history reply over the line cap; the rows before it were already read. */ +export class CodexThreadHistoryTooLargeError extends Error { + constructor(message: string) { + super(message); + this.name = "CodexThreadHistoryTooLargeError"; + } +} + +const CodexThreadMetadataSchema = z + .object({ + thread: z.object({ historyMode: z.enum(["legacy", "paginated"]).optional() }).passthrough(), + }) + .passthrough(); + +const CodexLegacyThreadReadSchema = z + .object({ + thread: z + .object({ + turns: z + .array( + z + .object({ + id: z.string().optional(), + startedAt: z.unknown().optional(), + completedAt: z.unknown().optional(), + items: z.array(z.unknown()).default([]), + }) + .passthrough(), + ) + .default([]), + }) + .passthrough() + .default({ turns: [] }), + }) + .passthrough(); + +const CodexItemsPageSchema = z + .object({ + data: z.array( + z + .object({ + turnId: z.string(), + item: z.unknown(), + startedAtMs: z.number().nullable().optional(), + completedAtMs: z.number().nullable().optional(), + }) + .passthrough(), + ), + nextCursor: z.string().nullable().optional(), + }) + .passthrough(); + +/** Codex builds before 0.143 report no history mode; their threads are legacy. */ +export async function readCodexThreadHistoryMode( + client: CodexThreadHistoryClient, + threadId: string, +): Promise { + const response = CodexThreadMetadataSchema.parse( + await client.request("thread/read", { threadId, includeTurns: false }), + ); + return response.thread.historyMode ?? "legacy"; +} + +/** + * Reads a thread's persisted items in order. Paginated threads are read in + * pages; a legacy thread arrives in one `thread/read` reply, as Codex offers + * no pages for it. + */ +export async function* readCodexThreadHistory( + client: CodexThreadHistoryClient, + threadId: string, +): AsyncGenerator { + if ((await readCodexThreadHistoryMode(client, threadId)) === "paginated") { + yield* readPaginatedHistory(client, threadId); + } else { + yield* readLegacyHistory(client, threadId); + } +} + +async function* readLegacyHistory( + client: CodexThreadHistoryClient, + threadId: string, +): AsyncGenerator { + const response = await requestHistory( + client, + "thread/read", + { threadId, includeTurns: true }, + `This Codex thread's history is too large to load in one read. ` + + `To move it to paginated history, archive this agent, ` + + `run \`codex migrate-rollouts --apply --thread ${threadId}\` (Codex 0.148 or newer), ` + + `then unarchive the agent.`, + ); + for (const turn of CodexLegacyThreadReadSchema.parse(response).thread.turns) { + for (const item of turn.items) { + yield { item, turnId: turn.id, startedAt: turn.startedAt, completedAt: turn.completedAt }; + } + } +} + +async function* readPaginatedHistory( + client: CodexThreadHistoryClient, + threadId: string, +): AsyncGenerator { + const seenCursors = new Set(); + let cursor: string | undefined; + for (;;) { + const page = CodexItemsPageSchema.parse( + await requestHistory( + client, + "thread/items/list", + { threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor }, + "The rest of this Codex thread's history is too large to load.", + ), + ); + for (const row of page.data) { + yield { + item: row.item, + turnId: row.turnId, + startedAt: row.startedAtMs, + completedAt: row.completedAtMs, + }; + } + if (page.nextCursor == null) return; + // Codex's own client guards against a cursor that does not advance. + if (seenCursors.has(page.nextCursor)) { + throw new Error(`Codex repeated history cursor for thread ${threadId}`); + } + seenCursors.add(page.nextCursor); + cursor = page.nextCursor; + } +} + +async function requestHistory( + client: CodexThreadHistoryClient, + method: string, + params: unknown, + tooLargeMessage: string, +): Promise { + try { + return await client.request(method, params); + } catch (error) { + if (error instanceof CodexAppServerResponseTooLargeError) { + throw new CodexThreadHistoryTooLargeError(tooLargeMessage); + } + throw error; + } +} diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 64629bc5d9..4e77dc9367 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -950,6 +950,7 @@ function mapCollabAgentToolCallItem( type: "sub_agent", subAgentType: "Sub-agent", ...(item.prompt ? { description: item.prompt } : {}), + // COMPAT(subagent-log): remove after 2027-04-30. log: "", actions: [], }; @@ -1006,6 +1007,7 @@ function mapSubAgentActivityItem( type: "sub_agent", subAgentType: nativeName, description, + // COMPAT(subagent-log): remove after 2027-04-30. log: "", actions: [], }, diff --git a/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts b/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts index af2bdabf01..4844415324 100644 --- a/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-frame-decoder.test.ts @@ -1,3 +1,4 @@ +import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js"; import { describe, expect, it } from "vitest"; import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js"; @@ -36,6 +37,16 @@ describe("JsonlFrameDecoder", () => { expect(problems).toEqual([]); }); + it("preserves UTF-8 characters split across raw stdout buffers", () => { + const { decoder, frames, problems } = decoderHarness(); + const bytes = Buffer.from('{"type":"notice","text":"é🙂"}\n'); + const split = bytes.indexOf(Buffer.from("🙂")) + 1; + decoder.write(bytes.subarray(0, split)); + decoder.write(bytes.subarray(split)); + expect(frames).toEqual([{ type: "notice", text: "é🙂" }]); + expect(problems).toEqual([]); + }); + it("reassembles a large ordered v2 frame", () => { const { decoder, frames, problems } = decoderHarness(); const frame = { type: "models", value: "x".repeat(1024 * 1024) }; @@ -55,3 +66,46 @@ describe("JsonlFrameDecoder", () => { expect(problems).toEqual(["out-of-order-chunk"]); }); }); + +describe("provider line admission", () => { + it("keeps the loop responsive while accumulating a fragmented provider response", async () => { + const { decoder, frames, problems } = decoderHarness(); + const part = "x".repeat(512); + const { blockingMs } = await measureMainThreadWork("fragmented JSONL main-thread work", () => { + decoder.write('{"type":"notice","text":"'); + // 1 MiB exposes the former rescan of every accumulated prefix. + for (let index = 0; index < 2048; index++) decoder.write(part); + decoder.write('"}\n'); + }); + expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]); + expect(problems).toEqual([]); + expect(blockingMs).toBeLessThan(50); + }); + + it("rejects an unterminated over-cap UTF-8 line and stops accepting frames", () => { + const frames: Record[] = []; + const failures: Array<{ problem: string; detail: unknown }> = []; + const decoder = new JsonlFrameDecoder({ + frame: (frame) => frames.push(frame), + problem: (problem, detail) => failures.push({ problem, detail }), + }); + // 64 MiB of UTF-8, only 32 Mi UTF-16 code units. No newline or JSON parse needed. + const chunk = "é".repeat(32 * 1024); + for (let index = 0; index < 1024; index++) decoder.write(chunk); + expect(failures).toEqual([]); + decoder.write("é"); + expect(failures).toEqual([ + { + problem: "line-too-large", + detail: expect.objectContaining({ + name: "ProviderStreamLimitError", + code: "PROVIDER_STREAM_LINE_TOO_LARGE", + limitBytes: 64 * 1024 * 1024, + }), + }, + ]); + decoder.write('\n{"type":"ready"}\n'); + expect(frames).toEqual([]); + expect(failures).toHaveLength(1); + }); +}); diff --git a/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts b/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts index 1662e7719c..ff8fa83489 100644 --- a/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts +++ b/packages/server/src/server/agent/providers/jsonl-frame-decoder.ts @@ -1,3 +1,92 @@ +// Provider-process stdout only; these are not app/daemon wire limits. +export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024; + +// Enough of an oversized line to read which response it was, e.g. `{"id":12,"result":`. +const OVERSIZED_LINE_HEAD_BYTES = 64; + +export class ProviderStreamLimitError extends Error { + readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE"; + + constructor( + readonly limitBytes: number, + /** The first bytes of the oversized line; the rest is discarded unread. */ + readonly lineHead = "", + ) { + super( + `Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`, + ); + this.name = "ProviderStreamLimitError"; + } +} + +function readLineHead(parts: Buffer[]): string { + const head: Buffer[] = []; + let bytes = 0; + for (const part of parts) { + if (bytes >= OVERSIZED_LINE_HEAD_BYTES) break; + head.push(part.subarray(0, OVERSIZED_LINE_HEAD_BYTES - bytes)); + bytes += head[head.length - 1].byteLength; + } + return Buffer.concat(head).toString("utf8"); +} + +/** + * Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. + * An oversized line is reported with its head and skipped up to its newline; + * the owner closes the reader if the stream cannot continue without it. + */ +export class ProviderLineReader { + private parts: Buffer[] = []; + private bytes = 0; + private skippingOversizedLine = false; + private stopped = false; + + constructor( + private readonly limitBytes: number, + private readonly onLine: (line: string) => void, + private readonly onLimit: (error: ProviderStreamLimitError) => void, + ) {} + + write(chunk: Buffer | string): void { + if (this.stopped) return; + const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk; + let start = 0; + while (start < buffer.byteLength && !this.stopped) { + const newline = buffer.indexOf(10, start); + const end = newline === -1 ? buffer.byteLength : newline; + if (this.skippingOversizedLine) { + if (newline === -1) return; + this.skippingOversizedLine = false; + start = newline + 1; + continue; + } + const length = end - start; + if (length > this.limitBytes - this.bytes) { + const head = readLineHead([...this.parts, buffer.subarray(start, end)]); + this.parts = []; + this.bytes = 0; + this.skippingOversizedLine = true; + this.onLimit(new ProviderStreamLimitError(this.limitBytes, head)); + continue; + } + if (length) this.parts.push(buffer.subarray(start, end)); + this.bytes += length; + if (newline === -1) return; + const line = Buffer.concat(this.parts, this.bytes).toString("utf8"); + this.parts = []; + this.bytes = 0; + this.onLine(line.endsWith("\r") ? line.slice(0, -1) : line); + start = newline + 1; + } + } + + close(): void { + this.stopped = true; + this.parts = []; + this.bytes = 0; + } +} + const V2_FRAME_BYTES = 1024 * 1024; const V2_REASSEMBLED_BYTES = 64 * 1024 * 1024; const V2_CHUNK_BYTES = 256 * 1024; @@ -5,6 +94,7 @@ const V2_BASE64_CHARS = Math.ceil(V2_CHUNK_BYTES / 3) * 4; const V2_MAX_CHUNKS = Math.ceil(V2_REASSEMBLED_BYTES / V2_CHUNK_BYTES); export type JsonlFrameProblem = + | "line-too-large" | "invalid-json" | "invalid-chunk" | "invalid-base64" @@ -78,7 +168,7 @@ function chunkBytes(data: string): Buffer | null { } export class JsonlFrameDecoder { - private lineBuffer = ""; + private readonly lines: ProviderLineReader; private assembly: ChunkAssembly | null = null; constructor( @@ -86,17 +176,21 @@ export class JsonlFrameDecoder { frame(message: Record): void; problem(problem: JsonlFrameProblem, detail?: unknown): void; }, - ) {} + ) { + this.lines = new ProviderLineReader( + JSONL_PROVIDER_LINE_BYTES, + (line) => { + if (line.trim()) this.consumeLine(line); + }, + (error) => { + this.lines.close(); + this.drop("line-too-large", error); + }, + ); + } - write(text: string): void { - this.lineBuffer += text; - let newline = this.lineBuffer.indexOf("\n"); - while (newline !== -1) { - const line = this.lineBuffer.slice(0, newline).replace(/\r$/, ""); - this.lineBuffer = this.lineBuffer.slice(newline + 1); - if (line.trim()) this.consumeLine(line); - newline = this.lineBuffer.indexOf("\n"); - } + write(chunk: Buffer | string): void { + this.lines.write(chunk); } private consumeLine(line: string): void { diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts index 7dfbb6abb8..141b8f36f0 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts @@ -471,3 +471,25 @@ describe("JsonlRpcProcess", () => { } }); }); + +describe("JSONL stdout byte admission", () => { + test("fails pending requests and publishes the typed failure to live-turn consumers", async () => { + const child = createInMemoryChildProcess(); + const process = startProcess({ child }); + const exit = new Promise((resolve) => process.onExit(resolve)); + const request = process.request({ type: "hang" }, 1000).catch((error: Error) => error); + const chunk = Buffer.alloc(64 * 1024, "x"); + child.stdout.write('{"type":"notice","text":"'); + for (let index = 0; index < 1024; index++) child.stdout.write(chunk); + const error = await request; + expect(error).toMatchObject({ + name: "ProviderStreamLimitError", + code: "PROVIDER_STREAM_LINE_TOO_LARGE", + limitBytes: 64 * 1024 * 1024, + }); + expect((await exit).error).toBe(error); + await process.close(); + child.stdout.end(); + child.stderr.end(); + }); +}); diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts index 9d8b9af623..ddf85eb316 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -3,7 +3,7 @@ import type { Logger } from "pino"; import { spawnProcess } from "../../../utils/spawn.js"; import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; -import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js"; +import { JsonlFrameDecoder, ProviderStreamLimitError } from "./jsonl-frame-decoder.js"; export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js"; /** Default wall-clock timeout for control-plane / short RPC calls. */ @@ -81,6 +81,7 @@ export class JsonlRpcProcess { private stderrBuffer = ""; private nextRequestId = 1; private disposed = false; + private streamFailure: ProviderStreamLimitError | null = null; private exited = false; private closing: Promise | null = null; private readonly frameDecoder: JsonlFrameDecoder; @@ -90,6 +91,14 @@ export class JsonlRpcProcess { this.frameDecoder = new JsonlFrameDecoder({ frame: (message) => this.dispatchFrame(message), problem: (problem, detail) => { + if (problem === "line-too-large" && detail instanceof ProviderStreamLimitError) { + // Stop the producer: silently skipping a response or turn event loses state. + this.streamFailure = detail; + void this.close(detail).catch((err) => { + this.options.logger.warn({ err }, `Failed to close ${this.diagnosticName}`); + }); + return; + } this.options.logger.warn( { problem, detail }, `Ignoring invalid ${this.diagnosticName} frame`, @@ -98,7 +107,7 @@ export class JsonlRpcProcess { }); this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch); this.child.stdout.on("data", (chunk) => { - this.handleStdoutChunk(chunk.toString()); + this.handleStdoutChunk(chunk); }); this.child.stderr.on("data", (chunk) => { this.stderrBuffer += chunk.toString(); @@ -114,9 +123,11 @@ export class JsonlRpcProcess { }); this.child.on("exit", (code, signal) => { this.exited = true; - const error = new Error( - `${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(), - ); + const error = + this.streamFailure ?? + new Error( + `${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(), + ); const exit = { code, signal, error }; for (const subscriber of this.exitSubscribers) { subscriber(exit); @@ -254,7 +265,7 @@ export class JsonlRpcProcess { } } - private handleStdoutChunk(chunk: string): void { + private handleStdoutChunk(chunk: Buffer | string): void { this.frameDecoder.write(chunk); } diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index b7b301540a..14322c6ccf 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -230,6 +230,7 @@ interface LargeAgentStreamPayloadRequest { } interface AgentStreamStressRequest { + tools: boolean; count: number; coalesced: boolean; } @@ -295,6 +296,35 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe return null; } + if (/with\s+links/i.test(text)) { + return { + questions: [ + { + question: + "Sign in at https://example.com/login, then follow [the setup guide](https://example.com/setup). Done?", + header: "signIn", + options: [ + { + label: "Done", + description: "Logs are at https://example.com/logs if sign-in fails.", + }, + { label: "Not yet, see [status](https://example.com/status)" }, + ], + multiSelect: false, + }, + { + question: "Which checks should run?", + header: "checks", + options: [ + { label: "Lint", description: "Rules: https://example.com/lint" }, + { label: "Typecheck" }, + ], + multiSelect: true, + }, + ], + }; + } + if (/free[-\s]?write|freeform|text[-\s]?only/i.test(text)) { return { questions: [ @@ -432,7 +462,7 @@ function parseLargeAgentStreamPayloadPrompt( function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStressRequest | null { const text = promptToText(prompt); - const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(text); + const match = /emit\s+(\d+)\s+(coalesced\s+|tool\s+)?agent stream updates/i.exec(text); if (!match) { return null; } @@ -442,7 +472,8 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre } return { count: Math.min(count, 5_000), - coalesced: Boolean(match[2]), + coalesced: match[2]?.trim() === "coalesced", + tools: match[2]?.trim() === "tool", }; } @@ -1423,21 +1454,37 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTurnStarted(turn); for (let index = 0; index < stress.count; index += 1) { - this.emitTimeline( - turn.turnId, - stress.coalesced - ? { - type: "assistant_message", - text: `stress-update-${index}`, - messageId: turn.assistantMessageId, - } - : { - type: "todo", - items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], - }, - ); + let item: AgentTimelineItem; + if (stress.tools) { + item = { + type: "tool_call", + callId: `${turn.turnId}:stress:${index}`, + name: "Bash", + status: "completed", + error: null, + detail: { type: "shell", command: `echo stress-${index}`, output: `stress-${index}` }, + }; + } else if (stress.coalesced) { + item = { + type: "assistant_message", + text: `stress-update-${index}`, + messageId: turn.assistantMessageId, + }; + } else { + item = { + type: "todo", + items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], + }; + } + this.emitTimeline(turn.turnId, item); } + if (stress.tools) + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: `Completed ${stress.count} commands`, + messageId: turn.assistantMessageId, + }); this.activeTurn = null; const usage = { inputTokens: 1, diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index c9911a64b8..15392fc07c 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -10,7 +10,7 @@ import type { OmpAgentMessage } from "./rpc-types.js"; import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js"; import type { OmpUsagePollScheduler } from "./usage-poller.js"; import { resolveOmpProviderOptions } from "./provider-config.js"; -import { OmpRuntimeEventSchema } from "./rpc-types.js"; +import { OmpRuntimeEventSchema, OmpSessionStateSchema } from "./rpc-types.js"; import { OmpHarness } from "./test-utils/omp-harness.js"; import { OmpAgentClient } from "./agent.js"; import { FakeOmp } from "./test-utils/fake-omp.js"; @@ -1133,6 +1133,17 @@ describe("OMP agent client and session", () => { messageId: "assistant-history", }, ]); + await expect( + omp.runPrompt("continue after resume", "resumed follow-up complete"), + ).resolves.toMatchObject({ + finalText: "resumed follow-up complete", + }); + expect(omp.timeline()).toContainEqual( + expect.objectContaining({ + type: "assistant_message", + text: "resumed follow-up complete", + }), + ); expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) }); await omp.close(); expect(omp.usageSession()).toBeNull(); @@ -1332,13 +1343,14 @@ describe("OMP agent client and session", () => { expect(omp.runtimeLaunches()).toHaveLength(1); }); - test("closes a replacement process if the session closes during relaunch", async () => { + test("rejects a pending recovery start and closes its processes when the session closes", async () => { const omp = new OmpHarness(); await omp.start(); omp.processExit("OMP RPC process exited with code 1 and signal null"); const turn = omp.startTurnDetached("continue"); + const rejected = expect(turn).rejects.toThrow("closed"); await omp.close(); - await turn; + await rejected; await waitForImmediate(); expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true); }); @@ -1544,3 +1556,176 @@ describe("OMP agent client and session", () => { ]); }); }); + +test("omp runtime safety protects queued replies and runtime-owned activity", async () => { + const runtime = new FakeOmp(); + const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({ + provider: "omp", + cwd: process.cwd(), + }); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + native.state.isSettled = true; + native.state.hasPendingAsyncWork = false; + expect(await session.canStopRuntime!()).toBe(true); + native.state.queuedMessageCount = 1; + expect(await session.canStopRuntime!()).toBe(false); + native.state.queuedMessageCount = 0; + native.state.isStreaming = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isStreaming = false; + native.state.isCompacting = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isCompacting = false; + expect(await session.canStopRuntime!()).toBe(true); + native.getStateError = new Error("Unable to prove idle"); + await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle"); +}); + +test("OMP runtime safety retains async work after the foreground turn finishes", async () => { + const runtime = new FakeOmp(); + const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({ + provider: "omp", + cwd: process.cwd(), + }); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + native.state.hasPendingAsyncWork = true; + native.state.isSettled = false; + const prompted = native.nextPrompt(); + const run = session.run("start a background job"); + await prompted; + native.beginTurn(); + native.acceptPrompt("start a background job", "async-user"); + native.streamAssistantText("Job started in background"); + native.finishTurn(); + await run; + expect(native.state.isStreaming).toBe(false); + expect(native.state.queuedMessageCount).toBe(0); + expect(await session.canStopRuntime!()).toBe(false); + // The delivery is drained, but an admitted/scheduled continuation still prevents settlement. + native.state.hasPendingAsyncWork = false; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isSettled = true; + expect(await session.canStopRuntime!()).toBe(true); +}); + +test.each([ + { hasPendingAsyncWork: true, isSettled: true, expected: false }, + { hasPendingAsyncWork: false, isSettled: false, expected: false }, + { hasPendingAsyncWork: false, isSettled: true, expected: true }, + { hasPendingAsyncWork: undefined, isSettled: true, expected: false }, + { hasPendingAsyncWork: false, isSettled: undefined, expected: false }, + { hasPendingAsyncWork: undefined, isSettled: undefined, expected: false }, +])( + "OMP runtime safety requires explicit settlement: %j", + async ({ hasPendingAsyncWork, isSettled, expected }) => { + const runtime = new FakeOmp(); + const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession( + { + provider: "omp", + cwd: process.cwd(), + }, + ); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + const response = { ...native.state }; + if (hasPendingAsyncWork !== undefined) response.hasPendingAsyncWork = hasPendingAsyncWork; + if (isSettled !== undefined) response.isSettled = isSettled; + // Legacy get_state responses stay valid; missing safety fields never imply settlement. + native.state = OmpSessionStateSchema.parse(response); + expect(native.state.hasPendingAsyncWork).toBe(hasPendingAsyncWork); + expect(native.state.isSettled).toBe(isSettled); + expect(await session.canStopRuntime!()).toBe(expected); + }, +); + +test.each([false, true])( + "image prompt keeps OMP's vision capability contract (vision: %s)", + async (vision) => { + const runtime = new FakeOmp(); + runtime.queueSessionSetup((session) => { + session.state = { + ...session.state, + model: { + provider: "test", + id: "image-model", + input: vision ? ["text", "image"] : ["text"], + }, + }; + }); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession({ provider: "omp", cwd: process.cwd() }); + onTestFinished(() => session.close()); + const bytes = Buffer.from("OMP attachment fixture"); + const first = session.startTurn([ + { type: "text", text: "Describe this image." }, + { type: "image", data: bytes.toString("base64"), mimeType: "image/png" }, + ]); + await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active"); + await first; + const [prompt] = runtime.latestSession().prompts; + expect(prompt.imageCount).toBe(vision ? 1 : 0); + if (vision) expect(prompt.message).toBe("Describe this image."); + else { + const imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1]; + expect(imagePath).toBeTruthy(); + const { readFile, rm } = await import("node:fs/promises"); + expect(await readFile(imagePath!)).toEqual(bytes); + await rm(imagePath!); + } + }, +); + +test("interrupting OMP during image conversion cancels the admitted turn and permits a follow-up", async () => { + const runtime = new FakeOmp(); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession({ provider: "omp", cwd: process.cwd() }); + onTestFinished(() => session.close()); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + let admitted = false; + const first = session + .startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 53).toString("base64"), + mimeType: "image/png", + }, + ]) + .then((result) => { + admitted = true; + return result; + }); + await waitForImmediate(); + expect(admitted).toBe(false); + expect(runtime.latestSession().prompts).toHaveLength(0); + await session.interrupt(); + const followup = await session.startTurn("follow-up after image interrupt"); + const original = await first; + expect(events).toContainEqual( + expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }), + ); + expect(followup.turnId).not.toBe(original.turnId); + expect(runtime.latestSession().prompts).toEqual([ + { message: "follow-up after image interrupt", imageCount: 0 }, + ]); +}); + +test("closing OMP during image conversion rejects its pending start", async () => { + const runtime = new FakeOmp(); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession({ provider: "omp", cwd: process.cwd() }); + const first = session.startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 54).toString("base64"), + mimeType: "image/png", + }, + ]); + const rejected = expect(first).rejects.toThrow("closed"); + await waitForImmediate(); + expect(runtime.latestSession().prompts).toHaveLength(0); + await session.close(); + await rejected; +}); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index ab82df9475..c8b44c0bd8 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -69,7 +69,7 @@ import { type OmpRuntimeOptions, } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; -import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; +import { OmpSubagentCardTracker } from "./subagent-card-tracker.js"; import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; import { mapOmpSystemNoticeToNotification } from "./system-notice.js"; @@ -130,7 +130,6 @@ export interface OmpAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; runtime?: OmpRuntime; - subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; @@ -205,7 +204,6 @@ interface OmpAgentSessionOptions { initialState: OmpSessionState; currentModeId?: string | null; logger: Logger; - subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; @@ -317,9 +315,9 @@ function ompModelSupportsImageInput(model: OmpModel | null | undefined): boolean return model?.input?.includes("image") === true; } -function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string { +async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise { try { - const materialized = materializeProviderImage({ + const materialized = await materializeProviderImage({ data: image.data, mimeType: image.mimeType, }); @@ -329,10 +327,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str } } -function convertPromptInput( +async function convertPromptInput( prompt: AgentPromptInput, options: { model: OmpModel | null | undefined }, -): OmpPromptPayload { +): Promise { if (typeof prompt === "string") { return { text: prompt }; } @@ -355,7 +353,7 @@ function convertPromptInput( mimeType: block.mimeType, }); } else { - textParts.push(renderTextOnlyImageHint(block)); + textParts.push(await renderTextOnlyImageHint(block)); } continue; } @@ -773,9 +771,7 @@ export class OmpAgentSession implements AgentSession { this.logger.debug({ err: error }, "OMP context usage poll failed"); }, }); - this.subagentCardTracker = new OmpSubagentCardTracker({ - scheduler: options.subagentCardScheduler, - }); + this.subagentCardTracker = new OmpSubagentCardTracker(); this.lastKnownThinkingOptionId = normalizeOmpThinkingOption(options.config.thinkingOptionId) ?? this.state.thinkingLevel ?? @@ -868,11 +864,9 @@ export class OmpAgentSession implements AgentSession { throw new Error("An OMP turn is already active"); } - const payload = convertPromptInput(prompt, { model: this.state.model }); const turnId = randomUUID(); this.live = true; this.activeTurnId = turnId; - this.rememberClientMessage(options?.clientMessageId ?? null, payload.text); this.activeAssistantMessageId = null; this.activeTurnTerminalAssistantMessage = null; this.activeTurnStarted = false; @@ -880,6 +874,10 @@ export class OmpAgentSession implements AgentSession { this.activeTurnHasUserMessage = false; this.activePromptRequestId = null; this.clearNoTurnBuffers(); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed) throw new Error("OMP session is closed"); + if (this.activeTurnId !== turnId) return { turnId }; + this.rememberClientMessage(options?.clientMessageId ?? null, payload.text); this.activeNoTurnPromptText = payload.text; this.usagePoller.startTurn(); if (announceTurn) { @@ -946,7 +944,9 @@ export class OmpAgentSession implements AgentSession { ): Promise { if (this.closed || this.activeTurnId !== options.expectedTurnId) return { status: "unavailable" }; - const payload = convertPromptInput(prompt, { model: this.state.model }); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed || this.activeTurnId !== options.expectedTurnId) + return { status: "unavailable" }; if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" }; const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text); try { @@ -1172,6 +1172,27 @@ export class OmpAgentSession implements AgentSession { this.activeToolCalls.clear(); } + async canStopRuntime(): Promise { + if ( + this.closed || + this.activeTurnId || + this.activeToolCalls.size || + this.pendingExtensionUiRequests.size || + this.pendingClientMessages.length || + this.pendingNoTurnOutputs.length || + this.pendingNoTurnCompletionAbort + ) + return false; + const state = await this.runtimeSession.getState(); + return ( + state.isSettled === true && + state.hasPendingAsyncWork === false && + !state.isStreaming && + !state.isCompacting && + state.queuedMessageCount === 0 + ); + } + async close(): Promise { if (this.closed) { return; @@ -2130,8 +2151,7 @@ export class OmpAgentSession implements AgentSession { return mapOmpToolDetail(toolCall, result, { toolCallId, bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName), - mapSubagentDetail: (detail) => - this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail, + mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail), }); } @@ -2221,7 +2241,6 @@ export class OmpAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly subagentCardScheduler?: OmpSubagentCardScheduler; private readonly providerIdleScheduler?: OmpProviderIdleScheduler; private readonly noTurnScheduler?: OmpNoTurnScheduler; private readonly usagePollScheduler?: OmpUsagePollScheduler; @@ -2240,7 +2259,6 @@ export class OmpAgentClient implements AgentClient { ); this.logger = options.logger; this.runtimeSettings = runtimeSettings; - this.subagentCardScheduler = options.subagentCardScheduler; this.providerIdleScheduler = options.providerIdleScheduler; this.noTurnScheduler = options.noTurnScheduler; this.usagePollScheduler = options.usagePollScheduler; @@ -2324,7 +2342,6 @@ export class OmpAgentClient implements AgentClient { initialState, currentModeId: launchMode.modeId, logger: this.logger, - subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, @@ -2396,7 +2413,6 @@ export class OmpAgentClient implements AgentClient { initialState, currentModeId: launchMode.modeId, logger: this.logger, - subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index e64a93fd64..6d39c21c2d 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -1,4 +1,5 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -8,7 +9,7 @@ import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./messag import type { OmpAgentMessage } from "./rpc-types.js"; import { FakeOmp } from "./test-utils/fake-omp.js"; import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js"; -import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js"; +import { readActiveOmpEntryChain, readOmpHistoryTodoState, streamOmpHistory } from "./history.js"; async function collectHistory( messages: OmpAgentMessage[], @@ -636,7 +637,7 @@ describe("OMP history mapper", () => { subAgentType: "explore", description: "Inspect files", childSessionId: "/tmp/omp-task/Explore.jsonl", - log: "done\ntranscript: /tmp/omp-task/Explore.jsonl", + log: "", }, error: null, }, @@ -1245,3 +1246,53 @@ describe("OMP history mapper", () => { ); }); }); + +test("indexes out-of-order branches, duplicate IDs and cycles before selecting history", async () => { + const directory = await mkdtemp(join(tmpdir(), "omp-history-branches-")); + const sessionFile = join(directory, "session.jsonl"); + const root = { id: "root", parentId: null }; + const earlyLeaf = { + id: "early", + parentId: "root", + message: { role: "user", content: "你好🌍".repeat(15000) }, + }; + const branch = { id: "branch", parentId: "root" }; + const active = { id: "active", parentId: "branch" }; + const duplicate = { + id: "branch", + parentId: "root", + message: { role: "user", content: "replacement" }, + }; + try { + await writeFile( + sessionFile, + [earlyLeaf, active, root, branch, duplicate] + .map((entry) => JSON.stringify(entry)) + .join("\n") + '\n{"id":', + ); + // The final record is a branch ancestor, not the active leaf. Latest duplicate wins + // when following parents; the invalid tail contributes neither a leaf nor a parent. + await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([root, duplicate, active]); + await expect(readActiveOmpEntryChain(sessionFile, "early")).resolves.toEqual([root, earlyLeaf]); + await expect(readActiveOmpEntryChain(sessionFile, "missing")).resolves.toEqual([ + root, + duplicate, + active, + ]); + const cycle = [ + { id: "a", parentId: "b" }, + { id: "b", parentId: "a" }, + ]; + await writeFile(sessionFile, cycle.map((entry) => JSON.stringify(entry)).join("\n")); + await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual(cycle); + await writeFile(sessionFile, "null\n{}\ninvalid\n"); + await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([]); + await expect(readActiveOmpEntryChain(directory)).rejects.toMatchObject({ code: "EISDIR" }); + await expect(readActiveOmpEntryChain(join(directory, "missing"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(readOmpHistoryTodoState(join(directory, "missing"))).resolves.toBeNull(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/omp/history.e2e.test.ts b/packages/server/src/server/agent/providers/omp/history.e2e.test.ts new file mode 100644 index 0000000000..f0a00d5a0d --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/history.e2e.test.ts @@ -0,0 +1,99 @@ +import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js"; +import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; +import { mkdtemp, open, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { setTimeout as delay } from "node:timers/promises"; +import { expect, test } from "vitest"; +import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../../../test-utils/daemon-client.js"; +import { readActiveOmpEntryChain } from "./history.js"; + +test("replays selected OMP history without blocking the event loop", async () => { + const directory = await mkdtemp(join(tmpdir(), "omp-history-budget-")); + const sessionFile = join(directory, "session.jsonl"); + const started = performance.now(); + try { + const daemon = await createTestPaseoDaemon({ + // This regression exercises file replay and daemon requests, not executable discovery. + providerOverrides: Object.fromEntries( + BUILTIN_PROVIDER_IDS.map((id) => [id, { enabled: false }]), + ), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.13.2", + }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.getDaemonStatus(); + const file = await open(sessionFile, "w"); + // Structured message blocks expose document-sized parsing at a modest byte size. + // Replay an earlier active entry so inactive payload retention cannot drive the probe. + const text = Array.from({ length: 64 }, () => ({ type: "text", text: "x" })); + let bytes = 0; + let count = 0; + try { + while (bytes < 28 * 1024 * 1024) { + const line = + JSON.stringify({ + type: "message", + id: `row-${count}`, + parentId: count ? `row-${count - 1}` : null, + message: { role: "user", content: text }, + }) + "\n"; + bytes += Buffer.byteLength(line); + await file.write(line); + count++; + } + } finally { + await file.close(); + } + let repliesDuringReplay = 0; + const { result: replayedEntries, blockingMs } = await measureMainThreadWork( + "OMP history main-thread work", + async () => { + const replayState = { finished: false }; + const replay = readActiveOmpEntryChain(sessionFile, "row-0").finally(() => { + replayState.finished = true; + }); + // A real WebSocket RPC served by the isolated daemon on the same event loop. + const requests = (async () => { + while (!replayState.finished) { + await client.getDaemonStatus(); + if (!replayState.finished) repliesDuringReplay++; + await delay(10); + } + })(); + const [entries] = await Promise.all([replay, requests]); + return entries; + }, + ); + console.log( + JSON.stringify({ + bytes, + count, + blockingMs, + repliesDuringReplay, + generationAndLoadMs: performance.now() - started, + }), + ); + expect(replayedEntries).toEqual([ + { + type: "message", + id: "row-0", + parentId: null, + message: { role: "user", content: text }, + }, + ]); + expect(blockingMs).toBeLessThan(50); + expect(repliesDuringReplay).toBeGreaterThan(0); + } finally { + await client.close().finally(() => daemon.close()); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}, 30000); diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index 2c818dfc5a..5102dfd44b 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -1,4 +1,5 @@ -import { readFile } from "node:fs/promises"; +import { open, type FileHandle } from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import { basename, extname, join } from "node:path"; import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; @@ -96,7 +97,9 @@ export async function* streamOmpHistory(input: { const messages: OmpAgentMessage[] = []; const replayEntries: OmpReplayEntry[] = []; const userEntries: OmpCapturedUserMessageEntry[] = []; + let prepared = 0; for (const entry of entries) { + if (++prepared % 32 === 0) await setImmediate(); if (entry.type === "compaction") { replayEntries.push({ entry, item: mapCompactionEntry(entry) }); continue; @@ -115,7 +118,9 @@ export async function* streamOmpHistory(input: { OMP_HISTORY_MAPPER_HOOKS, input.bridgedTools, ); + let replayed = 0; for (const replayEntry of replayEntries) { + if (++replayed % 32 === 0) await setImmediate(); const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp); const events: AgentStreamEvent[] = "item" in replayEntry @@ -329,34 +334,139 @@ function stripExtension(filePath: string): string { return extension ? filePath.slice(0, -extension.length) : filePath; } -export async function readActiveOmpEntryChain( - sessionFile: string, - activeEntryId?: string, -): Promise { - const content = await readFile(sessionFile, "utf8"); - const entries = content.split("\n").flatMap((line) => { - if (!line.trim()) return []; - try { - const value = JSON.parse(line) as OmpSessionEntry; - return value && typeof value === "object" && typeof value.id === "string" ? [value] : []; - } catch { - return []; +interface OmpEntryLocation { + id: string; + parentId?: string | null; + offset: number; +} + +// Yield after each bounded IO chunk, including chunks containing only tiny or invalid records. +// Keep bytes until a complete line so multi-byte UTF-8 characters survive chunk boundaries. +async function* readOmpLines( + file: FileHandle, + size: number, +): AsyncGenerator<{ + line: Buffer; + offset: number; +}> { + const chunkSize = 64 * 1024; + let fragments: Buffer[] = []; + let lineOffset = 0; + for (let position = 0; position < size; ) { + const chunk = Buffer.allocUnsafe(Math.min(chunkSize, size - position)); + const { bytesRead } = await file.read(chunk, 0, chunk.length, position); + if (!bytesRead) break; + let start = 0; + for (let index = 0; index < bytesRead; index++) { + if (chunk[index] !== 10) continue; + const tail = chunk.subarray(start, index); + const line = fragments.length ? Buffer.concat([...fragments, tail]) : tail; + yield { line, offset: lineOffset }; + fragments = []; + start = index + 1; + lineOffset = position + start; } - }); - if (entries.length === 0) return []; - const byId = new Map(entries.map((entry) => [entry.id!, entry])); - const parentIds = new Set(entries.flatMap((entry) => (entry.parentId ? [entry.parentId] : []))); - const leaves = entries.filter((entry) => !parentIds.has(entry.id!)); - let current: OmpSessionEntry | undefined = - (activeEntryId ? byId.get(activeEntryId) : undefined) ?? leaves.at(-1) ?? entries.at(-1); - const chain: OmpSessionEntry[] = []; + if (start < bytesRead) fragments.push(chunk.subarray(start, bytesRead)); + position += bytesRead; + await setImmediate(); + } + if (fragments.length) yield { line: Buffer.concat(fragments), offset: lineOffset }; +} + +function parseOmpEntry(line: Buffer): OmpSessionEntry | null { + try { + const value: unknown = JSON.parse(line.toString("utf8")); + return value && typeof value === "object" && typeof Reflect.get(value, "id") === "string" + ? (value as OmpSessionEntry) + : null; + } catch { + // OMP can leave a partial last record; malformed records have always been ignored. + return null; + } +} + +async function selectOmpChain( + locations: readonly OmpEntryLocation[], + byId: ReadonlyMap, + parentIds: ReadonlySet, + activeEntryId?: string, +): Promise { + let current = activeEntryId ? byId.get(activeEntryId) : undefined; + if (!current) { + for (let index = locations.length - 1; index >= 0; index--) { + const location = locations[index]!; + if (!parentIds.has(location.id)) { + current = location; + break; + } + if (index % 256 === 0) await setImmediate(); + } + current ??= locations.at(-1); + } + const chain: OmpEntryLocation[] = []; const seen = new Set(); while (current?.id && !seen.has(current.id)) { chain.push(current); seen.add(current.id); current = current.parentId ? byId.get(current.parentId) : undefined; + if (chain.length % 256 === 0) await setImmediate(); + } + return chain; +} + +export async function readActiveOmpEntryChain( + sessionFile: string, + activeEntryId?: string, +): Promise { + const file = await open(sessionFile, "r"); + try { + const stat = await file.stat(); + if (stat.isDirectory()) { + throw Object.assign( + new Error(`EISDIR: illegal operation on a directory, read '${sessionFile}'`), + { + code: "EISDIR", + }, + ); + } + // One descriptor and a fixed byte boundary exclude appends made while replay is yielding. + // Retain graph metadata only: abandoned branches must not retain their message payloads. + const locations: OmpEntryLocation[] = []; + const byId = new Map(); + const parentIds = new Set(); + for await (const { line, offset } of readOmpLines(file, stat.size)) { + const entry = parseOmpEntry(line); + if (!entry) continue; + const location = { id: entry.id!, parentId: entry.parentId, offset }; + locations.push(location); + byId.set(location.id, location); + if (location.parentId) parentIds.add(location.parentId); + } + const chain = await selectOmpChain(locations, byId, parentIds, activeEntryId); + if (chain.length === 0) return []; + const selected = new Map(); + for (let index = 0; index < chain.length; index++) { + selected.set(chain[index]!.offset, chain.length - index - 1); + if (index % 256 === 0) await setImmediate(); + } + const entries: OmpSessionEntry[] = []; + for await (const { line, offset } of readOmpLines(file, stat.size)) { + const index = selected.get(offset); + if (index === undefined) continue; + const entry = parseOmpEntry(line); + // Truncation in place invalidates the indexed snapshot; never publish a partial chain. + if (!entry || entry.id !== chain[chain.length - index - 1]!.id) { + throw new Error(`OMP transcript changed during replay: ${sessionFile}`); + } + entries[index] = entry; + selected.delete(offset); + if (selected.size === 0) break; + } + if (selected.size) throw new Error(`OMP transcript changed during replay: ${sessionFile}`); + return entries; + } finally { + await file.close(); } - return chain.toReversed(); } function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 733dedf571..cfe0b4cbd6 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -152,6 +152,8 @@ export const OmpSessionStateSchema = z thinkingLevel: OmpThinkingLevelSchema.optional(), isStreaming: z.boolean(), isCompacting: z.boolean(), + hasPendingAsyncWork: z.boolean().optional(), + isSettled: z.boolean().optional(), autoCompactionEnabled: z.boolean().optional(), fastModeEnabled: z.boolean().optional(), fastModeActive: z.boolean().optional(), diff --git a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts index 5164097661..6819292d33 100644 --- a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts +++ b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; +import { OmpSubagentCardTracker } from "./subagent-card-tracker.js"; import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js"; const PARENT_TOOL_CALL_ID = "task-1"; @@ -59,184 +59,84 @@ const PROGRESS: OmpSubagentProgressPayload[] = [ }, ]; -class ManualScheduler implements OmpSubagentCardScheduler { - private currentMs = 0; - private nextId = 1; - private readonly timers = new Map void }>(); - - now(): number { - return this.currentMs; - } - - setTimeout(callback: () => void, delayMs: number) { - const id = this.nextId; - this.nextId += 1; - this.timers.set(id, { dueMs: this.currentMs + delayMs, callback }); - return { token: id }; - } - - clearTimeout(timer: { token: unknown }): void { - if (typeof timer.token === "number") { - this.timers.delete(timer.token); - } - } - - advance(ms: number): void { - this.currentMs += ms; - const dueTimers = [...this.timers.entries()] - .filter(([, timer]) => timer.dueMs <= this.currentMs) - .sort((left, right) => left[1].dueMs - right[1].dueMs); - for (const [id, timer] of dueTimers) { - if (this.timers.delete(id)) { - timer.callback(); - } - } - } -} - describe("OmpSubagentCardTracker", () => { - test("folds lifecycle and progress into one throttled sub-agent detail", () => { - const scheduler = new ManualScheduler(); + test("keeps the card a launch marker while the child runs", () => { const emitted: string[] = []; - const tracker = new OmpSubagentCardTracker({ - scheduler, - emitToolCall: (toolCallId) => { - emitted.push(toolCallId); - return true; - }, - }); - tracker.handleLifecycle(LIFECYCLE); + const emit = (toolCallId: string) => { + emitted.push(toolCallId); + return true; + }; + const tracker = new OmpSubagentCardTracker(); + tracker.handleLifecycle(LIFECYCLE, emit); for (const payload of PROGRESS) { - tracker.handleProgress(payload); + tracker.handleProgress(payload, emit); } expect(emitted).toEqual([PARENT_TOOL_CALL_ID]); - const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, { - type: "sub_agent", - subAgentType: "task", - description: "Task arg description wins", - log: "", - }); - expect(detailBeforeTrailing).toEqual({ - type: "sub_agent", - subAgentType: "task", - description: "Task arg description wins", - childSessionId: SESSION_FILE, - log: [ - "EchoSubagent started", - "[bash] echo subagent-hi", - "[yield]", - "EchoSubagent completed", - ].join("\n"), - }); - - scheduler.advance(500); - - expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]); - }); - - test("aggregates batch progress streams into one index-prefixed log", () => { - const scheduler = new ManualScheduler(); - const emitted: string[] = []; - const tracker = new OmpSubagentCardTracker({ - scheduler, - emitToolCall: (toolCallId) => { - emitted.push(toolCallId); - return true; - }, - }); - - tracker.handleLifecycle({ - id: "Explore", - agent: "task", - description: "Inspect files", - status: "started", - sessionFile: "/tmp/one.jsonl", - parentToolCallId: "task.batch-1", - index: 0, - }); - scheduler.advance(600); - tracker.handleProgress({ - index: 5, - agent: "task", - task: "Run tests", - parentToolCallId: "task.batch-1", - progress: { - id: "Test", - status: "running", - description: "Run tests", - recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }], - }, - sessionFile: "/tmp/two.jsonl", - }); - - expect(emitted).toEqual(["task.batch-1", "task.batch-1"]); expect( - tracker.detailFor("task.batch-1", { + tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", - subAgentType: "batch", - actions: [], + subAgentType: "task", + description: "Task arg description wins", log: "", }), ).toEqual({ type: "sub_agent", - subAgentType: "batch", - description: "Inspect files", - childSessionId: "/tmp/one.jsonl", - log: "[1/6] Explore started\n[6/6] [bash] npm test", - actions: [], + subAgentType: "task", + description: "Task arg description wins", + childSessionId: SESSION_FILE, + log: "", }); }); - test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => { - const scheduler = new ManualScheduler(); + test("re-emits the card when a later event names the child", () => { const emitted: string[] = []; - const tracker = new OmpSubagentCardTracker({ - scheduler, - emitToolCall: (toolCallId) => { - emitted.push(toolCallId); - return true; + const emit = (toolCallId: string) => { + emitted.push(toolCallId); + return true; + }; + const tracker = new OmpSubagentCardTracker(); + tracker.handleLifecycle({ ...LIFECYCLE, description: undefined, sessionFile: undefined }, emit); + tracker.handleProgress(PROGRESS[0], emit); + + expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]); + expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({ + type: "sub_agent", + childSessionId: SESSION_FILE, + log: "", + }); + + tracker.delete(PARENT_TOOL_CALL_ID); + expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({ + type: "sub_agent", + log: "", + }); + }); + test("leaves the card alone when a later child of the same task starts", () => { + const emitted: string[] = []; + const emit = (toolCallId: string) => { + emitted.push(toolCallId); + return true; + }; + const tracker = new OmpSubagentCardTracker(); + tracker.handleLifecycle(LIFECYCLE, emit); + tracker.handleLifecycle( + { + ...LIFECYCLE, + id: "Second", + index: 1, + description: "Second child", + sessionFile: "/s.jsonl", }, + emit, + ); + + expect(emitted).toEqual([PARENT_TOOL_CALL_ID]); + expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({ + type: "sub_agent", + description: "Run echo in subagent", + childSessionId: SESSION_FILE, + log: "", }); - - tracker.handleLifecycle({ - id: "Explore", - agent: "task", - description: "Inspect files", - status: "started", - parentToolCallId: "task-1", - index: 0, - }); - scheduler.advance(100); - tracker.handleProgress({ - index: 0, - agent: "task", - task: "Inspect files", - parentToolCallId: "task-1", - progress: { - id: "Explore", - status: "running", - recentOutput: ["found target file"], - }, - }); - - expect(emitted).toEqual(["task-1"]); - expect( - tracker.detailFor("task-1", { - type: "sub_agent", - log: "", - }).log, - ).toBe("Explore started\nfound target file"); - - tracker.delete("task-1"); - scheduler.advance(500); - - expect(emitted).toEqual(["task-1"]); - expect( - tracker.detailFor("task-1", { - type: "sub_agent", - log: "static", - }).log, - ).toBe("static"); }); }); diff --git a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts index 7034c4c506..60ad291058 100644 --- a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts +++ b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts @@ -1,361 +1,106 @@ import type { ToolCallDetail } from "../../agent-sdk-types.js"; import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js"; -export interface OmpSubagentCardTimer { - readonly token: unknown; -} - -export interface OmpSubagentCardScheduler { - now(): number; - setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer; - clearTimeout(timer: OmpSubagentCardTimer): void; -} - -export interface OmpSubagentCardTrackerOptions { - scheduler?: OmpSubagentCardScheduler; - emitToolCall?: (toolCallId: string) => boolean; -} - -interface OmpSubagentLogLine { - key: string; - text: string; -} - -interface OmpSubagentCardItem { +interface OmpSubagentCardChild { index: number; description?: string; - agent?: string; - status?: "pending" | "running" | "completed" | "failed" | "aborted"; childSessionId?: string; - lines: OmpSubagentLogLine[]; - lineKeys: Set; -} - -interface OmpSubagentCardState { - items: Map; - emitToolCall: (toolCallId: string) => boolean; - lastEmitMs: number | null; - trailingTimer: OmpSubagentCardTimer | null; - dirty: boolean; -} - -const MAX_LOG_LINES_PER_ITEM = 200; -const MAX_LOG_LINE_CHARS = 240; -const THROTTLE_INTERVAL_MS = 500; - -export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler { - return { - now: () => Date.now(), - setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }), - clearTimeout: (timer) => { - clearTimeout(timer.token as ReturnType); - }, - }; } +/** + * Fills a task card's description and child session from OMP's sub-agent events. The card is a + * launch marker that names the task's first child; the children's activity belongs to their own + * timelines. + */ export class OmpSubagentCardTracker { - private readonly scheduler: OmpSubagentCardScheduler; - private readonly emitToolCall: (toolCallId: string) => boolean; - private readonly states = new Map(); - - constructor(options: OmpSubagentCardTrackerOptions) { - this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler(); - this.emitToolCall = options.emitToolCall ?? (() => false); - } + private readonly firstChildren = new Map(); handleLifecycle( payload: OmpSubagentLifecyclePayload, - emitToolCall: (toolCallId: string) => boolean = this.emitToolCall, + emitToolCall: (toolCallId: string) => boolean, ): void { - const parentToolCallId = readTrimmedString(payload.parentToolCallId); - if (!parentToolCallId) { - return; - } - this.stateFor(parentToolCallId).emitToolCall = emitToolCall; - const item = this.upsertItem(parentToolCallId, { - index: payload.index, - agent: payload.agent, - description: payload.description, - status: payload.status === "started" ? "running" : payload.status, - childSessionId: payload.sessionFile, - }); - this.appendLine( - item, - `lifecycle:${payload.status}:${payload.id}`, - `${payload.id} ${payload.status}`, + this.update( + payload.parentToolCallId, + { + index: payload.index, + description: payload.description, + childSessionId: payload.sessionFile, + }, + emitToolCall, ); - this.requestEmit(parentToolCallId); } handleProgress( payload: OmpSubagentProgressPayload, - emitToolCall: (toolCallId: string) => boolean = this.emitToolCall, + emitToolCall: (toolCallId: string) => boolean, ): void { - const parentToolCallId = readTrimmedString(payload.parentToolCallId); - if (!parentToolCallId) { - return; - } - this.stateFor(parentToolCallId).emitToolCall = emitToolCall; - const item = this.upsertItem(parentToolCallId, { - index: payload.index, - agent: payload.agent, - description: payload.progress.description, - status: payload.progress.status, - childSessionId: payload.sessionFile, - }); - - const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool)); - if (currentToolLine) { - this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text); - } - - for (const tool of readRecordArray(payload.progress.recentTools)) { - const line = summarizeTool(tool); - if (line) { - this.appendLine(item, `tool:${line.key}`, line.text); - } - } - - for (const output of readOutputLines(payload.progress.recentOutput)) { - this.appendLine(item, `output:${output}`, output); - } - - if (payload.progress.status !== "running") { - this.appendLine( - item, - `status:${payload.progress.status}:${payload.progress.id}`, - `${payload.progress.id} ${payload.progress.status}`, - ); - } - - this.requestEmit(parentToolCallId); + this.update( + payload.parentToolCallId, + { + index: payload.index, + description: payload.progress.description, + childSessionId: payload.sessionFile, + }, + emitToolCall, + ); } detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail { if (baseDetail.type !== "sub_agent") { return baseDetail; } - const state = this.states.get(toolCallId); - if (!state) { + const first = this.firstChildren.get(toolCallId); + if (!first) { return baseDetail; } - const items = [...state.items.values()].sort((left, right) => left.index - right.index); - const firstItem = items[0]; - if (!firstItem) { - return baseDetail; - } - - const detail: ToolCallDetail = { - type: "sub_agent", - ...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}), - ...((baseDetail.description ?? firstItem.description) - ? { description: baseDetail.description ?? firstItem.description } - : {}), - ...((firstItem.childSessionId ?? baseDetail.childSessionId) - ? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId } - : {}), - log: buildLog(items, baseDetail.log), + const description = baseDetail.description ?? first.description; + const childSessionId = first.childSessionId ?? baseDetail.childSessionId; + return { + ...baseDetail, + ...(description ? { description } : {}), + ...(childSessionId ? { childSessionId } : {}), }; - return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail; - } - - flush(toolCallId: string): void { - const state = this.states.get(toolCallId); - if (!state || !state.dirty) { - return; - } - this.emitNow(toolCallId, state); } delete(toolCallId: string): void { - const state = this.states.get(toolCallId); - if (state?.trailingTimer) { - this.scheduler.clearTimeout(state.trailingTimer); - } - this.states.delete(toolCallId); + this.firstChildren.delete(toolCallId); } clear(): void { - for (const toolCallId of this.states.keys()) { - this.delete(toolCallId); - } + this.firstChildren.clear(); } - private upsertItem( - parentToolCallId: string, - input: { - index: number; - agent?: string; - status?: "pending" | "running" | "completed" | "failed" | "aborted"; - description?: string; - childSessionId?: string; - }, - ): OmpSubagentCardItem { - const state = this.stateFor(parentToolCallId); - const existing = state.items.get(input.index); - if (existing) { - existing.agent = readTrimmedString(input.agent) ?? existing.agent; - existing.status = input.status ?? existing.status; - existing.description = readTrimmedString(input.description) ?? existing.description; - existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId; - return existing; + private update( + parentToolCallId: string | undefined, + input: { index: number; description?: string; childSessionId?: string }, + emitToolCall: (toolCallId: string) => boolean, + ): void { + const toolCallId = readTrimmedString(parentToolCallId); + if (!toolCallId) { + return; } - - const item: OmpSubagentCardItem = { + const first = this.firstChildren.get(toolCallId); + if (first && input.index > first.index) { + return; + } + const existing = first?.index === input.index ? first : undefined; + const next: OmpSubagentCardChild = { index: input.index, - lines: [], - lineKeys: new Set(), - ...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}), - ...(input.status ? { status: input.status } : {}), - ...(readTrimmedString(input.description) - ? { description: readTrimmedString(input.description) } - : {}), - ...(readTrimmedString(input.childSessionId) - ? { childSessionId: readTrimmedString(input.childSessionId) } - : {}), + description: readTrimmedString(input.description) ?? existing?.description, + childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId, }; - state.items.set(input.index, item); - return item; - } - - private stateFor(parentToolCallId: string): OmpSubagentCardState { - const existing = this.states.get(parentToolCallId); - if (existing) { - return existing; - } - const state: OmpSubagentCardState = { - items: new Map(), - emitToolCall: this.emitToolCall, - lastEmitMs: null, - trailingTimer: null, - dirty: false, - }; - this.states.set(parentToolCallId, state); - return state; - } - - private appendLine(item: OmpSubagentCardItem, key: string, text: string): void { - const normalizedText = normalizeLine(text); - if (!normalizedText || item.lineKeys.has(key)) { + if ( + existing && + existing.description === next.description && + existing.childSessionId === next.childSessionId + ) { return; } - item.lines.push({ key, text: normalizedText }); - item.lineKeys.add(key); - while (item.lines.length > MAX_LOG_LINES_PER_ITEM) { - const removed = item.lines.shift(); - if (removed) { - item.lineKeys.delete(removed.key); - } - } + this.firstChildren.set(toolCallId, next); + emitToolCall(toolCallId); } - - private requestEmit(toolCallId: string): void { - const state = this.stateFor(toolCallId); - const now = this.scheduler.now(); - const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs; - if (elapsedMs >= THROTTLE_INTERVAL_MS) { - this.emitNow(toolCallId, state); - return; - } - - state.dirty = true; - if (state.trailingTimer) { - return; - } - state.trailingTimer = this.scheduler.setTimeout(() => { - state.trailingTimer = null; - if (state.dirty) { - this.emitNow(toolCallId, state); - } - }, THROTTLE_INTERVAL_MS - elapsedMs); - } - - private emitNow(toolCallId: string, state: OmpSubagentCardState): void { - if (state.trailingTimer) { - this.scheduler.clearTimeout(state.trailingTimer); - state.trailingTimer = null; - } - state.dirty = false; - state.lastEmitMs = this.scheduler.now(); - state.emitToolCall(toolCallId); - } -} - -function buildLog(items: OmpSubagentCardItem[], fallback: string): string { - const itemCount = Math.max(...items.map((item) => item.index)) + 1; - const lines = items.flatMap((item) => { - const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : ""; - return item.lines.map((line) => `${prefix}${line.text}`); - }); - return lines.length > 0 ? lines.join("\n") : fallback; -} - -function summarizeTool(tool: Record | null): OmpSubagentLogLine | null { - if (!tool) { - return null; - } - const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name); - if (!toolName) { - return null; - } - const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input); - const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : ""; - return { - key: `${toolName}:${args ?? ""}${endMs}`, - text: args ? `[${toolName}] ${args}` : `[${toolName}]`, - }; -} - -function readOutputLines(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.flatMap((item) => { - const text = - readTrimmedString(item) ?? - (isRecord(item) - ? (readTrimmedString(item.text) ?? - readTrimmedString(item.output) ?? - readTrimmedString(item.content)) - : undefined); - return text ? [text] : []; - }); -} - -function readRecord(value: unknown): Record | null { - return isRecord(value) ? value : null; -} - -function readRecordArray(value: unknown): Record[] { - return Array.isArray(value) ? value.filter(isRecord) : []; -} - -function summarizeUnknown(value: unknown): string | undefined { - if (value === undefined || value === null) { - return undefined; - } - if (typeof value === "string") { - return readTrimmedString(value); - } - const json = JSON.stringify(value); - return typeof json === "string" ? normalizeLine(json) : undefined; -} - -function normalizeLine(value: string): string | undefined { - const normalized = value.trim().replace(/\s+/g, " "); - if (!normalized) { - return undefined; - } - if (normalized.length <= MAX_LOG_LINE_CHARS) { - return normalized; - } - return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`; } function readTrimmedString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts index ae4c0d0d77..cc13f9001f 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts @@ -85,7 +85,7 @@ describe("OMP tool call mapper", () => { expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull(); }); - test("uses task result text and transcript path as the best static replay detail", () => { + test("takes the child session from the task transcript path", () => { expect( mapOmpToolDetail( parseToolArgs("task", { @@ -106,7 +106,7 @@ describe("OMP tool call mapper", () => { subAgentType: "explore", description: "Inspect the target files", childSessionId: "/tmp/omp-task-static/Explore.jsonl", - log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl", + log: "", }); }); diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts index 86b1ee53ef..500123758a 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts @@ -179,7 +179,6 @@ const OPAQUE_TEXT_TOOLS = new Set([ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail { const argRecord = isRecord(args) ? args : {}; const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined; - const resultText = extractTextFromToolResult(result); const childSessionId = readChildSessionId(result); return { type: "sub_agent", @@ -200,7 +199,8 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail argRecord.assignment, ), ...(childSessionId ? { childSessionId } : {}), - log: resultText?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; } 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 d6b6c5513a..8beed24be0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -7010,3 +7010,21 @@ describe("OpenCode session permission rules", () => { } }); }); + +test("OpenCode v1 runtime safety retains terminals and busy sessions on its shared server", async () => { + const { parent, openCode } = await createParentSession("runtime-safety"); + try { + expect(await parent.canStopRuntime!()).toBe(true); + openCode.ptyListResponse = { data: [{ id: "shell", status: "running" }] }; + expect(await parent.canStopRuntime!()).toBe(false); + openCode.ptyListResponse = { data: [] }; + openCode.sessionStatusResponse = { data: { "other-native-session": { type: "busy" } } }; + expect(await parent.canStopRuntime!()).toBe(false); + openCode.sessionStatusResponse = { data: {} }; + expect(await parent.canStopRuntime!()).toBe(true); + openCode.ptyListResponse = { error: "Inventory unavailable" }; + await expect(parent.canStopRuntime!()).rejects.toThrow("Unable to inspect OpenCode terminals"); + } finally { + await parent.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 7e49bc50d3..6fef290d91 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -4846,15 +4846,6 @@ class OpenCodeAgentSession implements AgentSession { ...item, status: "failed", error, - detail: - item.detail.type === "sub_agent" - ? { - ...item.detail, - log: [item.detail.log, error.message] - .filter((entry) => entry.trim().length > 0) - .join("\n"), - } - : item.detail, }, }, turnId, @@ -5055,6 +5046,43 @@ class OpenCodeAgentSession implements AgentSession { }; } + async canStopRuntime(): Promise { + if ( + this.closed || + this.serverExited || + this.reconnection || + this.turnState.status !== "idle" || + this.runningToolCalls.size || + this.pendingPermissions.size || + this.pendingSteerSubmissions.length + ) + return false; + const request = { signal: AbortSignal.timeout(10_000) }; + const family = new Map([[this.sessionId, this.config.cwd]]); + const inspected = new Set(); + for (const [sessionId, directory] of family) { + if (!inspected.has(directory)) { + const statuses = await this.client.session.status({ directory }, request); + if (statuses.error || !statuses.data) + throw new Error("Unable to inspect OpenCode executions"); + if (Object.values(statuses.data).some((status) => status.type !== "idle")) return false; + const terminals = await this.client.pty.list({ directory }, request); + if (terminals.error || !terminals.data) + throw new Error("Unable to inspect OpenCode terminals"); + if (terminals.data.some((terminal) => terminal.status === "running")) return false; + inspected.add(directory); + } + const response = await this.client.session.children( + { sessionID: sessionId, directory }, + request, + ); + const children = readOpenCodeChildSessionInfosFromResponse(response); + if (!children) throw new Error("Unable to inspect OpenCode child sessions"); + for (const child of children) family.set(child.id, child.directory ?? directory); + } + return true; + } + async close(): Promise { try { this.closed = true; diff --git a/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts b/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts index 133ac3c992..6cb15ccb6d 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge-adapter.test.ts @@ -80,8 +80,12 @@ describe("OpenCode bridge adapter", () => { await rm(paseoHome, { recursive: true, force: true }); }); const runtime = new TestOpenCodeHarness(); - runtime.enqueueClient(new TestOpenCodeClient()); - runtime.enqueueClient(new TestOpenCodeClient()); + const envClient = new TestOpenCodeClient(); + const mcpClient = new TestOpenCodeClient(); + envClient.sessionCreateResponse = { data: { id: "ses_env" } }; + mcpClient.sessionCreateResponse = { data: { id: "ses_mcp" } }; + runtime.enqueueClient(envClient); + runtime.enqueueClient(mcpClient); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { serverManager: runtime, createClient: runtime.createClient, @@ -102,6 +106,12 @@ describe("OpenCode bridge adapter", () => { ); expect(runtime.acquisitions.map(({ kind }) => kind)).toEqual(["dedicated", "dedicated"]); + await expect(readBridgeContext(bridge, "ses_env")).resolves.toEqual({ + env: { PASEO_AGENT_ID: "one", CUSTOM_TOKEN: "secret" }, + }); + await expect(readBridgeContext(bridge, "ses_mcp")).resolves.toEqual({ + env: { PASEO_AGENT_ID: "two" }, + }); await customEnv.close(); await customMcp.close(); }); diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index 843ef21ebb..749374b29f 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -455,9 +455,25 @@ test.each([ let original: Awaited> | undefined; let resumed: Awaited> | undefined; try { + // Windows invokes the platform binary directly. Installing its wrapper also runs an + // unused postinstall binary probe, which has no timeout and can hold the fixture open. + const installPackage = + process.platform === "win32" + ? `${windowsPackage}-${process.arch}@${cliPackage.slice(cliPackage.lastIndexOf("@") + 1)}` + : cliPackage; + // npm abandons a stalled registry download only after its 5 minute default, past this + // test's budget, and the killed install keeps the fixture locked. Retry stalls sooner. await execCommand( "npm", - ["install", "--prefix", root, "--no-audit", "--no-fund", cliPackage], + [ + "install", + "--prefix", + root, + "--no-audit", + "--no-fund", + "--fetch-timeout=30000", + installPackage, + ], { cwd: root, timeout: 180_000, diff --git a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts index 1396d1f784..277cad58fe 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts @@ -56,6 +56,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as getDaemonTcpHost: null, serviceProxyPublicBaseUrl: null, resolveScriptHealth: null, + getGlobalServicePorts: async () => undefined, logger, emit: () => {}, publishStatusUpdate: () => {}, diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 33f43c4f79..468baabe14 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -20,6 +20,7 @@ import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tool import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js"; import { OpenCodeBridge, + decorateOpenCodeV2Env, loadOpenCodeBridgePluginArtifact, materializeOpenCodeV2Plugin, } from "./bridge.js"; @@ -137,33 +138,28 @@ describe("OpenCodeBridge", () => { }; expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true); - const v2Config = z - .object({ plugins: z.array(z.object({ package: z.string() })) }) - .parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT)); - const v2Module: { - default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; - } = await import( - pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href - ); const tools = new Map(); - const dispose = await v2Module.default.setup({ - options: plugin, - tool: { - transform: async (transform) => { - transform({ - add: (tool) => { - tools.set(tool.name, tool); - }, - }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "bound" }), + hook: async () => ({ dispose: async () => undefined }), }, }, - session: { - context: async () => [], - get: async () => ({ parentID: "bound" }), - hook: async () => ({ dispose: async () => undefined }), - }, - }); + ["bound"], + ); try { const created = await tools .get("paseo_create_agent")! @@ -402,45 +398,35 @@ describe("OpenCodeBridge", () => { const release = bridge.bindSession({ sessionId: "parent", env: {}, tools: catalog }); const releaseDisabled = bridge.bindSession({ sessionId: "disabled", env: {} }); try { - const env = await bridge.decorateV2ServerEnv({}); - const config = z - .object({ - plugins: z.array( - z.object({ - package: z.string(), - options: z.object({ baseUrl: z.string(), token: z.string() }), - }), - ), - }) - .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); - expect(await bridge.decorateV2ServerEnv(env)).toEqual(env); - const plugin = config.plugins[0]!; + const pluginUrl = await bridge.materializeV2Plugin(); + const env = decorateOpenCodeV2Env({}, pluginUrl); + expect(decorateOpenCodeV2Env(env, pluginUrl)).toEqual(env); const tools = new Map(); let filter!: (input: V2TestContext) => Promise; - const module: { - default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; - } = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); - const dispose = await module.default.setup({ - options: plugin.options, - tool: { - transform: async (transform) => { - transform({ - add: (tool) => { - tools.set(tool.name, tool); - }, - }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async ({ sessionID }) => ({ parentID: sessionID === "child" ? "parent" : "" }), + hook: async (_name, callback) => { + filter = callback; + return { dispose: async () => undefined }; + }, }, }, - session: { - context: async () => [], - get: async () => ({ parentID: "parent" }), - hook: async (_name, callback) => { - filter = callback; - return { dispose: async () => undefined }; - }, - }, - }); + ["parent", "disabled"], + ); const allowed: V2TestContext = { sessionID: "child", tools: { paseo_echo_context: {}, native: {} }, @@ -453,6 +439,12 @@ describe("OpenCodeBridge", () => { }; await filter(disabled); expect(Object.keys(disabled.tools)).toEqual(["native"]); + const unrelated: V2TestContext = { + sessionID: "unrelated", + tools: { paseo_echo_context: {}, native: {} }, + }; + await filter(unrelated); + expect(Object.keys(unrelated.tools)).toEqual(["native"]); await expect( tools.get("paseo_echo_context")!.execute({ value: "child result" }, { sessionID: "child" }), ).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] }); @@ -486,35 +478,33 @@ describe("OpenCodeBridge", () => { await bridge.start(); const release = bridge.bindSession({ sessionId: "caller", env: {}, tools: catalog }); try { - const env = await bridge.decorateV2ServerEnv({}); - const { plugins } = JSON.parse(env.OPENCODE_CONFIG_CONTENT); - const plugin = plugins[0]; - const module = await import( - pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href - ); + const options = bridge.sharedV2Connection(); const tools = new Map(); - const dispose = await module.default.setup({ - options: plugin.options, - tool: { - transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => { - transform({ add: (tool) => tools.set(tool.name, tool) }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => { + transform({ add: (tool) => tools.set(tool.name, tool) }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "" }), + hook: async () => ({ dispose: async () => undefined }), }, }, - session: { - context: async () => [], - get: async () => ({ parentID: "" }), - hook: async () => ({ dispose: async () => undefined }), - }, - } satisfies V2TestPluginContext); + ["caller"], + ); try { const input = { provider: "codex", title: "Invalid provider", initialPrompt: "Hello" }; const response = await fetch( - `${plugin.options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`, + `${options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`, { method: "POST", headers: { - Authorization: `Bearer ${plugin.options.token}`, + Authorization: `Bearer ${options.token}`, "Content-Type": "application/json", }, body: JSON.stringify(input), @@ -572,35 +562,24 @@ describe("OpenCodeBridge", () => { await bridge.start(); const release = bridge.bindSession({ sessionId: "session", env: {}, tools: catalog }); try { - const env = await bridge.decorateV2ServerEnv({}); - const plugin = z - .object({ - plugins: z.array( - z.object({ - package: z.string(), - options: z.object({ baseUrl: z.string(), token: z.string() }), - }), - ), - }) - .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)).plugins[0]!; const pluginTools = new Map(); - const module: { - default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; - } = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); - const dispose = await module.default.setup({ - options: plugin.options, - tool: { - transform: async (transform) => { - transform({ add: (tool) => pluginTools.set(tool.name, tool) }); - return { dispose: async () => undefined }; + const dispose = await setupBoundV2Plugin( + bridge, + { + tool: { + transform: async (transform) => { + transform({ add: (tool) => pluginTools.set(tool.name, tool) }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: undefined }), + hook: async () => ({ dispose: async () => undefined }), }, }, - session: { - context: async () => [], - get: async () => ({ parentID: undefined }), - hook: async () => ({ dispose: async () => undefined }), - }, - }); + ["session"], + ); const result = await pluginTools .get("paseo_browser_screenshot")! .execute({}, { sessionID: "session" }); @@ -642,7 +621,8 @@ describe("OpenCodeBridge", () => { const tools = new Map(); let hook!: (input: V2TestContext) => Promise; const dispose = await module.default.setup({ - options: { baseUrl: "", token: "" }, + options: { mode: "structured-only" }, + location: { directory: root }, tool: { transform: async (transform) => { transform({ @@ -657,7 +637,7 @@ describe("OpenCodeBridge", () => { context: async () => history, get: async () => ({ parentID: "parent" }), hook: async (_name, callback) => { - hook = callback; + hook ??= callback; return { dispose: async () => undefined }; }, }, @@ -701,9 +681,13 @@ describe("OpenCodeBridge", () => { name.startsWith("paseo-v2-"), ), ).toEqual([]); - const env = await bridge.decorateV2ServerEnv({ - OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }), - }); + const pluginUrl = await bridge.materializeV2Plugin(); + const env = decorateOpenCodeV2Env( + { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }), + }, + pluginUrl, + ); const config = z .object({ plugins: z.tuple([z.literal("user-plugin"), z.object({ package: z.string() })]) }) .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); @@ -711,6 +695,9 @@ describe("OpenCodeBridge", () => { expect((await stat(directory)).isDirectory()).toBe(true); const entry = await readFile(path.join(directory, "server.js"), "utf8"); expect(entry).toContain('id: "paseo"'); + expect(await readFile(path.join(directory, "state.mjs"), "utf8")).toBe( + await readFile(new URL("./v2/state.mjs", import.meta.url), "utf8"), + ); const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); expect(manifest).toMatchObject({ type: "module", exports: { "./server": "./server.js" } }); } finally { @@ -856,7 +843,8 @@ interface V2TestContext { system?: Array<{ type: "text"; text: string }>; } interface V2TestPluginContext { - options: { baseUrl: string; token: string }; + options: Record; + location?: { directory: string }; tool: { transform( callback: (editor: { add(tool: V2TestTool): void }) => void, @@ -864,10 +852,33 @@ interface V2TestPluginContext { }; session: { context(input: { sessionID: string }): Promise; - get(input: { sessionID: string }): Promise<{ parentID: string }>; + get(input: { sessionID: string }): Promise<{ parentID: string | undefined }>; hook( name: string, callback: (input: V2TestContext) => Promise, ): Promise<{ dispose(): Promise }>; }; } + +async function setupBoundV2Plugin( + bridge: OpenCodeBridge, + context: Pick, + sessionIds: string[], +): Promise<() => Promise> { + const directory = fileURLToPath(await bridge.materializeV2Plugin()); + const module = await import(pathToFileURL(path.join(directory, "server.js")).href); + let bind!: (input: { sessionID: string; baseUrl: string; token: string }) => Promise; + const dispose: () => Promise = await module.default.setup({ + ...context, + options: {}, + location: { directory }, + rpc: { + register: async (_definition: unknown, handlers: { bind: typeof bind }) => { + bind = handlers.bind; + return { dispose: async () => undefined }; + }, + }, + }); + for (const sessionID of sessionIds) await bind({ sessionID, ...bridge.sharedV2Connection() }); + return dispose; +} diff --git a/packages/server/src/server/agent/providers/opencode/bridge.ts b/packages/server/src/server/agent/providers/opencode/bridge.ts index 7abe34475d..87ee07798b 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.ts @@ -30,6 +30,7 @@ class OpenCodeBridgeClientError extends Error { interface OpenCodeBridgeOptions { paseoHome: string; logger: Logger; + port?: number; } interface OpenCodeSessionBinding { @@ -54,6 +55,7 @@ interface OpenCodeConfig { export class OpenCodeBridge { private readonly paseoHome: string; private readonly logger: Logger; + private readonly port: number; private readonly token = randomBytes(32).toString("hex"); private readonly sessions = new Map(); private server: Server | null = null; @@ -65,6 +67,7 @@ export class OpenCodeBridge { constructor(options: OpenCodeBridgeOptions) { this.paseoHome = options.paseoHome; this.logger = options.logger.child({ module: "agent", component: "opencode-bridge" }); + this.port = options.port ?? 0; } async start(): Promise { @@ -75,7 +78,7 @@ export class OpenCodeBridge { }); await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => { + server.listen(this.port, "127.0.0.1", () => { server.off("error", reject); resolve(); }); @@ -94,6 +97,8 @@ export class OpenCodeBridge { } bindSession(input: BindOpenCodeSessionInput): () => void { + if (this.sessions.has(input.sessionId)) + throw new Error(`OpenCode session ${input.sessionId} is already bound to a Paseo agent`); const binding: OpenCodeSessionBinding = { env: { ...input.env }, ...(input.tools ? { tools: input.tools } : {}), @@ -127,12 +132,13 @@ export class OpenCodeBridge { }; } - async decorateV2ServerEnv(env: Record): Promise> { + async materializeV2Plugin(): Promise { this.v2PluginUrl ??= await this.materializePlugin(2); - return decorateOpenCodeV2Env(env, this.v2PluginUrl, { - baseUrl: this.requireBaseUrl(), - token: this.token, - }); + return this.v2PluginUrl; + } + + sharedV2Connection(baseUrl = this.requireBaseUrl()): OpenCodePluginOptions { + return { baseUrl, token: this.token }; } async close(): Promise { @@ -282,6 +288,8 @@ export async function materializeOpenCodeV2Plugin(paseoHome: string): Promise { }); describe("OpenCode v2 history reconciliation", () => { - it("keeps client message identity on live echoes but omits it from history", () => { + it("keeps client message identity on live echoes but omits it from history", async () => { const user: SessionMessageUser = { id: "native-message", type: "user", @@ -1568,13 +1569,13 @@ describe("OpenCode v2 history reconciliation", () => { time: { created: 1 }, metadata: { paseoClientMessageId: "client-message" }, }; - expect(new V2Timeline().messages([user])).toMatchObject([ + expect(await collectTimelineSnapshot(new V2Timeline(), [user])).toMatchObject([ { item: { clientMessageId: "client-message", messageId: "native-message" } }, ]); - expect(new V2Timeline(false).messages([user])).toMatchObject([ + expect(await collectTimelineSnapshot(new V2Timeline(false), [user])).toMatchObject([ { item: { type: "user_message", messageId: "native-message" } }, ]); - const [event] = new V2Timeline(false).messages([user]); + const [event] = await collectTimelineSnapshot(new V2Timeline(false), [user]); expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId"); }); function assistant(text: string): SessionMessageAssistant { @@ -1587,31 +1588,35 @@ describe("OpenCode v2 history reconciliation", () => { content: [{ type: "text", text }], }; } - it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => { + it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", async () => { const timeline = new V2Timeline(); - const text = (messages: ReturnType) => + const text = (messages: Awaited>) => messages.flatMap((event) => event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], ); - expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]); - expect(timeline.messages([assistant("hel")])).toEqual([]); - expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]); - expect(timeline.messages([assistant("hello world")])).toEqual([]); + expect(text(await collectTimelineSnapshot(timeline, [assistant("hello")]))).toEqual(["hello"]); + expect(await collectTimelineSnapshot(timeline, [assistant("hel")])).toEqual([]); + expect(text(await collectTimelineSnapshot(timeline, [assistant("hello world")]))).toEqual([ + " world", + ]); + expect(await collectTimelineSnapshot(timeline, [assistant("hello world")])).toEqual([]); }); - it("keeps reasoning separate from assistant output", () => { + it("keeps reasoning separate from assistant output", async () => { const timeline = new V2Timeline(); const message = assistant("answer"); message.content.unshift({ type: "reasoning", text: "thinking" }); expect( - timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)), + (await collectTimelineSnapshot(timeline, [message])).map((event) => + event.type === "timeline" ? event.item : null, + ), ).toEqual([ { type: "reasoning", text: "thinking" }, { type: "assistant_message", text: "answer", messageId: "message" }, ]); }); - it("emits tool state transitions once and keeps their call identity", () => { + it("emits tool state transitions once and keeps their call identity", async () => { const timeline = new V2Timeline(); const message = assistant(""); message.content = [ @@ -1623,7 +1628,7 @@ describe("OpenCode v2 history reconciliation", () => { state: { status: "running", input: { command: "pwd" }, metadata: {} }, }, ]; - expect(timeline.messages([message])).toMatchObject([ + expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([ { item: { type: "tool_call", callId: "call", status: "running" } }, ]); message.content = [ @@ -1639,9 +1644,9 @@ describe("OpenCode v2 history reconciliation", () => { }, }, ]; - expect(timeline.messages([message])).toMatchObject([ + expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([ { item: { type: "tool_call", callId: "call", status: "completed" } }, ]); - expect(timeline.messages([message])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [message])).toEqual([]); }); }); diff --git a/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts b/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts index b1ee5be697..07f312da0a 100644 --- a/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts +++ b/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts @@ -66,6 +66,7 @@ test("retries a rejected version probe after the configured binary changes", asy const script = join(root, "version.cjs"); const client = new OpenCodeRuntimeClient(createTestLogger(), { command: { mode: "replace", argv: [process.execPath, script] }, + env: { XDG_STATE_HOME: root }, }); try { await writeFile(script, 'console.log("opencode v2.0.3")'); @@ -95,6 +96,7 @@ test.each([ const script = join(root, "version.cjs"); const client = new OpenCodeRuntimeClient(createTestLogger(), { command: { mode: "replace", argv: [process.execPath, script] }, + env: { XDG_STATE_HOME: root }, }); try { await writeFile(script, initialSource); @@ -193,6 +195,7 @@ test.each([ logger, { command: { mode: "replace", argv: [process.execPath, script] }, + env: { XDG_STATE_HOME: root }, }, { serverManager: new TrackedServerManager({ logger }) }, ); @@ -209,3 +212,38 @@ test.each([ }, 10000, ); + +test("selects v2 without probing the binary when an external server is configured", async () => { + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const client = new OpenCodeRuntimeClient(createTestLogger(), { + command: { mode: "replace", argv: [process.execPath, "-e", "process.exit(1)"] }, + env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" }, + }); + try { + const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" }); + expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]); + } finally { + await client.shutdown(); + } +}); + +test("keeps native Paseo tools available when an external server is configured", async () => { + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const { OpenCodeBridge } = await import("./bridge.js"); + const bridge = new OpenCodeBridge({ paseoHome: "/tmp", logger: createTestLogger() }); + const external = new OpenCodeRuntimeClient( + createTestLogger(), + { env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" } }, + { bridge }, + ); + const local = new OpenCodeRuntimeClient(createTestLogger(), undefined, { bridge }); + try { + expect(external.capabilities.supportsNativePaseoTools).toBe(true); + expect(local.capabilities.supportsNativePaseoTools).toBe(true); + } finally { + await external.shutdown(); + await local.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/runtime-client.ts b/packages/server/src/server/agent/providers/opencode/runtime-client.ts index 2a34abe35d..e5fa17021a 100644 --- a/packages/server/src/server/agent/providers/opencode/runtime-client.ts +++ b/packages/server/src/server/agent/providers/opencode/runtime-client.ts @@ -19,7 +19,9 @@ import { import { execCommand } from "../../../../utils/spawn.js"; import { OpenCodeAgentClient } from "../opencode-agent.js"; import type { OpenCodeV2AgentClient } from "./v2/agent.js"; +import { resolveExternalOpenCodeServer } from "./v2/external-server.js"; import { withOpenCodeRuntimeNotice } from "./runtime-notice.js"; +import { selectOpenCodeService } from "./v2/service-selection.js"; // Keep the minimum aligned with the SDK and binary exercised by CI. const MINIMUM_V2: readonly [number, number] = [0, 10]; @@ -67,8 +69,25 @@ export class OpenCodeRuntimeClient implements AgentClient { this.resolveCreateConfig = this.legacy.resolveCreateConfig; this.isCreateConfigUnattended = this.legacy.isCreateConfigUnattended; } + private selectService() { + return selectOpenCodeService({ + explicit: resolveExternalOpenCodeServer({ ...globalThis.process.env, ...this.settings?.env }), + env: this.settings?.env, + }); + } private client(): Promise { this.selected ??= (async () => { + const selection = await this.selectService(); + if (selection.kind !== "owned") { + const { OpenCodeV2AgentClient } = await import("./v2/agent.js"); + return new OpenCodeV2AgentClient({ + logger: this.logger, + settings: this.settings, + managedProcesses: this.options.managedProcesses, + bridge: this.options.bridge, + selection, + }); + } let output: string; try { const launch = await resolveProviderLaunch({ @@ -97,6 +116,7 @@ export class OpenCodeRuntimeClient implements AgentClient { settings: this.settings, managedProcesses: this.options.managedProcesses, bridge: this.options.bridge, + selection, }); })().catch((error: unknown) => { // A failed probe must not poison refresh after the user updates the binary. @@ -106,9 +126,14 @@ export class OpenCodeRuntimeClient implements AgentClient { return this.selected; } async isAvailable() { + const selection = await this.selectService(); + if (selection.kind !== "owned") return true; return this.legacy.isAvailable(); } async getDiagnostic() { + const selection = await this.selectService(); + if (selection.kind !== "owned") + return { diagnostic: `OpenCode server: ${selection.server.url}` }; return this.legacy.getDiagnostic(); } async createSession( diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 7bad72b8b1..f9c1683a48 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -170,6 +170,7 @@ export class TestOpenCodeClient { null; sessionPromptAsyncEvents: unknown[] = [idleEvent()]; sessionPromptAsyncResponse: OpenCodeResponse = {}; + ptyListResponse: OpenCodeResponse = { data: [] }; sessionStatusResponse: OpenCodeResponse = { data: {} }; sessionStatusImplementation: | ((parameters: unknown, options: unknown) => Promise) @@ -195,6 +196,7 @@ export class TestOpenCodeClient { asSdkClient(): OpencodeClient { return { + pty: { list: async () => this.ptyListResponse }, app: { agents: async (parameters: unknown, options: unknown) => { this.calls.appAgents.push(parameters); diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts index e2dc4f68a6..eb06928e63 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts @@ -90,6 +90,9 @@ export class V2Harness { readonly api: V2Api = { server: { info: unexpected }, + shell: { + list: async () => ({ location: { directory: this.info.location.directory }, data: [] }), + }, plugin: { list: async () => ({ location: { directory: this.info.location.directory }, @@ -131,6 +134,7 @@ export class V2Harness { }, permission: { list: async () => [], reply: unexpected }, session: { + inbox: { list: async () => [] }, form: { list: async () => [], reply: unexpected, cancel: unexpected }, create: async (input = {}) => { this.creates.push(input); @@ -168,6 +172,9 @@ export class V2Harness { event: { subscribe: (options) => this.events(options?.signal) }, }; readonly connection: V2Connection = { + bindSession: async () => {}, + unbindSession: async () => {}, + refreshLocation: async () => {}, client: this.api, release: async () => { this.releases += 1; diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts index d9d254d9de..98cd79526a 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "./v2-timeline.js"; import { TimelineProjection } from "../../../timeline-projection.js"; import { V2Timeline } from "../v2/timeline.js"; @@ -21,16 +22,18 @@ for (let index = 0; index < chunks; index++) { const event = mode === "delta" ? timeline.delta({ ...part, delta }) - : timeline.messages([ - { - id: "answer", - type: "assistant", - agent: "build", - model: { providerID: "test", id: "model" }, - time: { created: 2 }, - content: [{ type: "reasoning", text }], - }, - ])[0]; + : ( + await collectTimelineSnapshot(timeline, [ + { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "reasoning", text }], + }, + ]) + )[0]; if (event?.type !== "timeline") throw new Error("Expected a timeline event"); projection.append({ seq: index + 1, diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-timeline.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-timeline.ts new file mode 100644 index 0000000000..e7aa552479 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-timeline.ts @@ -0,0 +1,15 @@ +import type { SessionMessageInfo } from "@opencode/client"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import type { V2Timeline } from "../v2/timeline.js"; + +// Snapshot assertions without a live stream can collect the publisher's events. +export async function collectTimelineSnapshot( + timeline: V2Timeline, + messages: SessionMessageInfo[], +): Promise { + const events: AgentStreamEvent[] = []; + await timeline.messages(messages, (event) => { + events.push(event); + }); + return events; +} diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts index 8214ebb1d0..b3eb432f86 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts @@ -153,21 +153,6 @@ const OpencodeGrepOutputSchema = z ]) .nullable(); -function formatLogEntry(value: unknown): string | undefined { - const outputText = readOutputText(value); - if (outputText) { - return outputText; - } - if (value === null || value === undefined) { - return undefined; - } - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - function extractOpenCodeTaskSessionId(value: unknown): string | undefined { const text = readOutputText(value); if (text) { @@ -195,7 +180,6 @@ function extractOpenCodeTaskSessionId(value: unknown): string | undefined { function deriveOpencodeTaskDetail( input: unknown, output: unknown, - error: unknown, metadata: Record | undefined, ): ToolCallDetail | null { if (!isRecord(input)) { @@ -208,7 +192,6 @@ function deriveOpencodeTaskDetail( return null; } - const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n"); const childSessionId = normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output); return { @@ -216,7 +199,8 @@ function deriveOpencodeTaskDetail( ...(subAgentType ? { subAgentType } : {}), ...(description ? { description } : {}), ...(childSessionId ? { childSessionId } : {}), - log, + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", actions: [], }; } @@ -373,11 +357,10 @@ export function deriveOpencodeToolDetail( toolName: string, input: unknown, output: unknown, - error: unknown = null, metadata?: Record, ): ToolCallDetail { if (toolName.trim().toLowerCase() === "task") { - const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata); + const taskDetail = deriveOpencodeTaskDetail(input, output, metadata); if (taskDetail) { return taskDetail; } diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts index 1c9ba212e1..eb38a04ac6 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -460,7 +460,7 @@ describe("opencode tool-call mapper", () => { }); }); - it("maps completed task calls with final output log to sub_agent detail", () => { + it("maps completed task calls to sub_agent detail", () => { const item = expectMapped( mapOpencodeToolCall({ toolName: "task", @@ -480,7 +480,7 @@ describe("opencode tool-call mapper", () => { type: "sub_agent", subAgentType: "explore", description: "Explore agent-tools codebase", - log: "Found the CLI entrypoint and provider registry.", + log: "", actions: [], }); }); @@ -505,12 +505,12 @@ describe("opencode tool-call mapper", () => { subAgentType: "explore", description: "Explore current directory", childSessionId: "ses_2268db431ffe299vL1bbot8R7Z", - log: 'done', + log: "", actions: [], }); }); - it("maps aborted task calls with preserved error log to sub_agent detail", () => { + it("maps aborted task calls to sub_agent detail", () => { const item = expectMapped( mapOpencodeToolCall({ toolName: "task", @@ -531,7 +531,7 @@ describe("opencode tool-call mapper", () => { type: "sub_agent", subAgentType: "explore", description: "Explore agent-tools codebase", - log: "Tool execution aborted", + log: "", actions: [], }); }); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts index 8fbc861a84..261a4ec827 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts @@ -43,7 +43,7 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim const error = raw.error ?? null; const rawStatus = typeof raw.status === "string" ? raw.status : undefined; const status = normalizeToolCallStatus(rawStatus, error, output); - const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata); + const detail = deriveOpencodeToolDetail(name, input, output, raw.metadata); if (status === "failed") { return { diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 26cb65885d..e8188730f5 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -17,6 +17,26 @@ function terminalEvents(events: AgentStreamEvent[]) { } describe("OpenCode v2 session lifecycle", () => { + test("refreshes the existing location before reading a forced catalog", async () => { + const harness = new V2Harness(); + const refreshed: string[] = []; + harness.connection.refreshLocation = async (directory) => { + refreshed.push(directory); + }; + harness.api.model.list = async () => ({ location: harness.info.location, data: [] }); + harness.api.provider.list = async () => ({ + location: harness.info.location, + data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }], + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/project", force: true }); + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/project", force: false }); + expect(refreshed).toEqual(["/tmp/project"]); + await client.shutdown(); + }); test("completes from execution events without a long-lived HTTP wait", async () => { const harness = new V2Harness(); harness.prompt = async (input) => { @@ -1074,3 +1094,110 @@ describe("OpenCode v2 session lifecycle", () => { } }); }); + +test("OpenCode v2 runtime safety retains shells, queued reports and shared executions", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + expect(await session.canStopRuntime!()).toBe(true); + harness.api.shell.list = async () => ({ + location: { directory: "/tmp/project" }, + data: [ + { + id: "quiet-shell", + status: "running", + command: "sleep 7200", + cwd: "/tmp/project", + shell: "sh", + file: "/tmp/out", + metadata: {}, + time: { started: 1 }, + }, + ], + }); + expect(await session.canStopRuntime!()).toBe(false); + harness.api.shell.list = async () => ({ location: { directory: "/tmp/project" }, data: [] }); + harness.api.session.inbox.list = async () => [ + { + id: "report", + sessionID: session.id!, + type: "synthetic", + delivery: "queue", + time: { created: 1 }, + payload: { text: "Background child finished" }, + }, + ]; + expect(await session.canStopRuntime!()).toBe(false); + harness.api.session.inbox.list = async () => []; + harness.api.session.active = async () => ({ + "unrelated-autonomous-session": { type: "running" }, + }); + expect(await session.canStopRuntime!()).toBe(false); + harness.api.session.active = async () => ({}); + expect(await session.canStopRuntime!()).toBe(true); + harness.api.shell.list = async () => { + throw new Error("No shell inventory"); + }; + await expect(session.canStopRuntime!()).rejects.toThrow("No shell inventory"); + } finally { + await session.close(); + } +}); + +test("OpenCode v2 runtime safety follows descendant locations before releasing the last lease", async () => { + const harness = new V2Harness(); + const child = { + ...harness.info, + id: "child", + parentID: harness.info.id, + location: { directory: "/tmp/child-worktree" }, + }; + const locations: string[] = []; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + // A completed native child can still own a quiet shell in another location. + harness.api.session.list = async (input) => ({ + data: input.parentID === session.id ? [child] : [], + cursor: {}, + }); + let running = true; + harness.api.shell.list = async (input) => { + const directory = input?.location?.directory ?? ""; + locations.push(directory); + return { + location: { directory }, + data: + directory === child.location.directory && running + ? [ + { + id: "child-shell", + status: "running", + command: "sleep 7200", + cwd: directory, + shell: "sh", + file: "/tmp/out", + metadata: {}, + time: { started: 1 }, + }, + ] + : [], + }; + }; + try { + expect(await session.canStopRuntime!()).toBe(false); + expect(locations).toEqual(["/tmp/project", "/tmp/child-worktree"]); + running = false; + expect(await session.canStopRuntime!()).toBe(true); + await session.close(); + expect(harness.releases).toBe(1); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.ts index 2e50e759bd..119fa0ade7 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.ts @@ -8,7 +8,7 @@ import { } from "./configuration.js"; import { commands } from "./commands.js"; -import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js"; +import { waitForLocationReady } from "./readiness.js"; import type { SessionInfo } from "@opencode/client"; @@ -40,6 +40,7 @@ import { importSessionFromPersistence } from "../../../provider-session-import.j import type { OpenCodeBridge } from "../bridge.js"; import { resolveOpenCodeHomeDir } from "../paths.js"; import { V2Runtime, type V2Connection } from "./runtime.js"; +import type { OpenCodeServiceSelection } from "./service-selection.js"; import { modelRef, modesFromV2, modelsFromV2 } from "./mapping.js"; interface V2AgentOptions { @@ -48,6 +49,7 @@ interface V2AgentOptions { managedProcesses?: ManagedProcessRegistry; bridge?: OpenCodeBridge; runtime?: Pick; + selection?: OpenCodeServiceSelection; } export class OpenCodeV2AgentClient implements AgentClient { @@ -56,12 +58,12 @@ export class OpenCodeV2AgentClient implements AgentClient { private readonly runtime: Pick; private readonly connections = new Map(); constructor(private readonly options: V2AgentOptions) { - this.capabilities = { ...V2_CAPABILITIES, supportsNativePaseoTools: Boolean(options.bridge) }; + const bridge = options.bridge; + this.capabilities = { ...V2_CAPABILITIES, supportsNativePaseoTools: Boolean(bridge) }; this.runtime = options.runtime ?? new V2Runtime({ ...options, - decorateEnv: options.bridge ? (env) => options.bridge!.decorateV2ServerEnv(env) : undefined, }); } async isAvailable() { @@ -85,6 +87,10 @@ export class OpenCodeV2AgentClient implements AgentClient { await activity("plugin.list", () => waitForLocationReady({ client: connection.client, location, signal: request.signal }), ); + if (options.force) + await activity("paseo.bridge.refresh", () => + connection.refreshLocation(location.directory, request.signal), + ); const [models, agents, providers] = await Promise.all([ activity("model.list", () => connection.client.model.list({ location }, request)), activity("agent.list", () => connection.client.agent.list({ location }, request)), @@ -163,7 +169,8 @@ export class OpenCodeV2AgentClient implements AgentClient { requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {}, ); let ownedConnection = connection; - const unbind = this.options.bridge?.bindSession({ + const bridge = this.options.bridge; + const unbind = bridge?.bindSession({ sessionId: info.id, env: launch?.env ?? {}, tools: launch?.paseoTools, @@ -172,7 +179,7 @@ export class OpenCodeV2AgentClient implements AgentClient { const bindChild = (childId: string) => { this.connections.set(childId, ownedConnection); if (bound.has(childId)) return; - const childUnbind = this.options.bridge?.bindSession({ + const childUnbind = bridge?.bindSession({ sessionId: childId, env: launch?.env ?? {}, tools: launch?.paseoTools, @@ -204,10 +211,6 @@ export class OpenCodeV2AgentClient implements AgentClient { moved, ); try { - if (this.options.bridge) { - const location = { directory: config.cwd }; - await awaitPaseoPlugin({ client: connection.client, location }); - } await session.initialize( launch?.env ? createProviderEnv({ runtimeSettings: this.options.settings, overlays: [launch.env] }) diff --git a/packages/server/src/server/agent/providers/opencode/v2/api.ts b/packages/server/src/server/agent/providers/opencode/v2/api.ts index fe225cade2..bec40ffbcb 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/api.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/api.ts @@ -2,6 +2,7 @@ import type { OpenCodeClient } from "@opencode/client"; export interface V2Api { server: OpenCodeClient["server"]; + shell: Pick; session: Pick< OpenCodeClient["session"], | "create" @@ -20,6 +21,7 @@ export interface V2Api { | "revert" | "log" > & { + inbox: Pick; form: Pick; }; plugin: Pick; diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs index 1664c0b59f..6971a5b323 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs @@ -1,100 +1,15 @@ -import { registerStructuredOutput, STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs"; +import { registerStructuredOutput } from "./structured-output-plugin.mjs"; +import { registerSharedBridge } from "./shared-bridge-plugin.mjs"; export default { id: "paseo", async setup(context) { const disposeStructuredOutput = await registerStructuredOutput(context); - const { baseUrl, token } = context.options; - if (!baseUrl || !token) return disposeStructuredOutput; - async function request(pathname, body, allowMissing = false) { - const response = await fetch(new URL(`/_internal/opencode${pathname}`, baseUrl), { - method: body === undefined ? "GET" : "POST", - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - if (allowMissing && response.status === 404) return null; - if (!response.ok) { - const payload = await response.json().catch(() => null); - const detail = formatErrorDetail(payload?.issues ?? payload?.details); - const message = - typeof payload?.error === "string" && payload.error - ? payload.error - : `Paseo tool bridge returned HTTP ${response.status}`; - const error = new Error(detail ? `${message}: ${detail}` : message); - error.status = response.status; - if (detail) error.details = detail; - throw error; - } - return response.json(); - } - async function scope(sessionID) { - const visited = new Set(); - while (sessionID && !visited.has(sessionID)) { - visited.add(sessionID); - const binding = await request( - `/sessions/${encodeURIComponent(sessionID)}/context?tools`, - undefined, - true, - ); - if (binding) return { ...binding, sessionID }; - const session = await context.session.get({ sessionID }); - sessionID = session.parentID; - } - throw new Error("OpenCode session is not bound to a Paseo agent"); - } - const manifest = await request("/tools"); - const registration = await context.tool.transform((editor) => { - for (const definition of manifest.tools) { - editor.add({ - name: `paseo_${definition.name}`, - description: definition.description, - input: definition.inputSchema, - options: { codemode: false }, - async execute(input, call) { - const binding = await scope(call.sessionID); - const result = await request( - `/sessions/${encodeURIComponent(binding.sessionID)}/tools/${encodeURIComponent(definition.name)}`, - input, - ); - return { - content: result.content.map(toOpenCodeContent), - metadata: { paseoTool: definition.name }, - }; - }, - }); - } - }); - const filtering = await context.session.hook("context", async (input) => { - const binding = await scope(input.sessionID); - const allowed = new Set(binding.tools.map((name) => `paseo_${name}`)); - for (const name of Object.keys(input.tools)) { - if (name !== STRUCTURED_OUTPUT_TOOL && name.startsWith("paseo_") && !allowed.has(name)) - delete input.tools[name]; - } - }); + if (context.options.mode === "structured-only") return disposeStructuredOutput; + const disposeBridge = await registerSharedBridge(context); return async () => { + await disposeBridge(); await disposeStructuredOutput(); - await filtering.dispose(); - await registration.dispose(); }; }, }; - -function formatErrorDetail(details) { - if (!Array.isArray(details)) return typeof details === "string" ? details : ""; - return details - .map((issue) => { - const path = - Array.isArray(issue?.path) && issue.path.length > 0 ? issue.path.join(".") : "input"; - return `${path}: ${issue?.message ?? "Invalid value"}`; - }) - .join("; "); -} - -// OpenCode tool content is text or a file; MCP images arrive as base64 data. -function toOpenCodeContent(part) { - if (part.type === "image") { - return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType }; - } - return part; -} diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts new file mode 100644 index 0000000000..52b483cca3 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { hasManagedBridge, provisionLocalBridge } from "./bridge-provisioning.js"; + +test("installs a watched plugin without changing existing OpenCode configuration", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-provision-")); + const source = path.join(root, "source.js"); + const config = path.join(root, "opencode.json"); + await writeFile(source, "export default { id: 'paseo' };"); + await writeFile(path.join(root, "state.mjs"), "export const state = new Map();"); + await writeFile(config, '{"plugins":["existing"]}\n'); + try { + expect(await hasManagedBridge(root)).toBe(false); + await Promise.all([ + provisionLocalBridge({ configDirectory: root, artifact: source }), + provisionLocalBridge({ configDirectory: root, artifact: source }), + ]); + expect(await hasManagedBridge(root)).toBe(true); + const manifest = path.join(root, "plugins/paseo-managed-bridge/package.json"); + const formatted = `${JSON.stringify(JSON.parse(await readFile(manifest, "utf8")), null, 2)}\n`; + await writeFile(manifest, formatted); + expect(await hasManagedBridge(root)).toBe(true); + await provisionLocalBridge({ configDirectory: root, artifact: source }); + expect(await readFile(manifest, "utf8")).toBe(formatted); + expect(await readFile(config, "utf8")).toBe('{"plugins":["existing"]}\n'); + expect(await readFile(path.join(root, "plugins/paseo-managed-bridge/state.mjs"), "utf8")).toBe( + "export const state = new Map();", + ); + expect( + await readFile(path.join(root, "plugins/paseo-managed-bridge/server.js"), "utf8"), + ).toContain("id: 'paseo'"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts new file mode 100644 index 0000000000..ce2626c38b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-provisioning.ts @@ -0,0 +1,89 @@ +import { mkdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { writeFileAtomic } from "../../../../atomic-file.js"; + +const PACKAGE = JSON.stringify({ + name: "paseo-managed-bridge", + private: true, + type: "module", + exports: { "./server": "./server.js" }, +}); + +export function openCodeConfigDirectory(env: NodeJS.ProcessEnv): string { + return ( + env.OPENCODE_CONFIG_DIR ?? + path.join(env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config"), "opencode") + ); +} + +export async function hasManagedBridge(configDirectory: string): Promise { + const directory = path.join(configDirectory, "plugins", "paseo-managed-bridge"); + const [manifest, artifact] = await Promise.all([ + readFile(path.join(directory, "package.json"), "utf8").catch(() => ""), + readFile(path.join(directory, "server.js")).catch(() => undefined), + ]); + return isManagedPackage(manifest) && Boolean(artifact?.length); +} + +export async function provisionLocalBridge(input: { + configDirectory: string; + artifact: string; +}): Promise { + const directory = path.join(input.configDirectory, "plugins", "paseo-managed-bridge"); + await mkdir(path.dirname(directory), { recursive: true }); + let created = false; + try { + await mkdir(directory); + created = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const manifest = path.join(directory, "package.json"); + if (created) await writeFileAtomic(manifest, PACKAGE); + else if (!isManagedPackage(await readManagedManifest(manifest))) + throw new Error(`Cannot install OpenCode bridge: ${directory} is not managed by Paseo`); + const artifact = await readFile(input.artifact); + const state = await readFile(path.join(path.dirname(input.artifact), "state.mjs")); + const stateTarget = path.join(directory, "state.mjs"); + const existingState = await readFile(stateTarget).catch(() => undefined); + if (!existingState) await writeFileAtomic(stateTarget, state); + else if (!existingState.equals(state)) + throw new Error(`Cannot update OpenCode bridge state module at ${stateTarget}`); + const target = path.join(directory, "server.js"); + const existing = await readFile(target).catch(() => undefined); + if (!existing?.equals(artifact)) await writeFileAtomic(target, artifact); +} + +function isManagedPackage(content: string): boolean { + let value: unknown; + try { + value = JSON.parse(content); + } catch { + return false; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const manifest = value as Record; + if (!manifest.exports || typeof manifest.exports !== "object" || Array.isArray(manifest.exports)) + return false; + const exports = manifest.exports as Record; + return ( + manifest.name === "paseo-managed-bridge" && + manifest.private === true && + manifest.type === "module" && + exports["./server"] === "./server.js" + ); +} + +async function readManagedManifest(file: string): Promise { + for (let attempt = 0; attempt < 20; attempt++) { + try { + return await readFile(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + await delay(25); + } + } + return ""; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/children.ts b/packages/server/src/server/agent/providers/opencode/v2/children.ts index 8c2e0ec312..08af88dcb0 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/children.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/children.ts @@ -92,13 +92,13 @@ export class SessionChildren { this.childStates.set(info.id, signature); this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation }); } - for (const event of timeline.messages(await messages(this.options.client(), info.id))) { + await timeline.messages(await messages(this.options.client(), info.id), (event) => { if (event.type === "timeline") this.options.emit({ type: "provider_subagent", provider: "opencode", event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp }, }); - } + }); } } diff --git a/packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts b/packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts new file mode 100644 index 0000000000..f4f39f4aeb --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/external-runtime.test.ts @@ -0,0 +1,180 @@ +import { createServer, type Server } from "node:http"; +import { createServer as createNetServer } from "node:net"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import { V2Runtime } from "./runtime.js"; +import { EXTERNAL_SERVER_PASSWORD_ENV, EXTERNAL_SERVER_URL_ENV } from "./external-server.js"; + +function listen(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Test server did not expose a TCP address")); + return; + } + resolve(address.port); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + server.closeAllConnections(); + }); +} + +function reserveClosedPort(): Promise { + return new Promise((resolve, reject) => { + const server = createNetServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Could not reserve a test port")); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); +} + +describe("V2Runtime against an external server", () => { + let server: Server | undefined; + + afterEach(async () => { + if (server) await close(server); + server = undefined; + }); + + test("reuses the configured server and never spawns a local one", async () => { + const requests: Array<{ url: string | undefined; authorization: string | undefined }> = []; + server = createServer((request, response) => { + requests.push({ url: request.url, authorization: request.headers.authorization }); + if (request.url !== "/api/info") { + response.writeHead(404); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.10", pid: 1, urls: [], paths: { tmp: "/tmp" } })); + }); + const port = await listen(server); + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: `http://127.0.0.1:${port}`, password: "secret" }, + }); + + const first = await runtime.acquire(); + const second = await runtime.acquire(); + expect(first.client).toBe(second.client); + expect(requests).toEqual([ + { + url: "/api/info", + authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`, + }, + ]); + + await first.release(); + await second.release(); + const refreshed = await runtime.acquire({ fresh: true }); + expect(refreshed.client).toBe(first.client); + await refreshed.release(); + await runtime.shutdown(); + expect(requests).toHaveLength(1); + }); + + test("selects the external server from provider environment", async () => { + const paths: Array = []; + server = createServer((request, response) => { + paths.push(request.url); + if (request.url !== "/api/info") { + response.writeHead(404); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.10", pid: 1, urls: [], paths: { tmp: "/tmp" } })); + }); + const port = await listen(server); + const runtime = new V2Runtime({ + logger: createTestLogger(), + settings: { + env: { + [EXTERNAL_SERVER_URL_ENV]: `http://127.0.0.1:${port}`, + [EXTERNAL_SERVER_PASSWORD_ENV]: "", + }, + }, + }); + + const connection = await runtime.acquire(); + expect(paths).toEqual(["/api/info"]); + await connection.release(); + await runtime.shutdown(); + }); + + test("reports an unreachable configured server", async () => { + const port = await reserveClosedPort(); + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: `http://127.0.0.1:${port}` }, + }); + + await expect(runtime.acquire()).rejects.toThrow( + `Cannot reach OpenCode v2 server at http://127.0.0.1:${port}`, + ); + await runtime.shutdown(); + }); + + test("rejects session configuration that requires process isolation", async () => { + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: "http://127.0.0.1:1" }, + }); + await expect(runtime.acquire({ dedicated: true })).rejects.toThrow( + "require an isolated server", + ); + await runtime.shutdown(); + }); + + test("rejects provider process environment on an explicit server", async () => { + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: "http://127.0.0.1:1" }, + settings: { env: { XDG_CONFIG_HOME: "/custom", PROVIDER_KEY: "secret" } }, + }); + await expect(runtime.acquire()).rejects.toThrow("XDG_CONFIG_HOME, PROVIDER_KEY"); + await runtime.shutdown(); + }); + + test("rejects an unsupported explicit server version", async () => { + server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.9", pid: 1, urls: [], paths: { tmp: "/tmp" } })); + }); + const port = await listen(server); + const runtime = new V2Runtime({ + logger: createTestLogger(), + externalServer: { url: `http://127.0.0.1:${port}` }, + }); + await expect(runtime.acquire()).rejects.toThrow("Unsupported OpenCode version 2.0.9"); + await runtime.shutdown(); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts b/packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts new file mode 100644 index 0000000000..f9986215ad --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/external-server.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest"; + +import { + EXTERNAL_SERVER_PASSWORD_ENV, + EXTERNAL_SERVER_URL_ENV, + resolveExternalOpenCodeServer, +} from "./external-server.js"; + +describe("resolveExternalOpenCodeServer", () => { + test("returns undefined when no URL is configured", () => { + expect(resolveExternalOpenCodeServer(undefined)).toBeUndefined(); + expect(resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: " " })).toBeUndefined(); + }); + + test("reads the URL and password and removes a trailing slash", () => { + expect( + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: " http://127.0.0.1:4096/ ", + [EXTERNAL_SERVER_PASSWORD_ENV]: "secret", + }), + ).toEqual({ url: "http://127.0.0.1:4096", password: "secret" }); + }); + + test("omits an empty password", () => { + expect( + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: "https://opencode.example.com", + [EXTERNAL_SERVER_PASSWORD_ENV]: "", + }), + ).toEqual({ url: "https://opencode.example.com" }); + }); + + test("preserves spaces in the password", () => { + expect( + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: "https://opencode.example.com", + [EXTERNAL_SERVER_PASSWORD_ENV]: " secret ", + }), + ).toEqual({ url: "https://opencode.example.com", password: " secret " }); + }); + + test("rejects relative and non-HTTP URLs", () => { + expect(() => + resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: "127.0.0.1:4096" }), + ).toThrow(`${EXTERNAL_SERVER_URL_ENV} must be an absolute URL, received "127.0.0.1:4096"`); + expect(() => + resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: "unix:/tmp/opencode.sock" }), + ).toThrow( + `${EXTERNAL_SERVER_URL_ENV} must use http or https, received "unix:/tmp/opencode.sock"`, + ); + }); + + test("rejects credentials embedded in URLs", () => { + expect(() => + resolveExternalOpenCodeServer({ + [EXTERNAL_SERVER_URL_ENV]: "https://user:password@opencode.example.com", + }), + ).toThrow("must not contain credentials"); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/external-server.ts b/packages/server/src/server/agent/providers/opencode/v2/external-server.ts new file mode 100644 index 0000000000..c00b158ce3 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/external-server.ts @@ -0,0 +1,38 @@ +export const EXTERNAL_SERVER_URL_ENV = "PASEO_OPENCODE_SERVER_URL"; +export const EXTERNAL_SERVER_PASSWORD_ENV = "PASEO_OPENCODE_SERVER_PASSWORD"; + +export interface ExternalOpenCodeServer { + url: string; + password?: string; +} + +/** + * Reads the optional connection to a user-managed OpenCode v2 server from + * provider environment. When absent, Paseo keeps spawning its own server. + */ +export function resolveExternalOpenCodeServer( + env: Record | undefined, +): ExternalOpenCodeServer | undefined { + const rawUrl = env?.[EXTERNAL_SERVER_URL_ENV]?.trim(); + if (!rawUrl) return undefined; + + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error(`${EXTERNAL_SERVER_URL_ENV} must be an absolute URL, received "${rawUrl}"`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${EXTERNAL_SERVER_URL_ENV} must use http or https, received "${rawUrl}"`); + } + if (url.username || url.password) + throw new Error( + `${EXTERNAL_SERVER_URL_ENV} must not contain credentials; use ${EXTERNAL_SERVER_PASSWORD_ENV}`, + ); + + const password = env?.[EXTERNAL_SERVER_PASSWORD_ENV]; + return { + url: url.toString().replace(/\/$/, ""), + ...(password ? { password } : {}), + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts b/packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts new file mode 100644 index 0000000000..d60535889b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/owned-runtime.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import { OpenCodeBridge } from "../bridge.js"; +import { provisionLocalBridge } from "./bridge-provisioning.js"; +import { V2Runtime } from "./runtime.js"; + +test("an owned helper uses an installed bridge once and survives catalog refresh", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-owned-opencode-")); + const config = path.join(root, "config", "opencode"); + const script = path.join(root, "server.cjs"); + const record = path.join(root, "env.json"); + const bridge = new OpenCodeBridge({ paseoHome: root, logger: createTestLogger() }); + await bridge.start(); + const artifact = fileURLToPath(await bridge.materializeV2Plugin()); + await provisionLocalBridge({ + configDirectory: config, + artifact: path.join(artifact, "server.js"), + }); + await writeFile( + script, + ` + const http = require('node:http'); + require('node:fs').writeFileSync(${JSON.stringify(record)}, process.env.OPENCODE_CONFIG_CONTENT); + const server = http.createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ version: '2.0.10', pid: process.pid, urls: [], paths: { tmp: '/tmp' } })); + }); + server.listen(0, '127.0.0.1', () => console.log('server listening on http://127.0.0.1:' + server.address().port)); + `, + ); + const runtime = new V2Runtime({ + logger: createTestLogger(), + bridge, + selection: { kind: "owned" }, + settings: { + command: { mode: "replace", argv: [process.execPath, script] }, + env: { XDG_CONFIG_HOME: path.join(root, "config"), OPENCODE_CONFIG_CONTENT: "{}" }, + }, + }); + try { + const first = await runtime.acquire(); + const pid = (await first.client.server.info()).pid; + expect(JSON.parse(await readFile(record, "utf8"))).toEqual({}); + await first.release(); + const refreshed = await runtime.acquire({ fresh: true }); + expect((await refreshed.client.server.info()).pid).toBe(pid); + await refreshed.release(); + } finally { + await runtime.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}, 15_000); + +test("reports a bridge port change that has not been applied", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-port-")); + const bridge = new OpenCodeBridge({ paseoHome: root, logger: createTestLogger() }); + await bridge.start(); + const actual = Number(new URL(bridge.sharedV2Connection().baseUrl).port); + const runtime = new V2Runtime({ + logger: createTestLogger(), + bridge, + selection: { kind: "explicit", server: { url: "http://127.0.0.1:1" } }, + settings: { env: { PASEO_OPENCODE_BRIDGE_PORT: actual === 1 ? "2" : "1" } }, + }); + try { + await expect(runtime.acquire()).rejects.toThrow("restart Paseo to apply the bridge port"); + } finally { + await runtime.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/runtime.ts b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts index cab5544582..0fe830fe45 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/runtime.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts @@ -16,11 +16,25 @@ import type { ManagedProcessRegistry } from "../../../../managed-processes/manag import { resolveOpenCodeHomeDir } from "../paths.js"; import { OpenCodeHttpError } from "../http-error.js"; import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js"; +import { resolveExternalOpenCodeServer, type ExternalOpenCodeServer } from "./external-server.js"; +import { selectOpenCodeService, type OpenCodeServiceSelection } from "./service-selection.js"; +import { bridgeBindings, connectSharedService } from "./shared-service.js"; +import { + hasManagedBridge, + openCodeConfigDirectory, + provisionLocalBridge, +} from "./bridge-provisioning.js"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { OpenCodeBridge } from "../bridge.js"; export interface V2Connection { client: V2Api; release(): Promise; retain(): V2Connection; + bindSession(sessionId: string, cwd: string): Promise; + unbindSession(sessionId: string, cwd: string): Promise; + refreshLocation(cwd: string, signal?: AbortSignal): Promise; readonly exited: Promise; } interface Generation { @@ -28,12 +42,17 @@ interface Generation { users: number; stop(): Promise; exited: Promise; + bindSession(sessionId: string, cwd: string): Promise; + unbindSession(sessionId: string, cwd: string): Promise; + refreshLocation(cwd: string, signal?: AbortSignal): Promise; } export interface V2RuntimeOptions { logger: Logger; settings?: ProviderRuntimeSettings; managedProcesses?: ManagedProcessRegistry; - decorateEnv?: (env: Record) => Promise>; + externalServer?: ExternalOpenCodeServer; + selection?: OpenCodeServiceSelection; + bridge?: OpenCodeBridge; } export interface V2RuntimeAcquireOptions { @@ -49,17 +68,36 @@ export class V2Runtime { private generations = new Set(); private starts = new Set>(); private closed = false; + private readonly selection: Promise; - constructor(private readonly options: V2RuntimeOptions) {} + constructor(private readonly options: V2RuntimeOptions) { + this.selection = options.selection + ? Promise.resolve(options.selection) + : selectOpenCodeService({ + explicit: + options.externalServer ?? + resolveExternalOpenCodeServer({ + ...globalThis.process.env, + ...options.settings?.env, + }), + env: options.settings?.env, + }); + } async acquire(input: V2RuntimeAcquireOptions = {}): Promise { if (this.closed) throw new Error("OpenCode runtime is closed"); input.signal?.throwIfAborted(); + this.assertBridgePort(); + const selection = await raceProviderRefreshAbort(input.signal, this.selection); let pending: Promise; - if (input.env || input.dedicated) { + if (selection.kind !== "owned") { + this.assertSharedConfiguration(input, selection); + this.current ??= this.startTracked(); + pending = this.current; + } else if (input.env || input.dedicated) { pending = this.startTracked(input.env); } else { - if (input.fresh || !this.current) this.current = this.startTracked(); + if (!this.current) this.current = this.startTracked(); pending = this.current; } let generation: Generation; @@ -97,6 +135,39 @@ export class V2Runtime { return connection; } + private assertBridgePort(): void { + const port = this.options.settings?.env?.PASEO_OPENCODE_BRIDGE_PORT; + if (!port || !this.options.bridge) return; + const actual = new URL(this.options.bridge.sharedV2Connection().baseUrl).port; + if (Number(port) !== Number(actual)) + throw new Error("PASEO_OPENCODE_BRIDGE_PORT changed; restart Paseo to apply the bridge port"); + } + + private assertSharedConfiguration( + input: V2RuntimeAcquireOptions, + selection: OpenCodeServiceSelection, + ): void { + const connectionEnv = new Set([ + "PASEO_OPENCODE_SERVER_URL", + "PASEO_OPENCODE_SERVER_PASSWORD", + "PASEO_OPENCODE_BRIDGE_URL", + "PASEO_OPENCODE_BRIDGE_PORT", + ]); + if (selection.kind === "discovered") { + connectionEnv.add("XDG_CONFIG_HOME"); + connectionEnv.add("XDG_STATE_HOME"); + connectionEnv.add("OPENCODE_CONFIG_DIR"); + } + const processOverrides = Object.keys(this.options.settings?.env ?? {}).filter( + (key) => !connectionEnv.has(key), + ); + if (input.env || input.dedicated || processOverrides.length) { + throw new Error( + `Custom OpenCode MCP servers or process environment require an isolated server and cannot be used with a shared OpenCode service${processOverrides.length ? `: ${processOverrides.join(", ")}` : ""}.`, + ); + } + } + private lease(generation: Generation, pending: Promise): V2Connection { if (this.closed || !this.generations.has(generation)) throw new Error("OpenCode helper server is no longer running"); @@ -107,7 +178,7 @@ export class V2Runtime { released = true; generation.users -= 1; if (generation.users !== 0) return; - if (this.current === pending) this.current = null; + if (this.current === pending) return; this.generations.delete(generation); await generation.stop(); }; @@ -115,6 +186,9 @@ export class V2Runtime { client: generation.client, release, retain: () => this.lease(generation, pending), + bindSession: generation.bindSession, + unbindSession: generation.unbindSession, + refreshLocation: generation.refreshLocation, exited: generation.exited, }; } @@ -146,6 +220,16 @@ export class V2Runtime { } private async start(env: Record = {}): Promise { + const selection = await this.selection; + if (selection.kind !== "owned") { + const generation: Generation = { + ...(await connectSharedService(selection, this.options)), + users: 0, + }; + this.generations.add(generation); + return generation; + } + const { settings, managedProcesses, logger } = this.options; const launch = await resolveProviderLaunch({ commandConfig: settings?.command, @@ -160,9 +244,20 @@ export class V2Runtime { ...settings?.env, ...env, }; - const decorated = this.options.decorateEnv - ? await this.options.decorateEnv(configured) - : decorateOpenCodeV2Env(configured, await materializeOpenCodeV2Plugin(resolvePaseoHome())); + const bridge = this.options.bridge; + const plugin = bridge + ? await bridge.materializeV2Plugin() + : await materializeOpenCodeV2Plugin(resolvePaseoHome()); + const globalConfig = openCodeConfigDirectory({ ...globalThis.process.env, ...configured }); + const managed = await hasManagedBridge(globalConfig); + if (managed && bridge) + await provisionLocalBridge({ + configDirectory: globalConfig, + artifact: path.join(fileURLToPath(plugin), "server.js"), + }); + const decorated = managed + ? configured + : decorateOpenCodeV2Env(configured, plugin, bridge ? {} : { mode: "structured-only" }); const args = [...launch.args, "serve", "--hostname", "127.0.0.1", "--port", "0"]; const process = spawnProcess(launch.command, args, { cwd, @@ -253,7 +348,13 @@ export class V2Runtime { }, }); await client.server.info({ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) }); - const generation: Generation = { client, users: 0, stop, exited }; + const generation: Generation = { + client, + users: 0, + stop, + exited, + ...bridgeBindings(client, bridge), + }; this.generations.add(generation); process.once("exit", () => { this.generations.delete(generation); diff --git a/packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts b/packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts new file mode 100644 index 0000000000..876582fb9c --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/service-selection.test.ts @@ -0,0 +1,55 @@ +import { createServer } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { selectOpenCodeService } from "./service-selection.js"; + +test("selects the registered service with its credential and ignores stale registration", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-service-")); + const file = path.join(directory, "service.json"); + let requests = 0; + const server = createServer((request, response) => { + requests++; + if ( + request.headers.authorization !== `Basic ${Buffer.from("opencode:secret").toString("base64")}` + ) { + response.writeHead(401).end(); + return; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ version: "2.0.10", pid: process.pid })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No test address"); + const url = `http://127.0.0.1:${address.port}`; + try { + await writeFile( + file, + JSON.stringify({ url, password: "secret", pid: process.pid, version: "2.0.10" }), + ); + expect(await selectOpenCodeService({ file })).toEqual({ + kind: "discovered", + server: { url, password: "secret" }, + }); + expect(requests).toBe(1); + expect( + await selectOpenCodeService({ file, explicit: { url: "http://explicit.test" } }), + ).toEqual({ kind: "explicit", server: { url: "http://explicit.test" } }); + expect(requests).toBe(1); + await writeFile(file, JSON.stringify({ url, password: "secret", pid: process.pid + 1 })); + expect(await selectOpenCodeService({ file })).toEqual({ kind: "owned" }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/service-selection.ts b/packages/server/src/server/agent/providers/opencode/v2/service-selection.ts new file mode 100644 index 0000000000..eeb2cb0994 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/service-selection.ts @@ -0,0 +1,43 @@ +import { Service } from "@opencode/client/service"; +import { homedir, networkInterfaces } from "node:os"; +import path from "node:path"; +import type { ExternalOpenCodeServer } from "./external-server.js"; + +export type OpenCodeServiceSelection = + | { kind: "owned" } + | { kind: "explicit" | "discovered"; server: ExternalOpenCodeServer }; + +export async function selectOpenCodeService(input: { + explicit?: ExternalOpenCodeServer; + file?: string; + env?: Record; +}): Promise { + if (input.explicit) return { kind: "explicit", server: input.explicit }; + const state = + input.env?.XDG_STATE_HOME ?? + process.env.XDG_STATE_HOME ?? + path.join(homedir(), ".local", "state"); + const endpoint = await Service.discover({ + file: input.file ?? path.join(state, "opencode", "service.json"), + version: compatibleVersion, + }).catch(() => undefined); + if (!endpoint) return { kind: "owned" }; + if (!isLocalOpenCodeHost(new URL(endpoint.url).hostname)) return { kind: "owned" }; + return { + kind: "discovered", + server: { url: endpoint.url, ...(endpoint.auth ? { password: endpoint.auth.password } : {}) }, + }; +} + +export function isLocalOpenCodeHost(input: string): boolean { + const hostname = input.replace(/^\[|\]$/g, ""); + const localAddresses = Object.values(networkInterfaces()).flatMap((items) => + (items ?? []).map((item) => item.address), + ); + return ["localhost", "127.0.0.1", "::1", "0.0.0.0", "::", ...localAddresses].includes(hostname); +} + +export function compatibleVersion(version: string): boolean { + const match = /^2\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version); + return Boolean(match && (Number(match[1]) > 0 || Number(match[2]) >= 10)); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 5cd45b1ff7..a167ec0713 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -131,7 +131,7 @@ export class OpenCodeV2Session implements AgentSession { await this.configureConnection(); const location = { directory: this.config.cwd }; this.modes = modesFromV2((await this.client.agent.list({ location })).data); - this.timeline.messages(await messages(this.client, this.id)); + await this.timeline.messages(await messages(this.client, this.id), () => {}); await this.startStream(); await this.children.reconcile(this.id); } @@ -146,6 +146,7 @@ export class OpenCodeV2Session implements AgentSession { } private async configureConnection() { const location = { directory: this.config.cwd }; + await this.connection.bindSession(this.id, this.config.cwd); await waitForLocationReady({ client: this.client, location, signal: this.abort.signal }); if (this.requiresPaseoPlugin) await awaitPaseoPlugin({ client: this.client, location, signal: this.abort.signal }); @@ -207,6 +208,11 @@ export class OpenCodeV2Session implements AgentSession { if (!this.acquire) throw new Error("OpenCode helper server exited"); await this.stream; await this.sync.catch(() => undefined); + await this.connection + .unbindSession(this.id, this.config.cwd) + .catch((error: unknown) => + this.logger.warn({ error }, "Could not unbind the OpenCode Paseo bridge"), + ); const next = await this.acquire(); if (this.closed) { await next.release(); @@ -261,7 +267,11 @@ export class OpenCodeV2Session implements AgentSession { async *streamHistory() { await this.reconnectIfExited(); const history = new V2Timeline(false); - yield* history.messages(await messages(this.client, this.id)); + const events: AgentStreamEvent[] = []; + await history.messages(await messages(this.client, this.id), (event) => { + events.push(event); + }); + yield* events; } async getRuntimeInfo() { await this.reconnectIfExited(); @@ -382,6 +392,7 @@ export class OpenCodeV2Session implements AgentSession { } async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) { await this.reconnectIfExited(); + await this.connection.bindSession(this.id, this.config.cwd); return this.turns.startTurn(prompt, options); } steerActiveTurn(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise { @@ -417,7 +428,7 @@ export class OpenCodeV2Session implements AgentSession { ]); this.info = info; this.history = history; - for (const event of this.timeline.messages(history)) this.emitTimeline(event); + await this.timeline.messages(history, (event) => this.emitTimeline(event)); await this.permissions.reconcile(this.id); } private async reconcile() { @@ -432,6 +443,7 @@ export class OpenCodeV2Session implements AgentSession { } } private async reconcileConnection() { + await this.connection.bindSession(this.id, this.config.cwd); // Session environments are process-local. Reapply this agent's snapshot before // reconciling a new connection, including event-stream reconnections. if (this.launchEnv) @@ -516,6 +528,36 @@ export class OpenCodeV2Session implements AgentSession { await delay(100, undefined, { signal }).catch(() => undefined); } } + async canStopRuntime(): Promise { + if (this.closed || this.exited || this.reconnecting || this.turns.id || this.dirty) + return false; + const request = { signal: AbortSignal.timeout(10_000) }; + // Active executions are server-wide. Releasing the last lease must not stop an + // autonomous execution merely because it belongs to another session or location. + if (Object.keys(await this.client.session.active(request)).length > 0) return false; + const family = new Map([[this.info.id, this.info]]); + const directories = new Set(); + for (const info of family.values()) { + if ((await this.client.session.inbox.list({ sessionID: info.id }, request)).length) + return false; + directories.add(info.location.directory); + let cursor: string | undefined; + do { + const page = await this.client.session.list( + { ...(cursor ? { cursor } : { parentID: info.id }), limit: 100 }, + request, + ); + for (const child of page.data) family.set(child.id, child); + cursor = page.cursor.next ?? undefined; + } while (cursor); + } + for (const directory of directories) { + const shells = await this.client.shell.list({ location: { directory } }, request); + if (shells.data.some((shell) => shell.status === "running")) return false; + } + return !this.turns.id && !this.dirty; + } + async close() { if (this.closed) return; this.closed = true; @@ -526,6 +568,11 @@ export class OpenCodeV2Session implements AgentSession { await this.stream; await this.reconnecting?.catch(() => undefined); await this.sync.catch(() => undefined); + await this.connection + .unbindSession(this.id, this.config.cwd) + .catch((error: unknown) => + this.logger.warn({ error }, "Could not unbind the OpenCode Paseo bridge"), + ); this.unbind?.(); try { if (!this.persist) await this.client.session.remove({ sessionID: this.id }); diff --git a/packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs new file mode 100644 index 0000000000..4be98bfba9 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge-plugin.mjs @@ -0,0 +1,207 @@ +import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs"; + +// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only +// live, per-session bindings over the authenticated OpenCode RPC channel. +export async function registerSharedBridge(context) { + const state = await import(new URL("./state.mjs", import.meta.url).href); + const { bindings, definitions } = state.stateFor(context.location); + const registrations = new Map(); + let updating = Promise.resolve(); + async function request(binding, pathname, body, allowMissing = false, signal) { + const requestSignal = body === undefined ? AbortSignal.timeout(5_000) : signal; + const response = await fetch(new URL(`/_internal/opencode${pathname}`, binding.baseUrl), { + method: body === undefined ? "GET" : "POST", + headers: { Authorization: `Bearer ${binding.token}`, "Content-Type": "application/json" }, + signal: requestSignal, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (allowMissing && response.status === 404) return null; + const payload = await response.json().catch(() => null); + if (!response.ok) { + const detail = formatErrorDetail(payload?.issues ?? payload?.details); + const message = payload?.error ?? `Paseo tool bridge returned HTTP ${response.status}`; + const error = new Error(detail ? `${message}: ${detail}` : message); + error.status = response.status; + if (detail) error.details = detail; + throw error; + } + return payload; + } + async function find(sessionID) { + const seen = new Set(); + while (sessionID && !seen.has(sessionID)) { + seen.add(sessionID); + const binding = bindings.get(sessionID); + if (binding) return { ...binding, sessionID }; + sessionID = (await context.session.get({ sessionID })).parentID; + } + return null; + } + async function install(binding) { + const manifest = await request(binding, "/tools"); + for (const definition of manifest.tools) await installDefinition(definition); + } + async function installDefinition(definition) { + const signature = JSON.stringify(definition); + const known = definitions.get(definition.name); + if (known && JSON.stringify(known) !== signature) + throw new Error(`Conflicting Paseo tool definition: ${definition.name}`); + const old = registrations.get(definition.name); + if (old) { + if (old.signature !== signature) + throw new Error(`Conflicting Paseo tool definition: ${definition.name}`); + return; + } + const registration = await context.tool.transform((editor) => { + editor.add({ + name: `paseo_${definition.name}`, + description: definition.description, + input: definition.inputSchema, + options: { codemode: false }, + async execute(input, call) { + const scope = await find(call.sessionID); + if (!scope) throw new Error("OpenCode session is not bound to a Paseo agent"); + const result = await request( + scope, + `/sessions/${encodeURIComponent(scope.sessionID)}/tools/${encodeURIComponent(definition.name)}`, + input, + false, + call.signal, + ); + return { + content: result.content.map(toOpenCodeContent), + metadata: { paseoTool: definition.name }, + }; + }, + }); + }); + registrations.set(definition.name, { signature, registration }); + definitions.set(definition.name, definition); + } + async function clearUnusedDefinitions() { + if (bindings.size) return; + await Promise.all( + [...registrations.values()].map(({ registration }) => registration.dispose()), + ); + registrations.clear(); + definitions.clear(); + } + // Reinstall live tools before this plugin generation is made visible. + // Definitions are kept in memory, so unavailable owners cannot delay reload. + for (const definition of definitions.values()) await installDefinition(definition); + const object = { type: "object", additionalProperties: true }; + const rpc = await context.rpc.register( + { + id: "paseo.bridge", + methods: { + bind: { input: object, output: object }, + unbind: { input: object, output: object }, + refresh: { input: object, output: object }, + }, + events: {}, + }, + { + bind: async (input) => { + if ( + typeof input.sessionID !== "string" || + typeof input.baseUrl !== "string" || + typeof input.token !== "string" + ) + throw new Error("Invalid Paseo bridge binding"); + const next = updating + .catch(() => undefined) + .then(async () => { + const previous = bindings.get(input.sessionID); + if ( + previous && + (previous.baseUrl !== input.baseUrl || previous.token !== input.token) + ) { + let active; + try { + active = await request( + previous, + `/sessions/${encodeURIComponent(input.sessionID)}/context`, + undefined, + true, + ); + } catch (error) { + // Refusal or rejected credentials mean this endpoint no longer + // serves the previous binding. Other failures are inconclusive. + if ( + error?.status !== 401 && + error?.cause?.code !== "ECONNREFUSED" && + error?.code !== "ConnectionRefused" + ) + throw error; + } + if (active) + return { error: "OpenCode session is already bound to another Paseo bridge" }; + bindings.delete(input.sessionID); + await clearUnusedDefinitions(); + } + await install(input); + bindings.set(input.sessionID, { baseUrl: input.baseUrl, token: input.token }); + return {}; + }); + updating = next; + return await next; + }, + unbind: async (input) => { + const next = updating + .catch(() => undefined) + .then(async () => { + const binding = bindings.get(input.sessionID); + if (binding && binding.token === input.token && binding.baseUrl === input.baseUrl) + bindings.delete(input.sessionID); + await clearUnusedDefinitions(); + return {}; + }); + updating = next; + await next; + return {}; + }, + refresh: async () => { + await context.provider.reload(); + await context.model.reload(); + await context.agent.reload(); + await context.command.reload(); + await context.skill.reload(); + return {}; + }, + }, + ); + const filter = await context.session.hook("context", async (input) => { + const binding = await find(input.sessionID); + const scope = + binding && + (await request(binding, `/sessions/${encodeURIComponent(binding.sessionID)}/context?tools`)); + const allowed = new Set((scope?.tools ?? []).map((name) => `paseo_${name}`)); + for (const name of Object.keys(input.tools)) { + if (name.startsWith("paseo_") && name !== STRUCTURED_OUTPUT_TOOL && !allowed.has(name)) + delete input.tools[name]; + } + }); + return async () => { + await filter.dispose(); + await rpc.dispose(); + await Promise.all( + [...registrations.values()].map(({ registration }) => registration.dispose()), + ); + }; +} + +function toOpenCodeContent(part) { + if (part.type === "image") + return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType }; + return part; +} + +function formatErrorDetail(details) { + if (!Array.isArray(details)) return typeof details === "string" ? details : ""; + return details + .map((issue) => { + const path = Array.isArray(issue?.path) && issue.path.length ? issue.path.join(".") : "input"; + return `${path}: ${issue?.message ?? "Invalid value"}`; + }) + .join("; "); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts new file mode 100644 index 0000000000..c560fa6558 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/shared-bridge.test.ts @@ -0,0 +1,168 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, test } from "vitest"; +import { z } from "zod"; +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import { OpenCodeBridge } from "../bridge.js"; + +interface Binding { + sessionID: string; + baseUrl: string; + token: string; +} +interface TestTool { + name: string; + execute(input: { value: string }, call: { sessionID: string }): Promise; +} +interface TestContext { + sessionID: string; + tools: Record; + system: unknown[]; +} + +test("a shared plugin binds Paseo sessions without involving unrelated sessions", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-")); + const bridge = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() }); + const tool = { name: "echo", description: "echo", inputSchema: { value: z.string() } }; + const tools = new Map([[tool.name, tool]]); + bridge.setManifestCatalog({ tools }); + await bridge.start(); + const unbind = bridge.bindSession({ + sessionId: "paseo", + env: {}, + tools: { + tools, + getTool: (name) => tools.get(name), + executeTool: async (_name, input) => ({ + content: [{ type: "text", text: String((input as { value: string }).value) }], + }), + }, + }); + expect(() => bridge.bindSession({ sessionId: "paseo", env: {} })).toThrow("already bound"); + try { + const plugin = { + package: await bridge.materializeV2Plugin(), + options: bridge.sharedV2Connection(), + }; + const module = await import( + pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href + ); + const installed = new Map(); + const hooks: Array<(input: TestContext) => Promise> = []; + const refreshed: string[] = []; + let structured = false; + let rpc: Record Promise> = {}; + const context = { + options: {}, + location: { directory: home }, + provider: { + reload: async () => { + refreshed.push("provider"); + }, + }, + model: { + reload: async () => { + refreshed.push("model"); + }, + }, + agent: { + reload: async () => { + refreshed.push("agent"); + }, + }, + command: { + reload: async () => { + refreshed.push("command"); + }, + }, + skill: { + reload: async () => { + refreshed.push("skill"); + }, + }, + rpc: { + register: async (_definition: unknown, handlers: typeof rpc) => { + rpc = handlers; + return { dispose: async () => {} }; + }, + }, + tool: { + transform: async (fn: (editor: { add(item: TestTool): void }) => void) => { + fn({ add: (item) => installed.set(item.name, item) }); + return { dispose: async () => {} }; + }, + }, + session: { + context: async () => + structured ? [{ type: "user", metadata: { paseoOutputSchema: { type: "string" } } }] : [], + get: async () => ({ parentID: "" }), + hook: async (_name: string, fn: (input: TestContext) => Promise) => { + hooks.push(fn); + return { dispose: async () => {} }; + }, + }, + }; + let dispose = await module.default.setup(context); + await rpc.bind({ sessionID: "paseo", ...plugin.options }); + await rpc.refresh({ sessionID: "paseo", ...plugin.options }); + expect(refreshed).toEqual(["provider", "model", "agent", "command", "skill"]); + await expect( + rpc.bind({ sessionID: "paseo", ...plugin.options, token: "another-daemon" }), + ).resolves.toEqual({ error: "OpenCode session is already bound to another Paseo bridge" }); + const own = { sessionID: "paseo", tools: { paseo_echo: {}, native: {} }, system: [] }; + for (const hook of hooks) await hook(own); + expect(Object.keys(own.tools)).toEqual(["paseo_echo", "native"]); + expect( + await installed.get("paseo_echo")!.execute({ value: "ok" }, { sessionID: "paseo" }), + ).toMatchObject({ content: [{ type: "text", text: "ok" }] }); + await dispose(); + hooks.length = 0; + installed.clear(); + dispose = await module.default.setup(context); + const afterReload = { sessionID: "paseo", tools: { paseo_echo: {}, native: {} }, system: [] }; + for (const hook of hooks) await hook(afterReload); + expect(Object.keys(afterReload.tools)).toEqual(["paseo_echo", "native"]); + expect( + await installed.get("paseo_echo")!.execute({ value: "reloaded" }, { sessionID: "paseo" }), + ).toMatchObject({ content: [{ type: "text", text: "reloaded" }] }); + await bridge.close(); + await dispose(); + hooks.length = 0; + installed.clear(); + dispose = await module.default.setup(context); + const unrelated = { sessionID: "other", tools: { paseo_echo: {}, native: {} }, system: [] }; + for (const hook of hooks) await hook(unrelated); + expect(Object.keys(unrelated.tools)).toEqual(["native"]); + structured = true; + const structuredOnly = { + sessionID: "other", + tools: { paseo_structured_output: {} }, + system: [], + }; + for (const hook of hooks) await hook(structuredOnly); + expect(Object.keys(structuredOnly.tools)).toEqual(["paseo_structured_output"]); + const restarted = new OpenCodeBridge({ + paseoHome: home, + logger: createTestLogger(), + port: Number(new URL(plugin.options.baseUrl).port), + }); + restarted.setManifestCatalog({ tools }); + await restarted.start(); + const releaseRestarted = restarted.bindSession({ sessionId: "paseo", env: {} }); + try { + await expect( + rpc.bind({ sessionID: "paseo", ...restarted.sharedV2Connection() }), + ).resolves.toEqual({}); + } finally { + releaseRestarted(); + await restarted.close(); + } + await dispose(); + } finally { + unbind(); + await bridge.close(); + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/shared-service.ts b/packages/server/src/server/agent/providers/opencode/v2/shared-service.ts new file mode 100644 index 0000000000..26fcbb7d26 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/shared-service.ts @@ -0,0 +1,192 @@ +import { OpenCode } from "@opencode/client"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Logger } from "pino"; +import type { ProviderRuntimeSettings } from "../../../provider-launch-config.js"; +import type { OpenCodeBridge } from "../bridge.js"; +import { OpenCodeHttpError } from "../http-error.js"; +import { resolveOpenCodeHomeDir } from "../paths.js"; +import { openCodeConfigDirectory, provisionLocalBridge } from "./bridge-provisioning.js"; +import { awaitPaseoPlugin } from "./readiness.js"; +import { + compatibleVersion, + isLocalOpenCodeHost, + type OpenCodeServiceSelection, +} from "./service-selection.js"; +import type { V2Api } from "./api.js"; + +interface SharedServiceOptions { + logger: Logger; + settings?: ProviderRuntimeSettings; + bridge?: OpenCodeBridge; +} + +export interface SharedService { + client: V2Api; + exited: Promise; + stop(): Promise; + bindSession(sessionId: string, cwd: string): Promise; + unbindSession(sessionId: string, cwd: string): Promise; + refreshLocation(cwd: string, signal?: AbortSignal): Promise; +} + +export async function connectSharedService( + selection: Extract, + options: SharedServiceOptions, +): Promise { + const server = selection.server; + const { logger } = options; + const lifetime = new AbortController(); + const closed = new Error(`OpenCode external server connection closed (${server.url})`); + let resolveExited!: (error: Error) => void; + const exited = new Promise((resolve) => { + resolveExited = resolve; + }); + const authorization = server.password + ? `Basic ${Buffer.from(`opencode:${server.password}`).toString("base64")}` + : undefined; + const client = OpenCode.make({ + baseUrl: server.url, + ...(authorization ? { headers: { Authorization: authorization } } : {}), + fetch: async (request, init) => { + const signal = init?.signal + ? AbortSignal.any([init.signal, lifetime.signal]) + : lifetime.signal; + const response = await fetch(request, { ...init, signal }); + const html = response.headers.get("content-type")?.includes("text/html") ?? false; + if (!response.ok || html) { + const requestUrl = request instanceof Request ? request.url : String(request); + throw new OpenCodeHttpError(new URL(requestUrl).pathname, response.status, html); + } + return response; + }, + }); + let info: Awaited>; + try { + info = await client.server.info({ + signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(30_000)]), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot reach OpenCode v2 server at ${server.url}: ${message}`, { + cause: error, + }); + } + if (!compatibleVersion(info.version)) + throw new Error( + `Unsupported OpenCode version ${info.version}; version 2.0.10 or newer is required`, + ); + const bridge = options.bridge; + const pluginDirectory = bridge ? fileURLToPath(await bridge.materializeV2Plugin()) : undefined; + if (selection.kind === "discovered" && pluginDirectory) { + const env = { ...globalThis.process.env, ...options.settings?.env }; + const configDirectory = openCodeConfigDirectory(env); + const home = resolveOpenCodeHomeDir(); + await mkdir(home, { recursive: true }); + const entries = await client.config.get({ location: { directory: home } }); + if ( + !entries.some( + (entry) => + entry.type === "directory" && path.resolve(entry.path) === path.resolve(configDirectory), + ) + ) + throw new Error( + `OpenCode service does not use the expected configuration directory ${configDirectory}; configure the bridge on that service explicitly`, + ); + await provisionLocalBridge({ + configDirectory, + artifact: path.join(pluginDirectory, "server.js"), + }); + } + const advertised = + options.settings?.env?.PASEO_OPENCODE_BRIDGE_URL ?? + globalThis.process.env.PASEO_OPENCODE_BRIDGE_URL; + validateBridgeAddress(selection, bridge, advertised); + const generation: SharedService = { + client, + stop: async () => { + if (lifetime.signal.aborted) return; + lifetime.abort(closed); + resolveExited(closed); + }, + exited, + ...bridgeBindings(client, bridge, advertised, pluginDirectory), + }; + logger.info({ url: server.url }, "Using existing OpenCode v2 server"); + return generation; +} + +function validateBridgeAddress( + selection: Extract, + bridge: OpenCodeBridge | undefined, + advertised: string | undefined, +): void { + if (advertised) { + let bridgeUrl: URL; + try { + bridgeUrl = new URL(advertised); + } catch { + throw new Error("PASEO_OPENCODE_BRIDGE_URL must be an absolute HTTP or HTTPS URL"); + } + if (bridgeUrl.protocol !== "http:" && bridgeUrl.protocol !== "https:") + throw new Error("PASEO_OPENCODE_BRIDGE_URL must be an absolute HTTP or HTTPS URL"); + } + const hostname = new URL(selection.server.url).hostname; + if (bridge && selection.kind === "explicit" && !advertised && !isLocalOpenCodeHost(hostname)) + throw new Error( + "Remote OpenCode requires PASEO_OPENCODE_BRIDGE_URL pointing to a reachable Paseo bridge tunnel and the Paseo plugin installed on the server", + ); +} + +export function bridgeBindings( + client: ReturnType, + bridge: OpenCodeBridge | undefined, + advertised?: string, + pluginDirectory?: string, +): Pick { + return { + bindSession: async (sessionId, cwd) => { + if (!bridge) return; + const location = { directory: cwd }; + let result: Awaited>; + try { + await awaitPaseoPlugin({ client, location }); + result = await client.rpc.call({ + location, + rpcID: "paseo.bridge", + method: "bind", + input: { sessionID: sessionId, ...bridge.sharedV2Connection(advertised) }, + }); + } catch (error) { + throw new Error( + `Cannot connect the Paseo tool bridge to OpenCode. Install the plugin from ${pluginDirectory ?? "Paseo's runtime directory"} and configure a reachable bridge URL: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + if (result.output && typeof result.output === "object" && "error" in result.output) + throw new Error(String(result.output.error)); + }, + unbindSession: async (sessionId, cwd) => { + if (bridge) + await client.rpc.call( + { + location: { directory: cwd }, + rpcID: "paseo.bridge", + method: "unbind", + input: { sessionID: sessionId, ...bridge.sharedV2Connection(advertised) }, + }, + { signal: AbortSignal.timeout(2_000) }, + ); + }, + refreshLocation: async (cwd, signal) => { + if (!bridge) return; + const location = { directory: cwd }; + await awaitPaseoPlugin({ client, location, signal }); + await client.rpc.call( + { location, rpcID: "paseo.bridge", method: "refresh", input: {} }, + { signal }, + ); + }, + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/state.mjs b/packages/server/src/server/agent/providers/opencode/v2/state.mjs new file mode 100644 index 0000000000..858be8f14a --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/state.mjs @@ -0,0 +1,11 @@ +// OpenCode reloads the plugin entry point independently from this module. +// Keep live bindings and tool definitions in memory across those reloads. +const locations = new Map(); + +export function stateFor(location) { + const key = JSON.stringify([location.directory, location.workspaceID ?? null]); + if (!locations.has(key)) { + locations.set(key, { bindings: new Map(), definitions: new Map() }); + } + return locations.get(key); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts index aa74faa6cf..b8c118f037 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js"; import { V2Timeline } from "./timeline.js"; import type { SessionMessageAssistant } from "@opencode/client"; import { describe, expect, test } from "vitest"; @@ -80,7 +81,7 @@ describe("OpenCode v2 token streaming", () => { } }); - test("deduplicates snapshots before and after deltas using per-type ordinals", () => { + test("deduplicates snapshots before and after deltas using per-type ordinals", async () => { const timeline = new V2Timeline(); const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; const reasoning = { ...text, type: "reasoning" } as const; @@ -99,24 +100,28 @@ describe("OpenCode v2 token streaming", () => { }, { type: "text", text: "Hello" }, ]); - expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]); + expect(await collectTimelineSnapshot(timeline, [snapshot])).toMatchObject([ + { item: { text: "lo" } }, + ]); expect(timeline.delta({ ...text, delta: "lo" })).toBeNull(); - expect(timeline.messages([snapshot])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]); expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } }); - expect(timeline.messages([snapshot])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]); }); - test("recovers missed fragments from snapshots after reconnect", () => { + test("recovers missed fragments from snapshots after reconnect", async () => { const timeline = new V2Timeline(); const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; timeline.startPart(part); expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } }); timeline.resetStreams(); expect(timeline.delta({ ...part, delta: "!" })).toBeNull(); - expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([ - { item: { text: "lo!" } }, - ]); - expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]); + expect( + await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]), + ).toMatchObject([{ item: { text: "lo!" } }]); + expect( + await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]), + ).toEqual([]); }); test("withholds streamed prose during a structured-output turn", async () => { @@ -203,3 +208,62 @@ describe("OpenCode v2 token streaming", () => { } }); }); + +test.each(["text", "reasoning"] as const)( + "publishes snapshot %s before a live suffix during image materialization", + async (type) => { + const timeline = new V2Timeline(); + const part = { assistantMessageID: "answer", type, ordinal: 0 }; + timeline.startPart(part); + const emitted: AgentStreamEvent[] = []; + let settled = false; + const snapshot = timeline + .messages( + [ + assistant([ + { type, text: "Hello" }, + { + type: "tool", + id: "screenshot", + name: "paseo_browser_screenshot", + executed: false, + state: { + status: "completed", + input: {}, + content: [ + { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png" }, + ], + metadata: {}, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ]), + ], + (event) => { + emitted.push(event); + }, + ) + .then(() => { + settled = true; + return undefined; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + const delta = timeline.delta({ ...part, delta: "Hello world" }); + if (delta) emitted.push(delta); + await snapshot; + const text = emitted.flatMap((event) => { + if (event.type !== "timeline") return []; + if ( + type === "text" && + event.item.type === "assistant_message" && + !event.item.text.includes("![Image]") + ) + return [event.item.text]; + if (type === "reasoning" && event.item.type === "reasoning") return [event.item.text]; + return []; + }); + expect(text).toEqual(["Hello", " world"]); + expect(text.join("")).toBe("Hello world"); + }, +); diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts index d86b9a7dcb..6e9b886c09 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts @@ -1,3 +1,4 @@ +import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js"; import type { SessionMessageAssistant } from "@opencode/client"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -15,7 +16,7 @@ function assistant(content: SessionMessageAssistant["content"]): SessionMessageA }; } -test("a completed edit carries the replaced and replacement text", () => { +test("a completed edit carries the replaced and replacement text", async () => { // Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`. const edit = assistant([ { @@ -45,7 +46,7 @@ test("a completed edit carries the replaced and replacement text", () => { }, ]); - expect(new V2Timeline().messages([edit])).toMatchObject([ + expect(await collectTimelineSnapshot(new V2Timeline(), [edit])).toMatchObject([ { item: { type: "tool_call", @@ -57,7 +58,7 @@ test("a completed edit carries the replaced and replacement text", () => { ]); }); -test("a tool's image output is shown as an image, not as base64 in the tool output", () => { +test("a tool's image output is shown as an image, not as base64 in the tool output", async () => { const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; const screenshot = assistant([ @@ -80,7 +81,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp ]); const timeline = new V2Timeline(); - const events = timeline.messages([screenshot]); + const events = await collectTimelineSnapshot(timeline, [screenshot]); expect(JSON.stringify(events)).not.toContain(png); expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([ @@ -95,7 +96,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp text: expect.stringMatching(/^\n\n!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)\n\n$/), }, ]); - expect(timeline.messages([screenshot])).toEqual([]); + expect(await collectTimelineSnapshot(timeline, [screenshot])).toEqual([]); }); test("a surrogate pair split across deltas streams back intact", () => { diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts index 0fdd6f9f40..5596443bbb 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts @@ -55,18 +55,32 @@ export class V2Timeline { return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() }; } - messages(messages: SessionMessageInfo[]): AgentStreamEvent[] { - const events: AgentStreamEvent[] = []; + private pendingMessages: Promise = Promise.resolve(); + + // Publish each cursor advance in the same synchronous step. Buffering snapshot + // events until image IO finishes lets live deltas overtake already committed text. + messages( + messages: SessionMessageInfo[], + publish: (event: AgentStreamEvent) => void, + ): Promise { + const result = this.pendingMessages.then(() => this.publishMessages(messages, publish)); + this.pendingMessages = result.catch(() => {}); + return result; + } + + private async publishMessages( + messages: SessionMessageInfo[], + publish: (event: AgentStreamEvent) => void, + ): Promise { const state: TimelineState = { structured: false, accepted: false }; for (const message of messages) { const timestamp = new Date(message.time.created).toISOString(); const push = (item: AgentTimelineItem) => - events.push({ type: "timeline", provider: "opencode", item, timestamp }); + publish({ type: "timeline", provider: "opencode", item, timestamp }); if (message.type === "user") this.userMessage(message, state, push); - else if (message.type === "assistant") this.assistantMessage(message, state, push); + else if (message.type === "assistant") await this.assistantMessage(message, state, push); else if (message.type === "compaction") this.compactionMessage(message, push); } - return events; } private userMessage( @@ -89,7 +103,7 @@ export class V2Timeline { }); } - private assistantMessage( + private async assistantMessage( message: Extract, state: TimelineState, push: (item: AgentTimelineItem) => void, @@ -99,16 +113,16 @@ export class V2Timeline { // paths address the same cursor entry. let textOrdinal = 0; let reasoningOrdinal = 0; - message.content.forEach((part, partIndex) => { + for (const [partIndex, part] of message.content.entries()) { if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) { this.structuredOutputPart(message.id, partIndex, part, state, push); } else if (part.type === "text" || part.type === "reasoning") { const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++; this.textPart(message.id, part, ordinal, state, push); } else { - this.toolPart(message.id, partIndex, part, push); + await this.toolPart(message.id, partIndex, part, push); } - }); + } } private structuredOutputPart( @@ -156,7 +170,7 @@ export class V2Timeline { else push({ type: "reasoning", text: suffix }); } - private toolPart( + private async toolPart( messageID: string, partIndex: number, part: Extract< @@ -174,16 +188,16 @@ export class V2Timeline { if (item) push(item); // Images follow their tool call as markdown, as Claude and Codex emit them, // so base64 never reaches the tool output. - images.forEach((image, index) => { + for (const [index, image] of images.entries()) { const imageKey = `${key}:image:${index}`; - if (this.content.has(imageKey)) return; + if (this.content.has(imageKey)) continue; this.content.set(imageKey, ""); - const imageItem = renderProviderImageOutputAsAssistantMarkdown( + const imageItem = await renderProviderImageOutputAsAssistantMarkdown( { url: image.uri, mimeType: image.mime }, { materialize: materializeProviderImage }, ); if (imageItem) push(imageItem); - }); + } } private compactionMessage( diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 927a9defd2..7dc3fd184c 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -5,11 +5,13 @@ import { mkdirSync, mkdtempSync, openSync, + readFileSync, readSync, + realpathSync, rmSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { setImmediate as waitForImmediate } from "node:timers/promises"; @@ -23,6 +25,7 @@ import { PiRpcAgentSession, transformPiModels, } from "./agent.js"; +import { PiCliRuntime } from "./cli-runtime.js"; import { FakePi } from "./test-utils/fake-pi.js"; import { createPiExtensionHost } from "./extensions/index.js"; import { PiExtensionHost } from "./extensions/host.js"; @@ -2719,6 +2722,7 @@ describe("PiRpcAgentClient", () => { const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-import-config-")); const cwd = path.join(root, "workspace"); const sessionsDir = path.join(root, "sessions"); + mkdirSync(cwd, { recursive: true }); mkdirSync(sessionsDir, { recursive: true }); const sessionFile = path.join(sessionsDir, "20260103_session.jsonl"); writeFileSync( @@ -3063,6 +3067,81 @@ describe("PiRpcAgentClient", () => { ]); }); + test.each([ + { outcome: "successful", error: null }, + { outcome: "failed", error: new Error("summarizer failed") }, + ])("waits for $outcome manual compaction before sending a new prompt", async ({ error }) => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + fakeSession.compactError = error; + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + + const { turnId } = await session.startTurn("continue after compaction"); + await flushTurnScheduling(); + expect(fakeSession.compactRequests).toEqual([{}]); + expect(fakeSession.prompts).toEqual([]); + + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + expect(fakeSession.prompts).toEqual([{ message: "continue after compaction", imageCount: 0 }]); + + fakeSession.finishTurn(); + await events.nextTurnCompletion(); + expect(events.turnLifecycleEvents()).toContainEqual({ type: "turn_completed", turnId }); + expect(events.eventTypes()).not.toContain("turn_failed"); + }); + + test.each(["interrupt", "close"] as const)( + "does not send a prompt waiting on compaction after %s", + async (action) => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + + await session.startTurn("do not send this prompt"); + await session[action](); + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + + expect(fakeSession.prompts).toEqual([]); + }, + ); + + test("does not send a prompt when compaction finishes during interruption", async () => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const clearQueueFinished = Promise.withResolvers(); + fakeSession.clearQueueCompletion = clearQueueFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + const { turnId } = await session.startTurn("do not send this prompt"); + + const interruption = session.interrupt(); + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + expect(fakeSession.controlRequests).toEqual(["clear_queue"]); + expect(fakeSession.prompts).toEqual([]); + + clearQueueFinished.resolve(); + await interruption; + expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_canceled", turnId }]); + expect(fakeSession.prompts).toEqual([]); + }); + test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => { const { pi, session } = await createSession(); const fakeSession = pi.latestSession(); @@ -3457,6 +3536,320 @@ describe("PiRpcAgentClient", () => { }); }); +describe("PiRpcAgentClient history resume", () => { + const BUILTIN_MCP_COMMAND = { + name: "mcp", + source: "extension", + sourceInfo: { path: "builtin:mcp" }, + } as const; + const PASEO_MCP_SERVERS = { + paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" }, + } as const; + + interface ArchivedPiSession { + handle: { + provider: string; + sessionId: string; + nativeHandle: string; + metadata: { cwd: string; model?: string }; + }; + sessionFile: string; + header: Record; + body: string; + originalBytes: Buffer; + } + + function createDeletedFolder(): string { + const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-deleted-cwd-")); + rmSync(folder, { recursive: true, force: true }); + return folder; + } + + function createFileWhereFolderWas(): string { + const folder = createDeletedFolder(); + writeFileSync(folder, "not a directory"); + onTestFinished(() => rmSync(folder, { force: true })); + return folder; + } + + function createExistingFolder(): string { + const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-existing-cwd-")); + onTestFinished(() => rmSync(folder, { recursive: true, force: true })); + return folder; + } + + function writeArchivedSession(cwd: string, metadata: { model?: string } = {}): ArchivedPiSession { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-archived-session-")); + onTestFinished(() => rmSync(dir, { recursive: true, force: true })); + const sessionFile = path.join(dir, "2026-01-01_pi-session-1.jsonl"); + const header = { + type: "session", + version: 3, + id: "pi-session-1", + timestamp: "2026-01-01T00:00:00.000Z", + cwd, + }; + const body = [ + JSON.stringify({ + type: "message", + id: "entry-1", + parentId: null, + message: { role: "user", content: "Où est le café ☕ ?" }, + }), + JSON.stringify({ + type: "message", + id: "entry-2", + parentId: "entry-1", + message: { role: "assistant", content: [{ type: "text", text: "Dans la cuisine." }] }, + }), + ] + .map((line) => `${line}\n`) + .join(""); + writeFileSync(sessionFile, `${JSON.stringify(header)}\n${body}`); + return { + handle: { + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: sessionFile, + metadata: { cwd, ...metadata }, + }, + sessionFile, + header, + body, + originalBytes: readFileSync(sessionFile), + }; + } + + function readSessionCopy(sessionFile: string): { header: unknown; body: string } { + const content = readUtf8File(sessionFile); + const headerEnd = content.indexOf("\n"); + return { + header: JSON.parse(content.slice(0, headerEnd)), + body: content.slice(headerEnd + 1), + }; + } + + function resumeArchived( + client: PiRpcAgentClient, + archived: ArchivedPiSession, + purpose: "history" | "interactive", + overrides: Partial = {}, + ): Promise { + return client.resumeSession(archived.handle, overrides, undefined, { purpose }); + } + + function writeFakePiProcess(): { + command: [string, ...string[]]; + readLaunches: () => Array<{ cwd: string; session: string | null }>; + } { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-process-")); + onTestFinished(() => rmSync(dir, { recursive: true, force: true })); + const scriptPath = path.join(dir, "fake-pi.cjs"); + const launchesPath = path.join(dir, "launches.jsonl"); + writeFileSync( + scriptPath, + ` +const fs = require("node:fs"); +const readline = require("node:readline"); +const [launchesPath, ...piArgs] = process.argv.slice(2); +const sessionFlag = piArgs.indexOf("--session"); +const sessionFile = sessionFlag === -1 ? null : piArgs[sessionFlag + 1]; +fs.appendFileSync(launchesPath, JSON.stringify({ cwd: process.cwd(), session: sessionFile }) + "\\n"); +if (sessionFile) { + const header = JSON.parse(fs.readFileSync(sessionFile, "utf8").split("\\n", 1)[0]); + if (header.cwd && !fs.existsSync(header.cwd)) { + fs.writeSync( + 2, + "Stored session working directory does not exist: " + header.cwd + "\\nSession file: " + sessionFile + "\\nCurrent working directory: " + process.cwd() + "\\n", + ); + process.exit(1); + } +} +const models = [ + { provider: "openrouter", id: "a" }, + { provider: "openrouter", id: "b" }, +]; +const state = { + sessionId: "pi-session-1", + sessionFile, + model: models[0], + thinkingLevel: "medium", + isStreaming: false, + isCompacting: false, + messageCount: 0, + pendingMessageCount: 0, +}; +const commands = [${JSON.stringify(BUILTIN_MCP_COMMAND)}]; +function handle(request) { + switch (request.type) { + case "get_state": + return state; + case "get_commands": + return { commands }; + case "get_available_models": + return { models }; + case "set_model": + fs.appendFileSync( + sessionFile, + JSON.stringify({ type: "model_change", id: "model-1", provider: request.provider, modelId: request.modelId }) + "\\n", + ); + state.model = models.find((model) => model.id === request.modelId); + return state.model; + default: + return {}; + } +} +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + process.stdout.write( + JSON.stringify({ id: request.id, type: "response", command: request.type, success: true, data: handle(request) }) + "\\n", + ); +}); +`, + ); + return { + command: [process.execPath, scriptPath, launchesPath], + readLaunches: () => + readUtf8File(launchesPath) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { cwd: string; session: string | null }), + }; + } + + test.each([ + ["is gone", createDeletedFolder], + ["is now a file", createFileWhereFolderWas], + ])( + "a history resume of an agent whose folder %s starts Pi from the home directory on a copy of its session", + async (_folderState, createFolder) => { + const archived = writeArchivedSession(createFolder()); + const pi = new FakePi(); + + const session = await resumeArchived(createClient(pi), archived, "history"); + onTestFinished(() => session.close()); + + expect(pi.recordedLaunches).toHaveLength(1); + const launch = pi.recordedLaunches[0]!; + expect(launch.cwd).toBe(homedir()); + expect(launch.session).not.toBe(archived.sessionFile); + expect(readSessionCopy(launch.session!)).toEqual({ + header: { ...archived.header, cwd: homedir() }, + body: archived.body, + }); + }, + ); + + test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => { + const archived = writeArchivedSession(createDeletedFolder()); + const pi = new FakePi(); + + const session = await resumeArchived(createClient(pi), archived, "history"); + const sessionCopy = pi.recordedLaunches[0]!.session!; + expect(existsSync(sessionCopy)).toBe(true); + await session.close(); + + expect(existsSync(path.dirname(sessionCopy))).toBe(false); + expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes); + }); + + test("a history resume of an agent whose folder is gone persists the original session file while Pi reports the copy", async () => { + const storedCwd = createDeletedFolder(); + const archived = writeArchivedSession(storedCwd); + const pi = new FakePi(); + + const session = await resumeArchived(createClient(pi), archived, "history"); + onTestFinished(() => session.close()); + await session.getRuntimeInfo(); + + expect(pi.latestSession().state.sessionFile).toBe(pi.recordedLaunches[0]!.session); + expect(session.describePersistence()).toMatchObject({ + nativeHandle: archived.sessionFile, + metadata: { cwd: storedCwd }, + }); + }); + + test("a history resume of an agent whose folder is gone removes its session copy when Pi fails to start", async () => { + const archived = writeArchivedSession(createDeletedFolder()); + const pi = new FakePi(); + pi.queueSessionSetup((session) => { + session.getStateError = new Error("Pi RPC process exited with code 1 and signal null"); + }); + + await expect(resumeArchived(createClient(pi), archived, "history")).rejects.toThrow( + "Pi RPC process exited with code 1", + ); + + expect(pi.recordedLaunches).toHaveLength(1); + expect(pi.recordedLaunches[0]!.session).not.toBe(archived.sessionFile); + expect(existsSync(path.dirname(pi.recordedLaunches[0]!.session!))).toBe(false); + }); + + test("a history resume of an agent whose folder is gone checks MCP support from the home directory", async () => { + const archived = writeArchivedSession(createDeletedFolder()); + const pi = new FakePi(); + pi.queueCommands([BUILTIN_MCP_COMMAND]); + + const session = await resumeArchived(createClient(pi), archived, "history", { + mcpServers: PASEO_MCP_SERVERS, + }); + onTestFinished(() => session.close()); + + expect(pi.recordedLaunches.map((launch) => launch.cwd)).toEqual([homedir(), homedir()]); + expect(session.capabilities.supportsMcpServers).toBe(true); + }); + + test("an interactive resume of an agent whose folder is gone still fails in its stored folder", async () => { + const storedCwd = createDeletedFolder(); + const archived = writeArchivedSession(storedCwd); + const pi = new FakePi(); + + await expect(resumeArchived(createClient(pi), archived, "interactive")).rejects.toThrow( + `Stored session working directory does not exist: ${storedCwd}`, + ); + + expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]); + }); + + test("a history resume of an agent whose folder exists keeps the stored folder and session file", async () => { + const storedCwd = createExistingFolder(); + const archived = writeArchivedSession(storedCwd); + const pi = new FakePi(); + + const session = await resumeArchived(createClient(pi), archived, "history"); + onTestFinished(() => session.close()); + + expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]); + }); + + test("a history resume of an agent whose folder is gone opens its session in a real Pi process started from the home directory", async () => { + const archived = writeArchivedSession(createDeletedFolder(), { model: "openrouter/b" }); + const fakePiProcess = writeFakePiProcess(); + const logger = pino({ level: "silent" }); + const client = new PiRpcAgentClient({ + logger, + runtime: new PiCliRuntime({ logger, command: fakePiProcess.command }), + }); + + const session = await resumeArchived(client, archived, "history", { + mcpServers: PASEO_MCP_SERVERS, + }); + onTestFinished(() => session.close()); + + const launches = fakePiProcess.readLaunches(); + const home = realpathSync.native(homedir()); + expect(launches.map((launch) => realpathSync.native(launch.cwd))).toEqual([home, home]); + expect(session.capabilities.supportsMcpServers).toBe(true); + const sessionCopy = launches[1]!.session!; + expect(readUtf8File(sessionCopy)).toContain('"type":"model_change"'); + expect(session.describePersistence()?.nativeHandle).toBe(archived.sessionFile); + + await session.close(); + expect(existsSync(path.dirname(sessionCopy))).toBe(false); + expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes); + }); +}); + describe("transformPiModels", () => { test("normalizes labels that include the upstream provider prefix", () => { expect( @@ -3488,3 +3881,194 @@ describe("transformPiModels", () => { ]); }); }); + +test("pi runtime safety protects queued replies and runtime-owned activity", async () => { + const runtime = new FakePi(); + const session = await createClient(runtime).createSession(createConfig()); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + expect(await session.canStopRuntime!()).toBe(true); + native.state.pendingMessageCount = 1; + expect(await session.canStopRuntime!()).toBe(false); + native.state.pendingMessageCount = 0; + native.state.isStreaming = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isStreaming = false; + native.state.isCompacting = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isCompacting = false; + expect(await session.canStopRuntime!()).toBe(true); + native.getStateError = new Error("Unable to prove idle"); + await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle"); +}); + +test("reserves the Pi turn while an image prompt is materialized", async () => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const first = session.startTurn([ + { type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" }, + ]); + await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active"); + await first; + expect(pi.latestSession().prompts).toHaveLength(1); +}); + +test("interrupting Pi during image conversion cancels the admitted turn and permits a follow-up", async () => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + let admitted = false; + const first = session + .startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 51).toString("base64"), + mimeType: "image/png", + }, + ]) + .then((result) => { + admitted = true; + return result; + }); + await waitForImmediate(); + expect(admitted).toBe(false); + expect(pi.latestSession().prompts).toHaveLength(0); + await session.interrupt(); + const followup = await session.startTurn("follow-up after image interrupt"); + const original = await first; + expect(events).toContainEqual( + expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }), + ); + expect(followup.turnId).not.toBe(original.turnId); + expect(pi.latestSession().prompts).toEqual([ + { message: "follow-up after image interrupt", imageCount: 0 }, + ]); +}); + +test("closing Pi during image conversion rejects its pending start", async () => { + const { pi, session } = await createSession(); + const first = session.startTurn([ + { + type: "image", + data: Buffer.alloc(16 * 1024 * 1024, 52).toString("base64"), + mimeType: "image/png", + }, + ]); + const rejected = expect(first).rejects.toThrow("closed"); + await waitForImmediate(); + expect(pi.latestSession().prompts).toHaveLength(0); + await session.close(); + await rejected; +}); + +describe("Pi tool-result images", () => { + test.each([false, true])( + "tool-result images precede the reply and completion (isError=%s)", + async (isError) => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + await session.startTurn("run"); + fakeSession.emit({ + type: "tool_execution_start", + toolCallId: "shot-1", + toolName: "screenshot", + args: {}, + }); + fakeSession.emit({ + type: "tool_execution_end", + toolCallId: "shot-1", + toolName: "screenshot", + result: { + content: [ + { type: "text", text: "captured" }, + { type: "image", data: Buffer.from("hello").toString("base64"), mimeType: "image/png" }, + ], + structuredContent: { + type: "image", + data: Buffer.from("hello").toString("base64"), + mimeType: "image/png", + note: "captured", + }, + }, + isError, + }); + fakeSession.emit({ + type: "message_update", + assistantMessageEvent: { type: "text_delta", delta: "Image read complete." }, + }); + fakeSession.finishTurn(); + await events.nextTurnCompletion(); + + const items = events.timelineItems(); + const toolIndex = items.findIndex( + (item) => + item.type === "tool_call" && + item.callId === "shot-1" && + item.status === (isError ? "failed" : "completed"), + ); + expect(toolIndex).toBeGreaterThanOrEqual(0); + // Base64 must never reach the tool output stringifier. + expect(JSON.stringify(items[toolIndex])).not.toContain( + Buffer.from("hello").toString("base64"), + ); + expect(items.slice(toolIndex + 1)).toEqual([ + expect.objectContaining({ + type: "assistant_message", + text: expect.stringContaining("![Image]("), + }), + expect.objectContaining({ type: "assistant_message", text: "Image read complete." }), + ]); + expect(events.timelineAndCompletionEvents().at(-1)).toEqual({ type: "turn_completed" }); + }, + ); +}); + +test("Pi image rendering preserves turn ownership across an interrupt and follow-up", async () => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const emitted: AgentStreamEvent[] = []; + session.subscribe((event) => emitted.push(event)); + const native = pi.latestSession(); + const first = await session.startTurn("read image"); + native.emit({ + type: "tool_execution_end", + toolCallId: "large-image", + toolName: "screenshot", + result: { + content: [ + { + type: "image", + data: Buffer.alloc(8 * 1024 * 1024, 73).toString("base64"), + mimeType: "image/png", + }, + ], + }, + isError: false, + }); + native.finishTurn(); + await session.interrupt(); + const followup = await session.startTurn("follow-up after image"); + await waitForImmediate(); + await waitForImmediate(); + await expect + .poll(() => + events + .timelineItems() + .some((item) => item.type === "assistant_message" && item.text.includes("![Image](")), + ) + .toBe(true); + expect(events.turnCompletedEvents().some((event) => event.turnId === followup.turnId)).toBe( + false, + ); + const imageEvent = emitted.find( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes("![Image]("), + ); + expect(imageEvent).toMatchObject({ turnId: first.turnId }); + native.finishTurn(); + await events.nextTurnCompletion(); +}); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 146b56b36f..8051c57fa9 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -2,7 +2,7 @@ import { mapCustomMessageToToolCall } from "../custom-message.js"; import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { join, resolve as resolvePath } from "node:path"; +import { basename, join, resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; import stripAnsi from "strip-ansi"; import { z } from "zod"; @@ -22,6 +22,7 @@ import { type AgentPersistenceHandle, type AgentPromptInput, type AgentProvider, + type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, @@ -64,15 +65,24 @@ import { streamPiHistory, type PiCapturedUserMessageEntry, } from "./history-mapper.js"; -import { materializeProviderImage } from "../provider-image-output.js"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, + type ProviderImageOutput, +} from "../provider-image-output.js"; import { PiCliRuntime } from "./cli-runtime.js"; import { createPiExtensionHost, piExtensionRuntimeBridge, type PiExtensionEventOutput, } from "./extensions/index.js"; +import { resolveResumeProcessCwd } from "../resume-process-cwd.js"; import { revertPiConversation } from "./rewind.js"; -import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; +import { + copyPiSessionFileWithCwd, + listPiImportableSessions, + readPiImportSessionConfig, +} from "./session-descriptor.js"; import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js"; import type { PiAgentSessionEvent, @@ -89,6 +99,7 @@ import { mapToolDetail, parseToolArgs, parseToolResult, + splitPiToolResultImages, type PiToolResult, type PiTrackedToolCall, } from "./tool-call-mapper.js"; @@ -240,6 +251,7 @@ interface PiRpcAgentSessionOptions { initialState: PiSessionState; capabilities: AgentCapabilityFlags; currentModeId?: string | null; + persistedSessionFile?: string; cleanup?: () => void; extensionTimeoutMs?: number; logger: Logger; @@ -367,9 +379,9 @@ function piModelSupportsImageInput(model: PiModel | null | undefined): boolean { return model?.input?.includes("image") === true; } -function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string { +async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise { try { - const materialized = materializeProviderImage({ + const materialized = await materializeProviderImage({ data: image.data, mimeType: image.mimeType, }); @@ -379,10 +391,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str } } -function convertPromptInput( +async function convertPromptInput( prompt: AgentPromptInput, options: { model: PiModel | null | undefined }, -): PiPromptPayload { +): Promise { if (typeof prompt === "string") { return { text: prompt }; } @@ -405,7 +417,7 @@ function convertPromptInput( mimeType: block.mimeType, }); } else { - textParts.push(renderTextOnlyImageHint(block)); + textParts.push(await renderTextOnlyImageHint(block)); } continue; } @@ -487,13 +499,14 @@ function buildResumeConfig( function buildResumeStartInput(input: { resumeConfig: PiResumeConfig; + processCwd: string; sessionFile: string; launchContext: AgentLaunchContext | undefined; mcpConfigFile: PiTempFile | null; paseoExtension: PiTempFile | null; }): PiStartSessionInput { return { - cwd: input.resumeConfig.cwd, + cwd: input.processCwd, env: input.launchContext?.env, session: input.sessionFile, thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, @@ -613,6 +626,19 @@ function createPiMcpConfigFile( }; } +async function createPiSessionCopy(sessionFile: string, cwd: string): Promise { + const dir = mkdtempSync(join(tmpdir(), "paseo-pi-session-")); + const cleanup = () => rmSync(dir, { recursive: true, force: true }); + const filePath = join(dir, basename(sessionFile)); + try { + await copyPiSessionFileWithCwd({ sourceFile: sessionFile, targetFile: filePath, cwd }); + } catch (error) { + cleanup(); + throw error; + } + return { path: filePath, cleanup }; +} + function createPiPaseoExtensionFile({ systemPrompt, mcpServers = {}, @@ -1189,6 +1215,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly contextUserEntries: PiCapturedEntry[] = []; private readonly treeUserEntriesById = new Map(); private readonly pendingExtensionResults = new Map(); + private compactionPromise: Promise | null = null; private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; private outOfBandCompactionStarted = false; private outOfBandCompactionCompleted = false; @@ -1200,6 +1227,7 @@ export class PiRpcAgentSession implements AgentSession { private closed = false; private readonly closeController = new AbortController(); private readonly pendingExtensionHydrations = new Set>(); + private pendingOutput: Promise | null = null; // Pi publishes the terminal before acknowledging abort. Autonomous runs have no // turn ID; retain their errors too until the cancellation request settles. private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null; @@ -1225,6 +1253,7 @@ export class PiRpcAgentSession implements AgentSession { this.capabilities = options.capabilities; this.provider = PI_PROVIDER; this.currentModeId = options.currentModeId ?? null; + this.persistedSessionFile = options.persistedSessionFile; this.cleanup = options.cleanup; this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS; this.logger = options.logger; @@ -1255,6 +1284,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly runtimeSession: PiRuntimeSession; private readonly config: AgentSessionConfig; + private readonly persistedSessionFile?: string; private readonly cleanup?: () => void; private readonly extensionTimeoutMs: number; @@ -1279,7 +1309,6 @@ export class PiRpcAgentSession implements AgentSession { throw new Error("A Pi turn is already active"); } - const payload = convertPromptInput(prompt, { model: this.state.model }); const turnId = randomUUID(); this.activeTurnId = turnId; this.usagePoller.startTurn(); @@ -1291,11 +1320,23 @@ export class PiRpcAgentSession implements AgentSession { this.activePromptRequestId = null; this.pendingSteerSubmissions.length = 0; this.clearNoTurnBuffers(); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed) throw new Error("Pi session is closed"); + if (this.activeTurnId !== turnId) return { turnId }; this.activeNoTurnPromptText = payload.text; const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null; void (async () => { try { + if (this.compactionPromise) { + // Pi RPC rejects prompts until the manual compaction request settles. + await this.compactionPromise.catch(() => undefined); + const turnCanceled = + this.closed || this.activeTurnId !== turnId || this.interruptingTurn?.turnId === turnId; + if (turnCanceled) { + return; + } + } const ack = await this.runtimeSession.prompt(payload.text, payload.images); this.activePromptRequestId = ack.requestId ?? null; const correlatedResult = ack.requestId @@ -1353,7 +1394,9 @@ export class PiRpcAgentSession implements AgentSession { if (this.closed || this.activeTurnId !== options.expectedTurnId) { return { status: "unavailable" }; } - const payload = convertPromptInput(prompt, { model: this.state.model }); + const payload = await convertPromptInput(prompt, { model: this.state.model }); + if (this.closed || this.activeTurnId !== options.expectedTurnId) + return { status: "unavailable" }; // Pi rejects steer RPCs that are extension commands, so slash inputs keep the // interrupt-and-replace fallback where they can run directly. if (this.parseSlashCommandInput(payload.text)) { @@ -1476,7 +1519,7 @@ export class PiRpcAgentSession implements AgentSession { return { provider: this.provider, sessionId: this.state.sessionId, - nativeHandle: this.state.sessionFile, + nativeHandle: this.persistedSessionFile ?? this.state.sessionFile, metadata: { cwd: this.config.cwd, ...(this.config.model ? { model: this.config.model } : {}), @@ -1581,6 +1624,23 @@ export class PiRpcAgentSession implements AgentSession { return await resultPromise; } + async canStopRuntime(): Promise { + if ( + this.closed || + this.activeTurnId || + this.pendingExtensionUiRequests.size || + this.pendingExtensionResults.size || + this.pendingExtensionHydrations.size || + this.pendingOutput || + this.pendingSteerSubmissions.length || + this.pendingNoTurnOutputs.length || + this.interruptingTurn + ) + return false; + const state = await this.runtimeSession.getState(); + return !state.isStreaming && !state.isCompacting && state.pendingMessageCount === 0; + } + async close(): Promise { if (this.closed) { return; @@ -1592,7 +1652,7 @@ export class PiRpcAgentSession implements AgentSession { try { await this.runtimeSession.close(); } finally { - await Promise.all(this.pendingExtensionHydrations); + await Promise.all([this.pendingOutput, ...this.pendingExtensionHydrations]); this.rejectAllExtensionResults(new Error("Pi session closed")); this.cleanup?.(); } @@ -1656,9 +1716,31 @@ export class PiRpcAgentSession implements AgentSession { } private emit(event: AgentStreamEvent): void { - for (const subscriber of this.subscribers) { - subscriber(event); + if (this.pendingOutput) { + this.emitAsync(() => Promise.resolve([event])); + return; } + this.publish(event); + } + + private publish(event: AgentStreamEvent): void { + for (const subscriber of this.subscribers) subscriber(event); + } + + // Keep timeline order while images are materialized, without delaying provider state changes. + private emitAsync(render: () => Promise): void { + const pending = (this.pendingOutput ?? Promise.resolve()) + .then(render) + .then((events) => { + if (!this.closed) for (const event of events) this.publish(event); + return undefined; + }) + .catch((error) => this.logger.error({ err: error }, "Pi output rendering failed")); + this.pendingOutput = pending; + void pending.then(() => { + if (this.pendingOutput === pending) this.pendingOutput = null; + return undefined; + }); } private emitExtensionOutput(output: PiExtensionEventOutput | undefined, turnId?: string): void { @@ -1788,7 +1870,8 @@ export class PiRpcAgentSession implements AgentSession { this.outOfBandCompactionStarted = false; this.outOfBandCompactionCompleted = false; try { - await this.runtimeSession.compact(customInstructions); + this.compactionPromise = this.runtimeSession.compact(customInstructions); + await this.compactionPromise; } catch (error) { const message = error instanceof Error ? error.message : String(error); if ( @@ -1814,6 +1897,7 @@ export class PiRpcAgentSession implements AgentSession { }, }); } finally { + this.compactionPromise = null; if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) { this.outOfBandCompactionEmit = null; this.outOfBandCompactionStarted = false; @@ -2283,8 +2367,11 @@ export class PiRpcAgentSession implements AgentSession { this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); this.activeToolCalls.delete(event.toolCallId); - const result = parseToolResult(event.result); - const error = event.isError ? event.result : null; + // Pull image blocks out of the result so base64 never reaches the tool output, then render + // each one as an assistant_message markdown image after the tool_call (mirroring the Claude + // provider, upstream #1717). + const { images, result } = splitPiToolResultImages(parseToolResult(event.result)); + const error = event.isError ? result : null; const status = event.isError ? "failed" : "completed"; this.extensionHost.onToolEnd({ callId: event.toolCallId, @@ -2294,6 +2381,30 @@ export class PiRpcAgentSession implements AgentSession { result, }); this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error); + if (images.length === 0) return; + const turnId = this.currentTurnIdForEvent(); + this.emitAsync(() => this.renderToolResultImages(images, turnId)); + } + + private async renderToolResultImages( + images: ProviderImageOutput[], + turnId: string | undefined, + ): Promise { + const events: AgentStreamEvent[] = []; + for (const image of images) { + const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, { + materialize: materializeProviderImage, + }); + if (imageItem && !this.closed) { + events.push({ + type: "timeline", + provider: this.provider, + turnId, + item: imageItem, + }); + } + } + return events; } private emitCompactionTimeline(input: { @@ -2557,6 +2668,7 @@ export class PiRpcAgentClient implements AgentClient { handle: AgentPersistenceHandle, overrides?: Partial, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise { const sessionFile = handle.nativeHandle; if (!sessionFile) { @@ -2565,6 +2677,7 @@ export class PiRpcAgentClient implements AgentClient { const persistenceMetadata = parsePersistenceMetadata(handle.metadata); const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider); + const processCwd = await resolveResumeProcessCwd(resumeConfig.cwd, options?.purpose); const providerOptions = PiProviderOptionsSchema.parse( resumeConfig.config.providerOptions ?? {}, @@ -2575,7 +2688,7 @@ export class PiRpcAgentClient implements AgentClient { ...launchContext?.env, }; const mcp = await this.prepareMcpInjection( - resumeConfig.cwd, + processCwd, resumeConfig.config.mcpServers, mcpEnv, runtime, @@ -2588,20 +2701,29 @@ export class PiRpcAgentClient implements AgentClient { ), mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined, }); + const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension]; + const cleanupTempFiles = () => { + for (const tempFile of tempFiles) tempFile.cleanup(); + }; + let sessionCopy: PiTempFile | null = null; let runtimeSession: PiRuntimeSession; try { + if (processCwd !== resumeConfig.cwd) { + sessionCopy = await createPiSessionCopy(sessionFile, processCwd); + tempFiles.push(sessionCopy); + } runtimeSession = await runtime.startSession( buildResumeStartInput({ resumeConfig, - sessionFile, + processCwd, + sessionFile: sessionCopy?.path ?? sessionFile, launchContext, mcpConfigFile, paseoExtension, }), ); } catch (error) { - mcpConfigFile?.cleanup(); - paseoExtension?.cleanup(); + cleanupTempFiles(); throw error; } try { @@ -2614,15 +2736,15 @@ export class PiRpcAgentClient implements AgentClient { }, initialState, capabilities: capabilitiesForSession(mcp !== null), - cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), + persistedSessionFile: sessionCopy ? sessionFile : undefined, + cleanup: cleanupTempFiles, extensionTimeoutMs: providerOptions.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfigFile?.cleanup(); - paseoExtension?.cleanup(); + cleanupTempFiles(); throw error; } } diff --git a/packages/server/src/server/agent/providers/pi/extensions/child-session.ts b/packages/server/src/server/agent/providers/pi/extensions/child-session.ts index 2013b831be..0c7c7df9aa 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/child-session.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/child-session.ts @@ -21,11 +21,11 @@ export function mapPiChildSession( return new PiChildSessionFollower(id, file, maxBytes).readNew(); } -function mapChildLine( +async function mapChildLine( mapper: PiHistoryMapper, id: string, line: string, -): ProviderSubagentInputEvent[] { +): Promise { let entry: { message?: PiAgentMessage; timestamp?: string }; try { entry = JSON.parse(line); @@ -33,7 +33,7 @@ function mapChildLine( return []; } if (!entry.message || typeof entry.message !== "object" || !("role" in entry.message)) return []; - return mapper.mapMessages([entry.message]).flatMap((mapped) => + return (await mapper.mapMessages([entry.message])).flatMap((mapped) => mapped.type === "timeline" ? [ { @@ -83,7 +83,10 @@ export class PiChildSessionFollower { const events: ProviderSubagentInputEvent[] = []; for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) { if (this.items >= MAX_ITEMS) break; - const mapped = mapChildLine(this.mapper, this.id, line).slice(0, MAX_ITEMS - this.items); + const mapped = (await mapChildLine(this.mapper, this.id, line)).slice( + 0, + MAX_ITEMS - this.items, + ); this.items += mapped.length; events.push(...mapped); } diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background-child-session.jsonl b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background-child-session.jsonl new file mode 100644 index 0000000000..ed668e807f --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background-child-session.jsonl @@ -0,0 +1,8 @@ +{"type": "session", "version": 3, "id": "e56a0006-f421-45be-9aef-e4019db1cf24", "timestamp": "2026-10-21T13:05:57.384Z", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/lean/sessions/2026-10-21T13-05-53-085Z_01a15e91-73bd-7282-82c6-d3974711a941.jsonl"} +{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:57.385Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": true, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/lean/agent", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "boundarySystemPrompt": false}, "id": "57355eeb", "parentId": null, "timestamp": "2026-10-21T13:05:57.386Z"} +{"type": "session_info", "id": "847415e4", "parentId": "57355eeb", "timestamp": "2026-10-21T13:05:57.566Z", "name": "[marker] Fixture marker verification"} +{"type": "model_change", "id": "d4ad59be", "parentId": "847415e4", "timestamp": "2026-10-21T13:05:57.672Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"} +{"type": "thinking_level_change", "id": "7f6bf9d2", "parentId": "d4ad59be", "timestamp": "2026-10-21T13:05:57.672Z", "thinkingLevel": "high"} +{"type": "message", "id": "737b762e", "parentId": "6b436446", "timestamp": "2026-10-21T13:05:57.679Z", "message": {"role": "user", "content": [{"type": "text", "text": "\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_LEAN_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n\n"}], "timestamp": 1792587957678}} +{"type": "message", "id": "100b110d", "parentId": "737b762e", "timestamp": "2026-10-21T13:06:02.050Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "PASEO_EDXETH_LEAN_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1713, "output": 13, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0, "totalTokens": 1726, "cost": {"input": 0.006852, "output": 0.00026000000000000003, "cacheRead": 0, "cacheWrite": 0, "total": 0.007111999999999999}}, "stopReason": "stop", "timestamp": 1792587957684, "responseId": "resp_0bc8bfaee079148a016ad8b8b7edb08191924676ed8dcfd447", "rawStopReason": "completed"}} +{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "80bbd3bb", "parentId": "100b110d", "timestamp": "2026-10-21T13:06:02.054Z"} diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background.json b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background.json new file mode 100644 index 0000000000..fe4913f6b6 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/background.json @@ -0,0 +1,263 @@ +{ + "provenance": { + "package": "Zeno2019/pi-subagents-lean", + "version": "2.9.2-lean.1", + "sourceCommit": "e10fe855808abcae193a205ea98f8ccd70d25620", + "piVersion": "0.87.1", + "captureDate": "2026-10-21" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb", + "toolName": "subagent", + "args": { + "agent": "marker", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_LEAN_OK" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict." + } + ], + "details": { + "evidence": { + "runId": "1bfdf3b3", + "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", + "activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl", + "resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json" + }, + "id": "1bfdf3b3", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_LEAN_OK", + "agent": "marker", + "sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl", + "noSession": false, + "status": "started", + "mode": "background", + "deliveryState": "detached", + "parentClosePolicy": "terminate", + "async": true, + "autoExit": true + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent_result", + "content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.", + "display": true, + "details": { + "evidence": { + "runId": "1bfdf3b3", + "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", + "activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl", + "resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json", + "finalized": true + }, + "id": "1bfdf3b3", + "name": "fixture-marker", + "task": "Return only PASEO_EDXETH_LEAN_OK", + "agent": "marker", + "mode": "background", + "status": "completed", + "deliveryState": "detached", + "parentClosePolicy": "terminate", + "async": true, + "exitCode": 0, + "elapsed": 7, + "outputTokens": 13, + "contextTokens": 1775, + "contextWindow": 372000, + "sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl" + }, + "timestamp": 1792587964414 + } + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb", + "name": "subagent", + "arguments": { + "agent": "marker", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_LEAN_OK" + } + } + ], + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "usage": { + "input": 2020, + "output": 43, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 2063, + "cost": { + "input": 0.00808, + "output": 0.0008600000000000001, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.00894 + } + }, + "stopReason": "toolUse", + "timestamp": 1792587953256, + "responseId": "resp_0d35cf51b3ed48b9016ad8b8b3698481918f82962b6eaf2788", + "rawStopReason": "completed" + }, + { + "role": "toolResult", + "toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict." + } + ], + "details": { + "evidence": { + "runId": "1bfdf3b3", + "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", + "activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl", + "resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json", + "finalized": true + }, + "id": "1bfdf3b3", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_LEAN_OK", + "agent": "marker", + "sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl", + "noSession": false, + "status": "started", + "mode": "background", + "deliveryState": "detached", + "parentClosePolicy": "terminate", + "async": true, + "autoExit": true + }, + "isError": false, + "timestamp": 1792587957399 + }, + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "**Identifying need for await keyword**" + }, + { + "type": "text", + "text": "DONE" + } + ], + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "usage": { + "input": 2258, + "output": 18, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 11, + "totalTokens": 2276, + "cost": { + "input": 0.009032, + "output": 0.00036, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.009392 + } + }, + "stopReason": "stop", + "timestamp": 1792587957402, + "responseId": "resp_0d35cf51b3ed48b9016ad8b8b602108191a8e8f7fd77fbe5b5", + "rawStopReason": "completed" + }, + { + "role": "custom", + "customType": "subagent_result", + "content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.", + "display": true, + "details": { + "evidence": { + "runId": "1bfdf3b3", + "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", + "activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl", + "resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json", + "finalized": true + }, + "id": "1bfdf3b3", + "name": "fixture-marker", + "task": "Return only PASEO_EDXETH_LEAN_OK", + "agent": "marker", + "mode": "background", + "status": "completed", + "deliveryState": "detached", + "parentClosePolicy": "terminate", + "async": true, + "exitCode": 0, + "elapsed": 7, + "outputTokens": 13, + "contextTokens": 1775, + "contextWindow": 372000, + "sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl" + }, + "timestamp": 1792587964414 + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "DONE" + } + ], + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "usage": { + "input": 389, + "output": 5, + "cacheRead": 2176, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 2570, + "cost": { + "input": 0.0015559999999999999, + "output": 0.0001, + "cacheRead": 0.0008704000000000001, + "cacheWrite": 0, + "total": 0.0025264 + } + }, + "stopReason": "stop", + "timestamp": 1792587964417, + "responseId": "resp_0d35cf51b3ed48b9016ad8b8bd0c548191bddd1fe3410a1c82", + "rawStopReason": "completed" + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground-child-session.jsonl b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground-child-session.jsonl new file mode 100644 index 0000000000..a607a1032d --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground-child-session.jsonl @@ -0,0 +1,8 @@ +{"type": "session", "version": 3, "id": "afbea6e0-7858-4ecf-8ef1-e26af9b7d6e0", "timestamp": "2026-10-21T13:05:17.628Z", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/upstream/sessions/2026-10-21T13-05-12-212Z_01a15e90-d414-7243-a701-4eb4b0bec880.jsonl"} +{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:17.628Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": false, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/upstream/agent", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "boundarySystemPrompt": false}, "id": "3f7d3c3f", "parentId": null, "timestamp": "2026-10-21T13:05:17.629Z"} +{"type": "session_info", "id": "6e198450", "parentId": "3f7d3c3f", "timestamp": "2026-10-21T13:05:17.825Z", "name": "[marker] Fixture marker verification"} +{"type": "model_change", "id": "94bf4c0b", "parentId": "6e198450", "timestamp": "2026-10-21T13:05:17.962Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"} +{"type": "thinking_level_change", "id": "57cc6428", "parentId": "94bf4c0b", "timestamp": "2026-10-21T13:05:17.962Z", "thinkingLevel": "high"} +{"type": "message", "id": "a9e47f08", "parentId": "8c186624", "timestamp": "2026-10-21T13:05:17.970Z", "message": {"role": "user", "content": [{"type": "text", "text": "\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_UPSTREAM_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n\n"}], "timestamp": 1792587917968}} +{"type": "message", "id": "bc221a13", "parentId": "a9e47f08", "timestamp": "2026-10-21T13:05:23.476Z", "message": {"role": "assistant", "content": [{"type": "thinking", "thinking": "**Following user file marker exactly**"}, {"type": "text", "text": "PASEO_EDXETH_UPSTREAM_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1673, "output": 49, "cacheRead": 0, "cacheWrite": 0, "reasoning": 34, "totalTokens": 1722, "cost": {"input": 0.006692, "output": 0.0009800000000000002, "cacheRead": 0, "cacheWrite": 0, "total": 0.007672}}, "stopReason": "stop", "timestamp": 1792587917974, "responseId": "resp_09445c1733d532e7016ad8b8909c908191ba803c059497945e", "rawStopReason": "completed"}} +{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "832fc8c9", "parentId": "bc221a13", "timestamp": "2026-10-21T13:05:23.477Z"} diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground.json b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground.json new file mode 100644 index 0000000000..c8df8eb5c2 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/fixtures/foreground.json @@ -0,0 +1,156 @@ +{ + "provenance": { + "package": "edxeth/pi-subagents", + "version": "2.9.2", + "sourceCommit": "cf6dbf41c17986f3882e6804a68e8fb8282d25e6", + "piVersion": "0.87.1", + "captureDate": "2026-10-21" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a", + "toolName": "subagent", + "args": { + "agent": "marker", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_UPSTREAM_OK" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish." + } + ], + "details": { + "id": "dd7f94e8", + "name": "fixture-marker", + "agent": "marker", + "status": "completed", + "mode": "background", + "parentClosePolicy": "terminate", + "deliveryState": "awaited", + "async": false, + "autoExit": true, + "exitCode": 0, + "elapsed": 7, + "outputTokens": 49, + "contextTokens": 1777, + "contextWindow": 372000, + "summary": "PASEO_EDXETH_UPSTREAM_OK", + "sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl" + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a", + "name": "subagent", + "arguments": { + "agent": "marker", + "name": "fixture-marker", + "title": "Fixture marker verification", + "task": "Return only PASEO_EDXETH_UPSTREAM_OK" + } + } + ], + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "usage": { + "input": 3256, + "output": 43, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 3299, + "cost": { + "input": 0.013023999999999999, + "output": 0.0008600000000000001, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.013883999999999999 + } + }, + "stopReason": "toolUse", + "timestamp": 1792587912428, + "responseId": "resp_0a0ca4aa78cd296a016ad8b88bd52881918cbb9b8326b3060a", + "rawStopReason": "completed" + }, + { + "role": "toolResult", + "toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish." + } + ], + "details": { + "id": "dd7f94e8", + "name": "fixture-marker", + "agent": "marker", + "status": "completed", + "mode": "background", + "parentClosePolicy": "terminate", + "deliveryState": "awaited", + "async": false, + "autoExit": true, + "exitCode": 0, + "elapsed": 7, + "outputTokens": 49, + "contextTokens": 1777, + "contextWindow": 372000, + "summary": "PASEO_EDXETH_UPSTREAM_OK", + "sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl" + }, + "isError": false, + "timestamp": 1792587924671 + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "DONE" + } + ], + "api": "openai-codex-responses", + "provider": "openai-codex", + "model": "gpt-5.6-sol", + "usage": { + "input": 388, + "output": 5, + "cacheRead": 3072, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 3465, + "cost": { + "input": 0.001552, + "output": 0.0001, + "cacheRead": 0.0012288000000000002, + "cacheWrite": 0, + "total": 0.0028808 + } + }, + "stopReason": "stop", + "timestamp": 1792587924675, + "responseId": "resp_0a0ca4aa78cd296a016ad8b896af2c81919c0ef586dd50f2f0", + "rawStopReason": "completed" + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.test.ts new file mode 100644 index 0000000000..86692ab32e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test } from "vitest"; +import { fileURLToPath } from "node:url"; +import { createPiExtensionHost } from "../index.js"; +import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { edxethPiSubagents } from "./index.js"; +import { streamPiHistory } from "../../history-mapper.js"; +import { ProviderSubagentStore } from "../../../../provider-subagents/store.js"; + +const args = { + agent: "scout", + name: "status-scout", + title: "Check child status", + task: "Return the completion marker", +}; + +describe("edxeth/pi-subagents adapter", () => { + test.each(["tool", "notification"])("ends a child that requested help via %s", (delivery) => { + const host = createPiExtensionHost(); + const call = { callId: "spawn-1", toolName: "subagent", args, status: "completed" as const }; + host.mapToolCall({ + ...call, + result: { details: { id: "run-1", name: args.name, status: "started" } }, + }); + const details = { id: "run-1", name: args.name, message: "Please clarify the task" }; + const result = + delivery === "tool" + ? host.mapToolCall({ ...call, result: { details: { ...details, status: "pinged" } } }) + : host.mapCustomMessage({ + role: "custom", + customType: "subagent_ping", + content: "Child requested help and exited", + details, + }); + expect(result?.subagents).toEqual([ + expect.objectContaining({ + type: "upsert", + id: "spawn-1", + status: "canceled", + subtitle: "Needs help", + }), + ]); + }); + + test("leaves unrelated tools and other subagent protocols to their adapters", () => { + const host = createPiExtensionHost(undefined, [edxethPiSubagents]); + const call = { callId: "other", toolName: "subagent", args, status: "completed" as const }; + for (const details of [null, {}, { mode: "single", results: [] }, { id: "run-1" }]) { + expect(host.mapToolCall({ ...call, result: { details } })).toBeUndefined(); + } + expect( + host.mapToolCall({ + ...call, + toolName: "unrelated", + result: { details: { id: "run-1", name: args.name, status: "completed" } }, + }), + ).toBeUndefined(); + for (const details of [{ id: "run-1" }, { id: "run-1", status: "unknown" }]) { + expect( + host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Unrecognized result", + details, + }), + ).toBeUndefined(); + } + }); + + test("waits for cancellation after a kill acknowledgement reports stopping", () => { + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "spawn-1", + toolName: "subagent", + args, + status: "completed", + result: { details: { id: "run-1", name: args.name, status: "started" } }, + }); + const stopping = host.mapToolCall({ + callId: "kill-1", + toolName: "subagent_kill", + args: { id: "run-1" }, + status: "completed", + result: { details: { id: "run-1", name: args.name, status: "stopping" } }, + }); + expect(stopping?.subagents ?? []).toEqual([]); + expect( + host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Cancelled", + details: { id: "run-1", status: "cancelled" }, + })?.subagents, + ).toEqual([{ type: "upsert", id: "spawn-1", status: "canceled" }]); + }); + + test("preserves a completion that arrives before its launch acknowledgement", () => { + const host = createPiExtensionHost(); + const early = host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Already done", + details: { id: "fast-run", status: "completed" }, + }); + expect(early?.subagents ?? []).toEqual([]); + const started = host.mapToolCall({ + callId: "fast-call", + toolName: "subagent", + args, + status: "completed", + result: { + details: { id: "fast-run", name: args.name, agent: args.agent, status: "started" }, + }, + }); + expect(started?.subagents).toEqual([ + { + type: "upsert", + id: "fast-call", + title: "scout", + description: args.task, + toolCallId: "fast-call", + status: "completed", + }, + ]); + }); + + test.each([ + { details: { status: "completed" }, expected: "completed" }, + { details: { status: "failed" }, expected: "failed" }, + { details: { status: "cancelled" }, expected: "canceled" }, + { details: { error: "Child watcher exited unexpectedly" }, expected: "failed" }, + ])("maps completion $details to $expected", ({ details, expected }) => { + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "spawn-1", + toolName: "subagent", + args, + status: "completed", + result: { details: { id: "run-1", name: args.name, status: "started" } }, + }); + expect( + host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Child result", + details: { id: "run-1", ...details }, + })?.subagents, + ).toEqual([{ type: "upsert", id: "spawn-1", status: expected }]); + }); + + test("a resume gets its own run and reads the retained child session", async () => { + const dir = await mkdtemp(join(tmpdir(), "paseo-edxeth-resume-")); + const file = join(dir, "child.jsonl"); + const host = createPiExtensionHost(); + try { + await writeFile( + file, + JSON.stringify({ + type: "message", + message: { role: "assistant", content: [{ type: "text", text: "RESUMED" }] }, + }) + "\n", + ); + const original = host.mapToolCall({ + callId: "spawn-1", + toolName: "subagent", + args, + status: "completed", + result: { + details: { id: "run-1", name: args.name, status: "completed", sessionFile: file }, + }, + }); + await original?.hydration; + const resumed = host.mapToolCall({ + callId: "resume-1", + toolName: "subagent_resume", + args: { sessionFile: file, task: "Continue" }, + status: "completed", + result: { + details: { id: "run-2", name: args.name, status: "started", sessionFile: file }, + }, + }); + expect(resumed?.childSessions).toEqual([{ id: "resume-1", file }]); + expect(await resumed?.hydration).toEqual([ + { + type: "provider_subagent", + provider: "pi", + event: { + type: "timeline", + id: "resume-1", + item: expect.objectContaining({ type: "assistant_message", text: "RESUMED" }), + }, + }, + ]); + expect( + host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Resumed run complete", + details: { id: "run-2", status: "completed", sessionFile: file }, + })?.subagents, + ).toEqual([{ type: "upsert", id: "resume-1", status: "completed" }]); + } finally { + host.close(); + await rm(dir, { recursive: true, force: true }); + } + }); + + test("settles each batched child by run id without creating a parent-call card", () => { + const host = createPiExtensionHost(); + const result = host.mapToolCall({ + callId: "batch-1", + toolName: "subagent", + args: { children: [args, { ...args, agent: "reviewer", name: "status-reviewer" }] }, + status: "completed", + result: { + details: { + status: "batch", + children: [ + { id: "run-1", ...args, mode: "background", status: "completed" }, + { id: "run-2", name: "status-reviewer", agent: "reviewer", status: "started" }, + ], + }, + }, + }); + expect(result?.subagents).toEqual([ + { + type: "upsert", + id: "batch-1:0", + title: "scout", + description: args.task, + toolCallId: "batch-1", + status: "completed", + }, + { + type: "upsert", + id: "batch-1:1", + title: "reviewer", + description: undefined, + toolCallId: "batch-1", + status: "running", + }, + ]); + expect( + host.mapCustomMessage({ + role: "custom", + customType: "subagent_result", + content: "Done", + details: { id: "run-2", status: "completed" }, + })?.subagents, + ).toEqual([{ type: "upsert", id: "batch-1:1", status: "completed" }]); + }); + + test.each([ + { mode: "foreground", statuses: ["running", "completed"] }, + { mode: "background", statuses: ["running", "running", "completed"] }, + ])("settles the captured $mode run live and on replay", async ({ mode, statuses }) => { + const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), { + from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`, + to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)), + }); + const events = await verifySubagentFixture(fixture); + const updates = events.flatMap(({ event }) => (event.type === "upsert" ? [event] : [])); + expect(updates.map((event) => event.status)).toEqual(statuses); + expect(new Set(updates.map((event) => event.id)).size).toBe(1); + expect(events.some(({ event }) => event.type === "timeline")).toBe(true); + }); + + test.each(["foreground", "background"])( + "restores a completed %s child and its transcript to the consumer store", + async (mode) => { + const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), { + from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`, + to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)), + }); + const store = new ProviderSubagentStore(); + for await (const event of streamPiHistory("pi", fixture.messages)) { + if (event.type === "provider_subagent") store.apply("parent", "pi", event.event); + } + const children = store.list("parent"); + expect(children).toEqual([expect.objectContaining({ status: "completed", title: "marker" })]); + const transcript = store.fetchTimeline("parent", children[0]!.id); + expect(transcript.rows).toContainEqual( + expect.objectContaining({ + item: expect.objectContaining({ + type: "assistant_message", + text: mode === "foreground" ? "PASEO_EDXETH_UPSTREAM_OK" : "PASEO_EDXETH_LEAN_OK", + }), + }), + ); + }, + ); + + test("settles a synchronous result on the card created when the tool started", () => { + const host = createPiExtensionHost(); + const call = { callId: "spawn-1", toolName: "subagent", args }; + const start = host.mapToolCall({ ...call, status: "running", result: null }); + expect(start?.subagents).toEqual([ + { + type: "upsert", + id: "spawn-1", + title: "scout", + description: args.task, + toolCallId: "spawn-1", + status: "running", + }, + ]); + const end = host.mapToolCall({ + ...call, + status: "completed", + result: { + content: [{ type: "text", text: "DONE" }], + details: { + id: "run-1", + name: args.name, + agent: args.agent, + mode: "background", + status: "completed", + deliveryState: "awaited", + exitCode: 0, + }, + }, + }); + expect(end?.subagents).toEqual([ + { + type: "upsert", + id: "spawn-1", + title: "scout", + description: args.task, + toolCallId: "spawn-1", + status: "completed", + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts new file mode 100644 index 0000000000..c58f0faf15 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/edxeth-pi-subagents/index.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; +import type { PiChildSessionFile, PiExtension } from "../contract.js"; +import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js"; + +const Run = z + .object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), + agent: z.string().trim().min(1).optional(), + task: z.string().optional(), + status: z.enum(["started", "completed", "failed", "cancelled", "pinged"]), + sessionFile: z.string().trim().min(1).optional(), + }) + .passthrough(); +const Args = z.object({ task: z.string().optional() }).passthrough(); +const Batch = z.object({ status: z.enum(["started", "batch"]), children: z.array(Run).min(1) }); +const Completion = z + .object({ + id: z.string().trim().min(1), + status: z.enum(["completed", "failed", "cancelled", "pinged"]).optional(), + error: z.string().optional(), + sessionFile: z.string().trim().min(1).optional(), + }) + .passthrough(); +const Ping = Completion.extend({ message: z.string() }); + +function childStatus(status: z.infer["status"]) { + if (status === "started") return "running"; + // A ping ends the child process and needs a new resume run. It is not success. + if (status === "cancelled" || status === "pinged") return "canceled"; + return status; +} + +export const edxethPiSubagents: PiExtension = { + id: "edxeth/pi-subagents", + createSession: () => { + const callsByRun = new Map(); + const completions = new Map>(); + const readSessions = new Map(); + const childSession = (id: string, file: string | undefined): PiChildSessionFile[] => { + if (!file || readSessions.get(id) === file) return []; + readSessions.set(id, file); + return [{ id, file }]; + }; + const completeRun = (result: z.infer) => { + const status = result.status ?? (result.error === undefined ? undefined : "failed"); + if (!status) return undefined; + // A fast child can finish before the tool returns its run id. Keep the + // terminal outcome so a later "started" acknowledgement cannot regress it. + completions.set(result.id, { ...result, status }); + const id = callsByRun.get(result.id); + if (!id) return undefined; + const subagents: ProviderSubagentInputEvent[] = [ + { + type: "upsert", + id, + status: childStatus(status), + ...(status === "pinged" ? { subtitle: "Needs help" } : {}), + }, + ]; + return { subagents, childSessions: childSession(id, result.sessionFile) }; + }; + return { + mapToolCall(call) { + const isLaunch = call.toolName === "subagent" || call.toolName === "subagent_resume"; + if (!isLaunch || call.status === "running") return undefined; + const details = typeof call.result === "object" ? call.result?.details : null; + const single = Run.safeParse(details); + let runs: z.infer[]; + if (single.success) { + runs = [single.data]; + } else { + const batch = Batch.safeParse(details); + if (!batch.success) return undefined; + runs = batch.data.children; + } + const args = Args.safeParse(call.args); + const description = args.success ? args.data.task : undefined; + const subagents: ProviderSubagentInputEvent[] = []; + const childSessions: PiChildSessionFile[] = []; + for (const [index, run] of runs.entries()) { + const id = runs.length === 1 ? call.callId : `${call.callId}:${index}`; + const completion = completions.get(run.id); + const status = completion?.status ?? run.status; + callsByRun.set(run.id, id); + subagents.push({ + type: "upsert", + id, + title: run.agent ?? run.name, + description: run.task ?? description, + toolCallId: call.callId, + status: childStatus(status), + ...(status === "pinged" ? { subtitle: "Needs help" } : {}), + }); + childSessions.push(...childSession(id, completion?.sessionFile ?? run.sessionFile)); + } + return { + detail: { + type: "sub_agent", + description, + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", + }, + subagents, + childSessions, + }; + }, + mapCustomMessage(message) { + if (message.customType === "subagent_ping") { + const ping = Ping.safeParse(message.details); + return ping.success ? completeRun({ ...ping.data, status: "pinged" }) : undefined; + } + if (message.customType !== "subagent_result") return undefined; + const parsed = Completion.safeParse(message.details); + return parsed.success ? completeRun(parsed.data) : undefined; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts index f89a9aefd3..7bb62a9d1f 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts @@ -130,6 +130,18 @@ describe("@gotgenes/pi-subagents adapter", () => { .filter((event) => event.event.type === "upsert") .map((event) => (event.event.type === "upsert" ? event.event.status : null)), ).toEqual(["running", "completed"]); + expect( + events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])), + ).toEqual([ + { + type: "timeline", + id: "call_502323", + item: { + type: "assistant_message", + text: "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty.", + }, + }, + ]); }); test("correlates a background notification and result collection", async () => { const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts index e1869705f8..9383911c3d 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts @@ -64,7 +64,8 @@ export const gotgenesPiSubagents: PiExtension = { type: "sub_agent" as const, subAgentType: title, description, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running") return { @@ -92,6 +93,10 @@ export const gotgenesPiSubagents: PiExtension = { outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId); pendingFilesByAgent.delete(details.data.agentId); const childSessions = childSession(call.callId, file); + const childStatus = status(details.data.status); + // Without a session file, as on replay, the child's answer is the tool result. + const answer = + !file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : ""; return { detail, subagents: [ @@ -101,8 +106,17 @@ export const gotgenesPiSubagents: PiExtension = { title, description, toolCallId: call.callId, - status: status(details.data.status), + status: childStatus, }, + ...(answer + ? [ + { + type: "timeline" as const, + id: call.callId, + item: { type: "assistant_message" as const, text: answer }, + }, + ] + : []), ], childSessions, }; @@ -118,7 +132,8 @@ export const gotgenesPiSubagents: PiExtension = { const detail = { type: "sub_agent" as const, description: details.success ? details.data.description : undefined, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running" || !details.success) return { detail }; const file = details.data.transcriptPath; diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts index 9afacae977..d374c1a02d 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts @@ -83,7 +83,7 @@ describe("Pi extension host", () => { test("declines a throwing adapter and preserves generic history", async () => { const host = createPiExtensionHost(undefined, [throwingAdapter]); const mapper = new PiHistoryMapper("pi", [], {}, host); - const events = mapper.mapMessages([ + const events = await mapper.mapMessages([ { role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "other", arguments: {} }], @@ -193,7 +193,7 @@ describe("Pi extension host", () => { await output?.hydration; expect(reads).toHaveLength(8); }); - test("claims a tool, declines an unrelated one, and maps history like live events", () => { + test("claims a tool, declines an unrelated one, and maps history like live events", async () => { const host = createPiExtensionHost(); const args = { agent: "scout", task: "Inspect files" }; const result = parseToolResult({ content: [{ type: "text", text: "Found two files" }] }); @@ -210,7 +210,7 @@ describe("Pi extension host", () => { type: "sub_agent", subAgentType: "scout", description: "Inspect files", - log: "Found two files", + log: "", }, }), ); @@ -225,7 +225,7 @@ describe("Pi extension host", () => { ).toBeUndefined(); const mapper = new PiHistoryMapper("pi"); - const events = mapper.mapMessages([ + const events = await mapper.mapMessages([ { role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "subagent", arguments: args }], diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts index 8e9db54047..740d66aadc 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts @@ -137,7 +137,7 @@ describe("pi-subagents adapter", () => { type: "sub_agent", subAgentType: "reviewer", description: "Review the Pi mapper change", - log: "The mapper change preserves provider status.", + log: "", }); }); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts index 64e814af3f..058171e8df 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts @@ -131,7 +131,8 @@ export const piSubagents: PiExtension = { type: "sub_agent" as const, subAgentType: args.data.agent, description: args.data.task, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; const base = { type: "upsert" as const, diff --git a/packages/server/src/server/agent/providers/pi/extensions/registry.ts b/packages/server/src/server/agent/providers/pi/extensions/registry.ts index c66fabad80..ca273b83bb 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/registry.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/registry.ts @@ -1,5 +1,6 @@ import { piAskUser } from "./pi-ask-user/index.js"; import { piSubagents } from "./pi-subagents/index.js"; +import { edxethPiSubagents } from "./edxeth-pi-subagents/index.js"; import { piMcpAdapter } from "./pi-mcp-adapter/index.js"; import { rpivTodo } from "./rpiv-todo/index.js"; import { piExampleTodo } from "./pi-example-todo/index.js"; @@ -10,6 +11,7 @@ import { rpivAskUserQuestion } from "./rpiv-ask-user-question/index.js"; export const piExtensions = [ piAskUser, // Dialog correlation + edxethPiSubagents, // Structured results before the generic subagent tool matcher piSubagents, // Delegation calls tintinwebPiSubagents, gotgenesPiSubagents, diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts index 3ec9256a29..d3c9fb7a9e 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts @@ -79,7 +79,7 @@ test("cancellation declines each remaining real RPC dialog", () => { } }); -test("captured result renders in live and history tool rows, while foreign shapes decline", () => { +test("captured result renders in live and history tool rows, while foreign shapes decline", async () => { const call = { callId: end.toolCallId, toolName: end.toolName, @@ -93,7 +93,7 @@ test("captured result renders in live and history tool rows, while foreign shape label: "Questions", text: "Which snack?: Cookie\nWhich colors?: Red, Blue\nWhat project name?: Nebula", }); - const history = new PiHistoryMapper("pi").mapMessages([ + const history = await new PiHistoryMapper("pi").mapMessages([ { role: "assistant", content: [ diff --git a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts index 8a9ca61d08..96ba771594 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts @@ -54,8 +54,6 @@ export async function verifySubagentFixture( try { await session.startTurn("Delegate work"); for (const event of fixture.events) pi.latestSession().emit(event); - const immediate = live.filter((event) => event.type === "provider_subagent"); - expect(immediate.some((event) => event.event.type === "timeline")).toBe(false); pi.latestSession().finishTurn(); const replay: AgentStreamEvent[] = []; for await (const event of streamPiHistory("pi", fixture.messages)) replay.push(event); diff --git a/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts index a3b2114cab..f702e0d7cf 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts @@ -89,7 +89,7 @@ export async function verifyTaskFixture(path: URL, expectedStatuses: string[]): for (const event of fixture.events) pi.latestSession().emit(event); pi.latestSession().finishTurn(); expect(todos(live)).toEqual(mapped); - const replay = new PiHistoryMapper("pi").mapMessages(fixture.messages); + const replay = await new PiHistoryMapper("pi").mapMessages(fixture.messages); expect(todos(replay)).toEqual(mapped); expect( replay.filter( diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts index ad666bb4d3..60f3b12869 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts @@ -36,12 +36,17 @@ describe("@tintinweb/pi-subagents adapter", () => { }); expect(mapping?.subagents).toEqual([ expect.objectContaining({ id: "call-1", status: "completed" }), + { + type: "timeline", + id: "call-1", + item: { type: "assistant_message", text: "Wrapped up (turn limit)" }, + }, ]); expect(mapping?.detail).toEqual({ type: "sub_agent", subAgentType: "Explore", description: "Inspect", - log: "Wrapped up (turn limit)", + log: "", }); }); test("maps captured foreground lifecycle live and on replay", async () => { @@ -53,6 +58,18 @@ describe("@tintinweb/pi-subagents adapter", () => { .filter((event) => event.event.type === "upsert") .map((event) => (event.event.type === "upsert" ? event.event.status : null)), ).toEqual(["running", "completed"]); + expect( + events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])), + ).toEqual([ + { + type: "timeline", + id: "call_458926", + item: { + type: "assistant_message", + text: "Agent completed in 6.6s (1 tool uses, 18.8k token).\n\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty (no files or hidden dotfiles).", + }, + }, + ]); }); test("uses a structured notification to complete a background child", async () => { const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); @@ -147,7 +164,7 @@ describe("@tintinweb/pi-subagents adapter", () => { expect(mapping?.detail).toEqual({ type: "sub_agent", description: undefined, - log: "Wrapped up (turn limit)", + log: "", }); }, ); diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts index 56b5935172..6858dec8be 100644 --- a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts @@ -57,7 +57,8 @@ export const tintinwebPiSubagents: PiExtension = { type: "sub_agent" as const, subAgentType: title, description, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running") return { @@ -84,6 +85,10 @@ export const tintinwebPiSubagents: PiExtension = { const file = outputFileFromToolResult(call.result); const childSessions = file && !readSessions.has(file) ? [{ id: call.callId, file }] : []; if (file) readSessions.add(file); + const childStatus = status(details.data.status); + // A foreground child has no session file; its answer is the tool result. + const answer = + !file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : ""; return { detail, subagents: [ @@ -93,8 +98,17 @@ export const tintinwebPiSubagents: PiExtension = { title, description, toolCallId: call.callId, - status: status(details.data.status), + status: childStatus, }, + ...(answer + ? [ + { + type: "timeline" as const, + id: call.callId, + item: { type: "assistant_message" as const, text: answer }, + }, + ] + : []), ], childSessions, }; @@ -110,7 +124,8 @@ export const tintinwebPiSubagents: PiExtension = { const detail = { type: "sub_agent" as const, description: details.success ? details.data.description : undefined, - log: extractTextFromToolResult(call.result)?.trim() ?? "", + // COMPAT(subagent-log): remove after 2027-04-30. + log: "", }; if (call.status === "running" || !details.success) return { detail }; return { diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts index 24d18306b2..f4136956eb 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts @@ -221,3 +221,75 @@ describe("Pi history mapper", () => { ]); }); }); + +describe("Pi tool-result image replay", () => { + test("recreates an image from native tool-result history without its source file", async () => { + const events = await collectHistory([ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "image-read", + name: "read", + arguments: { path: "missing-preview.png" }, + }, + ], + }, + { + role: "toolResult", + toolCallId: "image-read", + toolName: "read", + content: [ + { type: "text", text: "Read image file [image/png]" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + }, + ]); + expect(events.map((event) => event.item?.type)).toEqual([ + "tool_call", + "tool_call", + "assistant_message", + ]); + const image = events.find( + (event) => event.type === "timeline" && event.item.type === "assistant_message", + ); + expect(image).toMatchObject({ + type: "timeline", + item: { type: "assistant_message", text: expect.stringContaining("![Image](file://") }, + }); + if (image?.type !== "timeline" || image.item.type !== "assistant_message") + throw new Error("Missing image"); + const url = image.item.text.match(/\((file:[^)]+)\)/)![1]; + const { readFile, unlink } = await import("node:fs/promises"); + expect(await readFile(new URL(url))).toEqual(Buffer.from("hello")); + await unlink(new URL(url)); + const replay = await collectHistory([ + { + role: "toolResult", + toolCallId: "image-read", + toolName: "read", + content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], + }, + ]); + expect(replay).toContainEqual(image); + expect(await readFile(new URL(url))).toEqual(Buffer.from("hello")); + }); +}); + +test("replayed Pi images keep their position before the following reply", async () => { + const events = await collectHistory([ + { + role: "toolResult", + toolCallId: "image-position", + toolName: "read", + content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], + }, + { role: "assistant", content: [{ type: "text", text: "Image read complete." }] }, + ]); + expect( + events.map((event) => + event.item?.type === "assistant_message" ? event.item.text : event.item?.type, + ), + ).toEqual(["tool_call", expect.stringContaining("![Image]("), "Image read complete."]); +}); diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.ts b/packages/server/src/server/agent/providers/pi/history-mapper.ts index 27a0250b00..89d9fcedce 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.ts @@ -1,5 +1,9 @@ import { mapCustomMessageToToolCall } from "../custom-message.js"; import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../provider-image-output.js"; import { createPiExtensionHost, type PiExtensionEventOutput, @@ -11,6 +15,7 @@ import { mapToolDetail, parseToolArgs, parseToolResult, + splitPiToolResultImages, type PiToolResult, type PiTrackedToolCall, } from "./tool-call-mapper.js"; @@ -71,7 +76,7 @@ export class PiHistoryMapper { private readonly extensionHost: PiExtensionHost = createPiExtensionHost(), ) {} - mapMessages(messages: readonly PiAgentMessage[]): AgentStreamEvent[] { + async mapMessages(messages: readonly PiAgentMessage[]): Promise { const events: AgentStreamEvent[] = []; for (const message of messages) { @@ -86,7 +91,7 @@ export class PiHistoryMapper { events.push(...this.mapAssistantMessage(message)); break; case "toolResult": { - events.push(...this.mapToolResultMessage(message)); + events.push(...(await this.mapToolResultMessage(message))); break; } case "bashExecution": @@ -207,24 +212,35 @@ export class PiHistoryMapper { return events; } - private mapToolResultMessage( + private async mapToolResultMessage( message: Extract, - ): AgentStreamEvent[] { + ): Promise { const tracked = this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null); this.pendingToolCalls.delete(message.toolCallId); const result = parseToolResult({ content: message.content, details: message.details }); + // Pull image blocks out of the result so base64 never reaches the tool output, then render + // each one as an assistant_message markdown image after the tool_call (mirroring the live + // path and the Claude provider, upstream #1717). + const { images, result: splitResult } = splitPiToolResultImages(result); const mapping = this.extensionHost.mapToolCall({ callId: message.toolCallId, toolName: tracked.toolName, args: tracked.args, status: message.isError ? "failed" : "completed", - result, + result: splitResult, }); - const detail = this.mapToolDetail(message.toolCallId, tracked, result, mapping?.detail); + const detail = this.mapToolDetail(message.toolCallId, tracked, splitResult, mapping?.detail); if (!detail) { return []; } + const imageEvents: AgentStreamEvent[] = []; + for (const image of images) { + const item = await renderProviderImageOutputAsAssistantMarkdown(image, { + materialize: materializeProviderImage, + }); + if (item) imageEvents.push({ type: "timeline", provider: this.provider, item }); + } return [ { type: "timeline", @@ -234,9 +250,10 @@ export class PiHistoryMapper { name: mapping?.name ?? tracked.toolName, isError: Boolean(message.isError), detail, - errorText: extractTextFromToolResult(result) ?? "Tool call failed", + errorText: extractTextFromToolResult(splitResult) ?? "Tool call failed", }), }, + ...imageEvents, ...this.extensionEvents(mapping), ]; } @@ -297,9 +314,10 @@ export async function* streamPiHistory( signal?: AbortSignal, ): AsyncGenerator { const mapper = new PiHistoryMapper(provider, userEntries, hooks, extensionHost); - for (const event of mapper.mapMessages(messages)) { + for (const message of messages) { if (signal?.aborted) return; - if (event) { + for (const event of await mapper.mapMessages([message])) { + if (signal?.aborted) return; yield event; } } diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts index 280b558739..c6b0df5dbf 100644 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts @@ -1,9 +1,13 @@ -import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, utimes, 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 { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; +import { + copyPiSessionFileWithCwd, + listPiImportableSessions, + readPiImportSessionConfig, +} from "./session-descriptor.js"; async function writeSession(root: string, lines: unknown[]): Promise { const sessionsDir = path.join(root, "sessions", "project"); @@ -216,3 +220,97 @@ test("Pi import config preserves thinking before a later model in large sessions thinkingOptionId: "low", }); }); + +test("a Pi session copy names the new cwd in its header and keeps every later byte", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const header = { + type: "session", + version: 3, + id: "pi-session-1", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: path.join(root, "removed-worktree"), + }; + const body = Buffer.from( + Array.from( + { length: 2_000 }, + (_, index) => + `${JSON.stringify({ type: "message", id: `entry-${index}`, message: { role: "user", content: `café ☕ ${index}` } })}\n`, + ).join(""), + ); + expect(body.length).toBeGreaterThan(64 * 1024); + const sourceFile = path.join(root, "source.jsonl"); + const targetFile = path.join(root, "copy.jsonl"); + await writeFile(sourceFile, Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`), body])); + + await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" }); + + const copy = await readFile(targetFile); + const headerEnd = copy.indexOf(0x0a); + expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({ + ...header, + cwd: "/home/user", + }); + expect(copy.subarray(headerEnd + 1).equals(body)).toBe(true); +}); + +test("a Pi session copy refuses a file without a session header", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const sourceFile = path.join(root, "source.jsonl"); + await writeFile(sourceFile, `${JSON.stringify({ type: "message", id: "entry-1" })}\n`); + + await expect( + copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }), + ).rejects.toThrow(`Pi session file has no session header: ${sourceFile}`); +}); + +const COPY_BODY = [ + { type: "message", id: "entry-1", message: { role: "user", content: "café ☕" } }, + { type: "message", id: "entry-2", message: { role: "assistant", content: "Dans la cuisine." } }, +] + .map((entry) => `${JSON.stringify(entry)}\n`) + .join(""); + +const COPY_HEADER = { + type: "session", + version: 3, + id: "pi-session-1", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/removed-worktree", +}; + +async function copySessionContent(content: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const sourceFile = path.join(root, "source.jsonl"); + const targetFile = path.join(root, "copy.jsonl"); + await writeFile(sourceFile, content); + await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" }); + return readFile(targetFile); +} + +test.each([ + ["a header line longer than 64 KiB", "", { ...COPY_HEADER, padding: "x".repeat(70 * 1024) }], + ["a header with an empty cwd", "", { ...COPY_HEADER, cwd: "" }], + ["blank lines before the header", "\n\r\n", COPY_HEADER], +])("a Pi session copy accepts %s", async (_headerShape, prefix, header) => { + const copy = await copySessionContent(`${prefix}${JSON.stringify(header)}\n${COPY_BODY}`); + + const headerEnd = copy.indexOf(0x0a); + expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({ + ...header, + cwd: "/home/user", + }); + expect(copy.subarray(headerEnd + 1).equals(Buffer.from(COPY_BODY))).toBe(true); +}); + +test("a Pi session copy reports a missing session file", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const sourceFile = path.join(root, "missing.jsonl"); + + await expect( + copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }), + ).rejects.toThrow(`Pi session file is missing: ${sourceFile}`); +}); diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.ts index 01f55bc0ce..6c0cc300f2 100644 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.ts +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.ts @@ -1,7 +1,8 @@ -import type { Dirent } from "node:fs"; -import { open, readdir, readFile, stat } from "node:fs/promises"; +import { createReadStream, createWriteStream, type Dirent } from "node:fs"; +import { open, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; +import { pipeline } from "node:stream/promises"; import type { ImportableProviderSession, @@ -18,6 +19,7 @@ const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR"; const HEAD_BYTES = 64 * 1024; const TAIL_BYTES = 256 * 1024; const FULL_SCAN_LINE_LIMIT = 2_000; +const PI_SESSION_HEADER_SCAN_BYTES = 1024 * 1024; // Rank all discovered files cheaply, then parse only a bounded recent window. const IMPORT_CANDIDATE_OVERSCAN = 40; const IMPORT_CANDIDATE_MIN = 400; @@ -100,6 +102,59 @@ export async function listPiImportableSessions( ); } +export async function copyPiSessionFileWithCwd(input: { + sourceFile: string; + targetFile: string; + cwd: string; +}): Promise { + const found = findSessionHeaderLine( + await readSessionFileStart(input.sourceFile, PI_SESSION_HEADER_SCAN_BYTES), + ); + if (!found) { + throw new Error(`Pi session file has no session header: ${input.sourceFile}`); + } + await writeFile(input.targetFile, `${JSON.stringify({ ...found.header, cwd: input.cwd })}\n`, { + mode: 0o600, + }); + await pipeline( + createReadStream(input.sourceFile, { start: found.bodyStart }), + createWriteStream(input.targetFile, { flags: "a" }), + ); +} + +function findSessionHeaderLine( + scanned: Buffer, +): { header: Record; bodyStart: number } | null { + let lineStart = 0; + while (lineStart < scanned.length) { + const newline = scanned.indexOf(0x0a, lineStart); + const bodyStart = newline === -1 ? scanned.length : newline + 1; + const line = scanned.subarray(lineStart, bodyStart).toString("utf8").trim(); + if (line) { + const header = parseJsonRecord(line); + return header?.type === "session" ? { header, bodyStart } : null; + } + lineStart = bodyStart; + } + return null; +} + +async function readSessionFileStart(filePath: string, maxBytes: number): Promise { + const handle = await open(filePath, "r").catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Pi session file is missing: ${filePath}`); + } + throw error; + }); + try { + const buffer = Buffer.alloc(maxBytes); + const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0); + return buffer.subarray(0, bytesRead); + } finally { + await handle.close().catch(() => undefined); + } +} + export async function readPiImportSessionConfig(filePath: string): Promise { const descriptor = await readPiSessionDescriptor(filePath); if (!descriptor) return {}; diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index b8c7930bc4..90a80e4c66 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from "node:fs"; + import type { PiRuntime, PiRuntimeLaunch, @@ -75,6 +77,12 @@ export class FakePi implements PiRuntime { const session = new FakePiSession(launch, this.removedModels); session.commands = this.queuedCommands.shift() ?? []; this.queuedSessionSetups.shift()?.(session); + const missingSessionCwd = launch.noSession ? null : readMissingSessionCwd(launch.session); + if (missingSessionCwd !== null) { + session.getStateError = new Error( + `Pi RPC process exited with code 1 and signal null\nStored session working directory does not exist: ${missingSessionCwd}\nSession file: ${launch.session}\nCurrent working directory: ${launch.cwd}`, + ); + } this.sessions.push(session); return session; } @@ -101,6 +109,27 @@ export class FakePi implements PiRuntime { } } +function readMissingSessionCwd(sessionFile: string | undefined): string | null { + if (!sessionFile || !existsSync(sessionFile)) { + return null; + } + const [headerLine = ""] = readFileSync(sessionFile, "utf8").split("\n", 1); + let header: unknown; + try { + header = JSON.parse(headerLine); + } catch { + return null; + } + if (typeof header !== "object" || header === null || !("cwd" in header)) { + return null; + } + const { type, cwd } = header as { type?: unknown; cwd?: unknown }; + if (type !== "session" || typeof cwd !== "string" || !cwd || existsSync(cwd)) { + return null; + } + return cwd; +} + export class FakePiSession implements PiRuntimeSession { readonly environment: Record; readonly prompts: Array<{ message: string; imageCount: number }> = []; @@ -108,6 +137,7 @@ export class FakePiSession implements PiRuntimeSession { steerError: Error | null = null; readonly controlRequests: string[] = []; clearQueueError: Error | null = null; + clearQueueCompletion: Promise | null = null; readonly compactRequests: Array<{ customInstructions?: string }> = []; readonly setAutoCompactionRequests: boolean[] = []; readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = []; @@ -140,6 +170,7 @@ export class FakePiSession implements PiRuntimeSession { subagentSubscriptionError: Error | null = null; setSessionNameError: Error | null = null; compactError: Error | null = null; + compactCompletion: Promise | null = null; emitCompactEnd = true; getStateError: Error | null = null; getSessionStatsError: Error | null = null; @@ -233,6 +264,7 @@ export class FakePiSession implements PiRuntimeSession { async clearQueue(): Promise { this.controlRequests.push("clear_queue"); + await this.clearQueueCompletion; if (this.clearQueueError) { throw this.clearQueueError; } @@ -241,6 +273,7 @@ export class FakePiSession implements PiRuntimeSession { async compact(customInstructions?: string): Promise { this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions }); this.emit({ type: "compaction_start", reason: "manual" }); + await this.compactCompletion; if (this.emitCompactEnd) { this.emit({ type: "compaction_end", reason: "manual" }); } diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts index 63b557f994..0c065e27cd 100644 --- a/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "vitest"; -import { mapToolDetail, parseToolArgs, parseToolResult } from "./tool-call-mapper.js"; +import { + extractTextFromToolResult, + mapToolDetail, + parseToolArgs, + parseToolResult, + splitPiToolResultImages, +} from "./tool-call-mapper.js"; describe("Pi tool call mapper", () => { test("maps bash args and result to shell detail", () => { @@ -56,3 +62,77 @@ describe("Pi tool call mapper", () => { }); }); }); + +describe("splitPiToolResultImages", () => { + test("extracts image blocks and replaces them with [image] placeholders", () => { + const parsed = parseToolResult({ + content: [ + { type: "text", text: "shot taken" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + }); + const { images, result } = splitPiToolResultImages(parsed); + + expect(images).toEqual([{ data: "aGVsbG8=", mimeType: "image/png" }]); + expect(result).toEqual({ + content: [ + { type: "text", text: "shot taken" }, + { type: "text", text: "[image]" }, + ], + }); + }); + + test("replaces image-only results with a non-empty placeholder", () => { + const parsed = parseToolResult({ + content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }], + }); + const { images, result } = splitPiToolResultImages(parsed); + + expect(images).toHaveLength(1); + expect(extractTextFromToolResult(result)).toBe("[image]"); + }); + + test("passes through string results and results without images", () => { + expect(splitPiToolResultImages("plain output")).toEqual({ + images: [], + result: "plain output", + }); + const textOnly = parseToolResult({ content: [{ type: "text", text: "no images" }] }); + expect(splitPiToolResultImages(textOnly)).toEqual({ images: [], result: textOnly }); + expect(splitPiToolResultImages(null)).toEqual({ images: [], result: null }); + }); + + test("keeps other fields on the result object", () => { + const parsed = parseToolResult({ + exitCode: 0, + content: [{ type: "image", data: "aGVsbG8=" }], + }); + const { result } = splitPiToolResultImages(parsed); + expect(result).toMatchObject({ exitCode: 0 }); + }); +}); + +test("tool-result images remove nested structured image data while preserving metadata", () => { + const data = Buffer.from("nested screenshot bytes").toString("base64"); + const result = parseToolResult({ + content: [{ type: "image", data, mimeType: "image/png" }], + structuredContent: { + content: [{ type: "image", data, mimeType: "image/png", width: 64 }], + captureId: "capture-1", + read: { type: "image", data, mimeType: "image/png", note: "Read image file" }, + extra: { data: "keep ordinary data" }, + }, + }); + const split = splitPiToolResultImages(result); + const detail = mapToolDetail(parseToolArgs("screenshot", {}), split.result); + expect(JSON.stringify(detail)).not.toContain(data); + expect(split.images).toEqual([{ data, mimeType: "image/png" }]); + expect(split.result).toMatchObject({ + structuredContent: { + content: [{ type: "image", data: "[image]", mimeType: "image/png", width: 64 }], + captureId: "capture-1", + read: { type: "image", data: "[image]", mimeType: "image/png", note: "Read image file" }, + extra: { data: "keep ordinary data" }, + }, + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts index 8c18a8fdbf..8fbba690b8 100644 --- a/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ToolCallDetail } from "../../agent-sdk-types.js"; +import type { ProviderImageOutput } from "../provider-image-output.js"; interface BashToolInput { command: string; @@ -52,6 +53,7 @@ interface PiToolResultObject { stdout?: string; text?: string; content?: PiToolResultContent[]; + structuredContent?: unknown; exitCode?: number; code?: number; details?: PiToolResultDetails; @@ -67,6 +69,12 @@ interface PiToolResultTextContent { text: string; } +const PiToolResultImageContentSchema = z.object({ + type: z.literal("image"), + data: z.string().min(1), + mimeType: z.string().optional(), +}); + interface PiToolResultUnknownContent { type: string; } @@ -236,6 +244,61 @@ export function parseToolResult(rawResult: unknown): PiToolResult { return null; } +// Pi returns images inside tool-result content as base64 blocks. Left in place they reach the +// tool-output stringifier, which dumps base64 into the tool output. We pull those blocks out to +// render them as image markdown and leave a "[image]" placeholder so image-only results still +// produce non-empty output (mirroring splitClaudeToolResultImages). +export function splitPiToolResultImages(result: PiToolResult): { + images: ProviderImageOutput[]; + result: PiToolResult; +} { + if (!result || typeof result === "string" || !result.content) { + return { images: [], result }; + } + const images: ProviderImageOutput[] = []; + let replaced = false; + const content = result.content.map((block) => { + const image = toPiImageOutput(block); + if (image) { + images.push(image); + replaced = true; + return { type: "text", text: "[image]" } satisfies PiToolResultTextContent; + } + return block; + }); + if (!replaced) { + return { images: [], result }; + } + return { + images, + result: { + ...result, + content, + ...(result.structuredContent === undefined + ? {} + : { structuredContent: withoutImageData(result.structuredContent) }), + }, + }; +} + +function withoutImageData(value: unknown): unknown { + if (Array.isArray(value)) return value.map(withoutImageData); + if (value === null || typeof value !== "object") return value; + const image = toPiImageOutput(value); + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + image && key === "data" ? "[image]" : withoutImageData(child), + ]), + ); +} + +function toPiImageOutput(block: unknown): ProviderImageOutput | null { + const parsed = PiToolResultImageContentSchema.safeParse(block); + if (!parsed.success) return null; + return { data: parsed.data.data, mimeType: parsed.data.mimeType ?? null }; +} + export function extractTextFromToolResult(result: PiToolResult): string | undefined { if (typeof result === "string") { return result; diff --git a/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts b/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts index 1f4da6e04a..5e71bad0bd 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.posix.test.ts @@ -5,8 +5,8 @@ import { describe, expect, test } from "vitest"; import { materializeProviderImage } from "./provider-image-output.js"; describe.skipIf(process.platform === "win32")("materializeProviderImage", () => { - test("writes image attachments under a private temp directory", () => { - const materialized = materializeProviderImage({ + test("writes image attachments under a private temp directory", async () => { + const materialized = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png", }); diff --git a/packages/server/src/server/agent/providers/provider-image-output.test.ts b/packages/server/src/server/agent/providers/provider-image-output.test.ts index b4f4784a66..3388edb6d5 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.test.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.test.ts @@ -1,10 +1,11 @@ -import { existsSync, rmSync } from "node:fs"; +import { existsSync, rmSync, statSync, readFileSync } from "node:fs"; import path from "node:path"; import MarkdownIt from "markdown-it"; import { projectTimelineRows } from "../timeline-projection.js"; import { describe, expect, test } from "vitest"; import { + MAX_PROVIDER_IMAGE_BYTES, isProviderImageMarkdown, materializeProviderImage, renderProviderImageOutputAsAssistantMarkdown, @@ -12,8 +13,8 @@ import { const HASH = "a".repeat(64); -function renderImageMarkdown(imagePath: string): string { - const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath }); +async function renderImageMarkdown(imagePath: string): Promise { + const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath }); if (!item || item.type !== "assistant_message") { throw new Error("Expected provider image output to render as assistant markdown."); } @@ -40,8 +41,8 @@ describe("isProviderImageMarkdown", () => { ).toBe(true); }); - test("emits Windows file paths as file URIs", () => { - const markdown = renderImageMarkdown( + test("emits Windows file paths as file URIs", async () => { + const markdown = await renderImageMarkdown( `C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`, ); @@ -51,22 +52,24 @@ describe("isProviderImageMarkdown", () => { expect(isProviderImageMarkdown(markdown)).toBe(true); }); - test("emits POSIX file paths with spaces as valid file URI markdown", () => { - const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png"); + test("emits POSIX file paths with spaces as valid file URI markdown", async () => { + const markdown = await renderImageMarkdown( + "/home/user/Projects/Project With Spaces/screenshot.png", + ); expect(markdown).toBe( "\n\n![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)\n\n", ); }); - test("encodes URI-significant characters in POSIX file paths", () => { - const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png"); + test("encodes URI-significant characters in POSIX file paths", async () => { + const markdown = await renderImageMarkdown("/tmp/screenshot#1?draft.png"); expect(markdown).toBe("\n\n![Image](file:///tmp/screenshot%231%3Fdraft.png)\n\n"); }); - test("preserves double-leading slashes in POSIX file paths", () => { - const markdown = renderImageMarkdown("//tmp/screenshot#1.png"); + test("preserves double-leading slashes in POSIX file paths", async () => { + const markdown = await renderImageMarkdown("//tmp/screenshot#1.png"); expect(markdown).toBe("\n\n![Image](file:////tmp/screenshot%231.png)\n\n"); }); @@ -78,8 +81,8 @@ describe("isProviderImageMarkdown", () => { "\\\\?\\UNC\\server\\share\\shot?draft.png", "file://server/share/shot%3Fdraft.png", ], - ])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => { - expect(renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`); + ])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => { + expect(await renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`); }); test("rejects user-authored markdown that is not a materialized attachment", () => { @@ -92,8 +95,8 @@ describe("isProviderImageMarkdown", () => { }); describe("materializeProviderImage", () => { - test("recreates the private temp directory if the cached directory is removed", () => { - const first = materializeProviderImage({ + test("recreates the private temp directory if the cached directory is removed", async () => { + const first = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png", }); @@ -102,7 +105,7 @@ describe("materializeProviderImage", () => { rmSync(firstDir, { recursive: true, force: true }); - const second = materializeProviderImage({ + const second = await materializeProviderImage({ data: "ZGVmNDU2", mimeType: "image/png", }); @@ -117,8 +120,8 @@ describe("materializeProviderImage", () => { }); describe("provider image paragraph boundaries", () => { - test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => { - const image = renderProviderImageOutputAsAssistantMarkdown({ + test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", async (ending) => { + const image = await renderProviderImageOutputAsAssistantMarkdown({ url: "https://example.com/chart.png", }); if (!image) throw new Error("Expected image output"); @@ -152,10 +155,10 @@ describe("provider image paragraph boundaries", () => { }); }); -test("materialized image output retains paragraph boundaries and attachment recognition", () => { - const item = renderProviderImageOutputAsAssistantMarkdown( +test("materialized image output retains paragraph boundaries and attachment recognition", async () => { + const item = await renderProviderImageOutputAsAssistantMarkdown( { data: "YWJjMTIz", mimeType: "image/png" }, - { materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) }, + { materialize: async () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) }, ); expect(item).toEqual({ type: "assistant_message", @@ -164,3 +167,83 @@ test("materialized image output retains paragraph boundaries and attachment reco if (!item || item.type !== "assistant_message") throw new Error("Expected image output"); expect(isProviderImageMarkdown(item.text)).toBe(true); }); + +test("repeated content is reused without rewriting the attachment", async () => { + const image = { + data: Buffer.from("dedupe regression").toString("base64"), + mimeType: "image/png", + }; + const first = await materializeProviderImage(image); + const before = statSync(first.path, { bigint: true }); + await new Promise((resolve) => setTimeout(resolve, 20)); + const second = await materializeProviderImage(image); + expect(second.path).toBe(first.path); + expect(statSync(second.path, { bigint: true }).mtimeNs).toBe(before.mtimeNs); +}); + +test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => { + const bytes = Buffer.alloc(64 * 1024 * 1024, 37); + const data = bytes.toString("base64"); + // Main-thread CPU time between ticks bounds the decode and hash work done on the loop; + // wall-clock gaps on shared CI runners also count preemption and timer granularity. + // File writes stay on fs/promises, so they never block the loop. + const threadCpuMs = () => { + const { user, system } = process.threadCpuUsage(); + return (user + system) / 1000; + }; + let previous = threadCpuMs(); + let longest = 0; + const probe = setInterval(() => { + const now = threadCpuMs(); + longest = Math.max(longest, now - previous); + previous = now; + }, 1); + try { + const result = await materializeProviderImage({ data, mimeType: "image/png" }); + await new Promise((resolve) => setTimeout(resolve, 5)); + console.log(`64 MiB attachment longest blocking stretch: ${longest.toFixed(2)} ms CPU`); + expect(longest).toBeLessThan(50); + expect(readFileSync(result.path).equals(bytes)).toBe(true); + rmSync(result.path, { force: true }); + } finally { + clearInterval(probe); + } +}); + +test("preserves base64 decoding across whitespace and chunk boundaries", async () => { + const bytes = Buffer.alloc(512 * 1024, 123); + const base64 = bytes.toString("base64"); + const data = base64.match(/.{1,77}/g)!.join("\n"); + const result = await materializeProviderImage({ data, mimeType: "image/png" }); + expect(readFileSync(result.path).equals(bytes)).toBe(true); +}); + +test("rejects oversized materialization before work and leaves the queue usable", async () => { + const data = "A".repeat(Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4 + 260); + await expect(materializeProviderImage({ data, mimeType: "image/png" })).rejects.toThrow( + "exceeds", + ); + const omitted = await renderProviderImageOutputAsAssistantMarkdown( + { data, mimeType: "image/png" }, + { materialize: materializeProviderImage }, + ); + expect(omitted).toEqual({ + type: "assistant_message", + text: "Image output was omitted because it was not available as a file path or URL.", + }); + const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" }); + expect(readFileSync(image.path).toString()).toBe("abc123"); +}); + +test("simultaneous requests for the same content share the published file", async () => { + const image = { + data: Buffer.from("concurrent dedupe fixture").toString("base64"), + mimeType: "image/png", + }; + const results = await Promise.all([ + materializeProviderImage(image), + materializeProviderImage(image), + ]); + expect(results[0]).toEqual(results[1]); + expect(readFileSync(results[0].path).toString()).toBe("concurrent dedupe fixture"); +}); diff --git a/packages/server/src/server/agent/providers/provider-image-output.ts b/packages/server/src/server/agent/providers/provider-image-output.ts index ba8843918b..0cd0787c1e 100644 --- a/packages/server/src/server/agent/providers/provider-image-output.ts +++ b/packages/server/src/server/agent/providers/provider-image-output.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; -import * as fsSync from "node:fs"; +import * as fs from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; import os from "node:os"; import path from "node:path"; @@ -23,32 +24,26 @@ const PRIVATE_ATTACHMENT_DIR_MODE = 0o700; const MATERIALIZED_IMAGE_FILE_MODE = 0o600; let materializedImageAttachmentDir: string | null = null; +// Serialize materializations: one decoder and one file operation at a time, including +// concurrent history replay and live turns. A rejected operation must not poison the queue. +let pendingMaterialization: Promise = Promise.resolve(); -function canReuseMaterializedImageAttachmentDir(dir: string): boolean { - try { - const stats = fsSync.lstatSync(dir); - if (!stats.isDirectory()) { - return false; +async function getMaterializedImageAttachmentDir(): Promise { + if (materializedImageAttachmentDir) { + try { + const stats = await fs.lstat(materializedImageAttachmentDir); + if (stats.isDirectory()) { + await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE); + return materializedImageAttachmentDir; + } + } catch { + /* Temp storage may have been removed between turns. */ } - fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE); - return true; - } catch { - return false; } -} - -function getMaterializedImageAttachmentDir(): string { - if ( - materializedImageAttachmentDir && - canReuseMaterializedImageAttachmentDir(materializedImageAttachmentDir) - ) { - return materializedImageAttachmentDir; - } - - materializedImageAttachmentDir = fsSync.mkdtempSync( + materializedImageAttachmentDir = await fs.mkdtemp( path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX), ); - fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE); + await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE); return materializedImageAttachmentDir; } @@ -71,32 +66,92 @@ function getImageExtension(mimeType: string): string { } } -function normalizeImageData(mimeType: string, data: string): { mimeType: string; data: string } { - if (data.startsWith("data:")) { - const match = data.match(/^data:([^;]+);base64,(.*)$/); - if (match) { - return { mimeType: match[1], data: match[2] }; - } - } - return { mimeType, data }; -} +// 128 MiB admits the measured 64 MiB fixture with headroom while bounding disk +// and decoding per image. This is materialization admission, not a wire schema limit. +export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024; +const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned. -// Filenames are a content hash of the bytes so re-materializing the same image -// within a process reuses the existing temp file instead of leaking a fresh one -// for repeated image blocks or history replay. export function materializeProviderImage(image: { data: string; mimeType: string | null; -}): MaterializedProviderImage { - const attachmentsDir = getMaterializedImageAttachmentDir(); - const normalized = normalizeImageData(image.mimeType ?? "image/png", image.data); - const bytes = Buffer.from(normalized.data, "base64"); - const extension = getImageExtension(normalized.mimeType); - const hash = createHash("sha256").update(bytes).digest("hex"); - const filePath = path.join(attachmentsDir, `${hash}.${extension}`); - fsSync.writeFileSync(filePath, bytes, { mode: MATERIALIZED_IMAGE_FILE_MODE }); - fsSync.chmodSync(filePath, MATERIALIZED_IMAGE_FILE_MODE); - return { path: filePath }; +}): Promise { + const result = pendingMaterialization.then(() => materializeImage(image)); + pendingMaterialization = result.catch(() => {}); + return result; +} + +async function materializeImage(image: { + data: string; + mimeType: string | null; +}): Promise { + const maxEncodedCharacters = Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4; + // Check length before inspecting/copying the payload, including a bounded data URI header. + if (image.data.length > maxEncodedCharacters + 256) { + throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`); + } + let data = image.data; + let mimeType = image.mimeType ?? "image/png"; + if (data.startsWith("data:")) { + const separator = data.indexOf(";base64,"); + if (separator >= 0 && separator < 256) { + mimeType = data.slice(5, separator); + data = data.slice(separator + 8); + } + } + if (data.length > maxEncodedCharacters) { + throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`); + } + const hash = createHash("sha256"); + for await (const bytes of decodeImage(data)) hash.update(bytes); + const dir = await getMaterializedImageAttachmentDir(); + const filePath = path.join(dir, `${hash.digest("hex")}.${getImageExtension(mimeType)}`); + try { + await fs.stat(filePath); + return { path: filePath }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + const staging = await fs.mkdtemp(path.join(dir, ".pending-")); + const stagingPath = path.join(staging, "image"); + try { + await fs.chmod(staging, PRIVATE_ATTACHMENT_DIR_MODE); + const file = await fs.open(stagingPath, "wx", MATERIALIZED_IMAGE_FILE_MODE); + try { + await file.chmod(MATERIALIZED_IMAGE_FILE_MODE); + // Decode again only for a new hash. This bounds retained memory to one chunk + // and performs no writes at all for repeated content, including history replay. + for await (const bytes of decodeImage(data)) await file.writeFile(bytes); + } finally { + await file.close(); + } + await fs.rename(stagingPath, filePath); + return { path: filePath }; + } finally { + await fs.rm(staging, { recursive: true, force: true }); + } +} + +async function* decodeImage(data: string): AsyncGenerator { + let carry = ""; + let decodedBytes = 0; + for (let offset = 0; offset < data.length; offset += BASE64_CHUNK_CHARACTERS) { + // Buffer.from ignores non-base64 characters. Carry incomplete quartets so + // line-wrapped payloads decode identically across chunk boundaries. + const chunk = + carry + + data.slice(offset, offset + BASE64_CHUNK_CHARACTERS).replace(/[^A-Za-z0-9+/_=-]/g, ""); + const finalChunk = offset + BASE64_CHUNK_CHARACTERS >= data.length || chunk.includes("="); + const end = finalChunk ? chunk.length : chunk.length - (chunk.length % 4); + const bytes = Buffer.from(chunk.slice(0, end), "base64"); + carry = chunk.slice(end); + decodedBytes += bytes.length; + if (decodedBytes > MAX_PROVIDER_IMAGE_BYTES) + throw new Error("Image attachment exceeds size limit"); + yield bytes; + await setImmediate(); + if (finalChunk) break; + } } // Recognizes markdown rendered for a materialized provider image: its source is a content-hashed @@ -112,7 +167,10 @@ export function isProviderImageMarkdown(text: string): boolean { } interface RenderProviderImageOutputOptions { - materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage; + materialize?: (image: { + data: string; + mimeType: string | null; + }) => Promise; } function nonEmptyString(value: string | null | undefined): string | null { @@ -169,10 +227,10 @@ function escapeMarkdownImageSource(value: string): string { return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)"); } -export function renderProviderImageOutputAsAssistantMarkdown( +export async function renderProviderImageOutputAsAssistantMarkdown( image: ProviderImageOutput, options: RenderProviderImageOutputOptions = {}, -): AgentTimelineItem | null { +): Promise { const source = nonEmptyString(image.path) ?? nonEmptyString(image.url); if (source && !isDataImageSource(source)) { const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image"); @@ -191,7 +249,7 @@ export function renderProviderImageOutputAsAssistantMarkdown( let materialized: MaterializedProviderImage | null = null; try { materialized = options.materialize - ? options.materialize({ + ? await options.materialize({ data, mimeType: nonEmptyString(image.mimeType), }) diff --git a/packages/server/src/server/agent/providers/resume-process-cwd.ts b/packages/server/src/server/agent/providers/resume-process-cwd.ts new file mode 100644 index 0000000000..d32637a9a6 --- /dev/null +++ b/packages/server/src/server/agent/providers/resume-process-cwd.ts @@ -0,0 +1,26 @@ +import { stat } from "node:fs/promises"; +import { homedir } from "node:os"; + +import type { AgentResumePurpose } from "../agent-sdk-types.js"; + +export async function resolveResumeProcessCwd( + cwd: string, + purpose: AgentResumePurpose | undefined, +): Promise { + if (purpose !== "history" || (await isDirectory(cwd))) { + return cwd; + } + return homedir(); +} + +async function isDirectory(target: string): Promise { + try { + return (await stat(target)).isDirectory(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} diff --git a/packages/server/src/server/agent/restart-recovery/index.test.ts b/packages/server/src/server/agent/restart-recovery/index.test.ts new file mode 100644 index 0000000000..0b1658b47d --- /dev/null +++ b/packages/server/src/server/agent/restart-recovery/index.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentStorage, parseStoredAgentRecord } from "../agent-storage.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { createRestartRecovery } from "./index.js"; + +test("recovery saves intent before normalization, clears before dispatch, and resumes each agent independently", async () => { + const root = await mkdtemp(join(tmpdir(), "restart-recovery-")); + try { + const storage = new AgentStorage(root, createTestLogger()); + for (const [id, lastStatus] of [ + ["broken", "running"], + ["stuck", "running"], + ["working", "running"], + ["idle", "idle"], + ["initializing", "initializing"], + ] as const) { + await storage.upsert( + parseStoredAgentRecord({ + id, + lastStatus, + provider: "codex", + cwd: root, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + ); + } + const dispatched: string[] = []; + const recovery = createRestartRecovery({ + storage, + settings: () => ({ enabled: true, prompt: "Continue verbatim.\n" }), + eligible: async () => true, + dispatch: async (id, prompt) => { + expect((await storage.get(id))?.resumeOnStartup).toBe(false); + if (id === "broken") throw new Error("unavailable"); + if (id === "stuck") return new Promise(() => {}); + dispatched.push(id + prompt); + }, + report: () => {}, + }); + await recovery.prepareStartup(); + expect((await storage.get("working"))?.resumeOnStartup).toBe(true); + expect((await storage.get("working"))?.lastStatus).toBe("closed"); + recovery.resumePending(); + const running = new AbortController().signal; + await recovery.waitForResume("broken", running); + await recovery.waitForResume("working", running); + await recovery.waitForResume("idle", running); + expect(dispatched).toEqual(["workingContinue verbatim.\n"]); + const stopped = new AbortController(); + stopped.abort(); + await expect(recovery.waitForResume("stuck", stopped.signal)).rejects.toBeDefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("opt-out consumes pending recovery and excludes archived, private, owned and archived-workspace agents", async () => { + const root = await mkdtemp(join(tmpdir(), "restart-recovery-")); + try { + const storage = new AgentStorage(root, createTestLogger()); + for (const extra of [ + { id: "running" }, + { id: "archived", archivedAt: "today" }, + { id: "private", internal: true }, + { id: "owned", owner: { kind: "daemon", daemonId: "hub", executionId: "run" } }, + { id: "workspace", workspaceId: "archived" }, + ]) { + await storage.upsert( + parseStoredAgentRecord({ + lastStatus: "running", + provider: "codex", + cwd: root, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...extra, + }), + ); + } + let enabled = true; + const dispatched: string[] = []; + const recovery = createRestartRecovery({ + storage, + settings: () => ({ enabled, prompt: "Resume working." }), + eligible: async (record) => record.workspaceId !== "archived", + dispatch: async (id) => { + dispatched.push(id); + }, + report: () => {}, + }); + await recovery.captureRunningAgents(); + expect((await storage.get("running"))?.resumeOnStartup).toBe(true); + enabled = false; + await recovery.prepareStartup(); + enabled = true; + recovery.resumePending(); + expect(dispatched).toEqual([]); + expect((await storage.list()).every((record) => !record.resumeOnStartup)).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/restart-recovery/index.ts b/packages/server/src/server/agent/restart-recovery/index.ts new file mode 100644 index 0000000000..dc4e7d90c7 --- /dev/null +++ b/packages/server/src/server/agent/restart-recovery/index.ts @@ -0,0 +1,81 @@ +import type { AgentStorage, StoredAgentRecord } from "../agent-storage.js"; +interface Dependencies { + storage: AgentStorage; + settings(): { enabled: boolean; prompt: string }; + eligible(record: StoredAgentRecord): Promise; + dispatch(agentId: string, prompt: string): Promise; + report(agentId: string, error: unknown): void; +} +export function createRestartRecovery(deps: Dependencies) { + // Candidates are chosen at startup; each settles once its resume was sent or abandoned. + const resuming = new Map; resolve(): void }>(); + const eligible = async (record: StoredAgentRecord) => + !record.archivedAt && !record.internal && !record.owner && (await deps.eligible(record)); + async function resume(agentId: string) { + const record = await deps.storage.get(agentId); + if (!record?.resumeOnStartup) return; + await deps.storage.upsert({ ...record, resumeOnStartup: false }); + if (!deps.settings().enabled || !(await eligible(record))) return; + await deps.dispatch(agentId, deps.settings().prompt); + } + return { + /** Choose candidates before anything else reads or repairs persisted running status. */ + async prepareStartup() { + for (const record of await deps.storage.list()) { + const pending = + deps.settings().enabled && + (record.resumeOnStartup === true || record.lastStatus === "running") && + (await eligible(record)); + await deps.storage.upsert({ + ...record, + resumeOnStartup: pending, + lastStatus: + record.lastStatus === "running" || record.lastStatus === "initializing" + ? "closed" + : record.lastStatus, + }); + if (pending) { + let resolve!: () => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + resuming.set(record.id, { promise, resolve }); + } + } + }, + async captureRunningAgents() { + await deps.storage.flush(); + for (const record of await deps.storage.list()) { + if (record.lastStatus === "running" && (await eligible(record))) + await deps.storage.upsert({ ...record, resumeOnStartup: deps.settings().enabled }); + } + }, + /** Resume every candidate in the background; a slow provider holds back only its agent. */ + resumePending() { + for (const [agentId, settled] of resuming) { + void resume(agentId) + .catch((error) => deps.report(agentId, error)) + .finally(() => { + resuming.delete(agentId); + settled.resolve(); + }); + } + }, + /** Wait until this agent's resume was sent or abandoned. Others return at once. */ + async waitForResume(agentId: string, signal: AbortSignal) { + const settled = resuming.get(agentId); + if (!settled) return; + let abort!: () => void; + const aborted = new Promise((_resolve, reject) => { + abort = () => reject(signal.reason); + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + }); + try { + await Promise.race([settled.promise, aborted]); + } finally { + signal.removeEventListener("abort", abort); + } + }, + }; +} diff --git a/packages/server/src/server/agent/runtime-mcp-config.ts b/packages/server/src/server/agent/runtime-mcp-config.ts index af04e8021c..711638ce2f 100644 --- a/packages/server/src/server/agent/runtime-mcp-config.ts +++ b/packages/server/src/server/agent/runtime-mcp-config.ts @@ -1,6 +1,6 @@ import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js"; -const PASEO_MCP_SERVER_NAME = "paseo"; +export const PASEO_MCP_SERVER_NAME = "paseo"; const PASEO_MCP_PATHNAME = "/mcp/agents"; export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig { diff --git a/packages/server/src/server/agent/stt-manager.test.ts b/packages/server/src/server/agent/stt-manager.test.ts index e3f6fb7d9c..5b1c036bc7 100644 --- a/packages/server/src/server/agent/stt-manager.test.ts +++ b/packages/server/src/server/agent/stt-manager.test.ts @@ -1,9 +1,12 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import pino from "pino"; import { EventEmitter } from "node:events"; import { STTManager } from "./stt-manager.js"; -import { PersistedConfigSchema } from "../persisted-config.js"; +import { loadPersistedConfig } from "../persisted-config.js"; import { resolveSpeechConfig } from "../speech/speech-config-resolver.js"; import type { SpeechToTextProvider, @@ -97,12 +100,18 @@ class SequencedFakeStt implements SpeechToTextProvider { describe("STTManager", () => { function resolveVoiceLanguage(params: { env?: NodeJS.ProcessEnv; persisted?: unknown }): string { - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: params.env ?? ({} as NodeJS.ProcessEnv), - persisted: PersistedConfigSchema.parse(params.persisted ?? {}), - }); - return result.speech.sttLanguages.voice; + const home = mkdtempSync(path.join(tmpdir(), "stt-language-")); + try { + writeFileSync(path.join(home, "config.json"), JSON.stringify(params.persisted ?? {})); + const result = resolveSpeechConfig({ + paseoHome: home, + env: params.env ?? {}, + persisted: loadPersistedConfig(home), + }); + return result.speech.sttLanguages.voice; + } finally { + rmSync(home, { recursive: true, force: true }); + } } async function transcribeWithResolvedVoiceLanguage(params: { diff --git a/packages/server/src/server/agent/test-utils/plugin-stream-memory.ts b/packages/server/src/server/agent/test-utils/plugin-stream-memory.ts new file mode 100644 index 0000000000..948202bae2 --- /dev/null +++ b/packages/server/src/server/agent/test-utils/plugin-stream-memory.ts @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; +import pino from "pino"; +import { PluginAgentClientRegistry } from "../plugin-provider.js"; +import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js"; +import { AgentManager } from "../agent-manager.js"; +import { AgentStorage } from "../agent-storage.js"; +import { buildProviderRegistry } from "../provider-registry.js"; + +if (!global.gc) throw new Error("Run with --expose-gc"); +const logger = pino({ level: "silent" }); +const plugins = new PluginAgentClientRegistry(logger); +const root = await mkdtemp(join(tmpdir(), "plugin-stream-memory-")); +const emitters: Array<(event: ProviderEvent) => void> = []; +const ids: string[] = []; +const registrations: ProviderRegistration[] = Array.from({ length: 3 }, (_, index) => ({ + id: `memory-${index}`, + label: "Memory fixture", + async connect() { + let listener: (event: ProviderEvent) => void = () => {}; + const capabilities = ["prompt.message", "session.persistence"] as const; + return { + version: 1, + capabilities, + onEvent(callback) { + listener = callback; + // Plugin IPC delivers independent JSON strings, rather than shared ropes. + emitters[index] = (event) => listener(JSON.parse(JSON.stringify(event))); + return () => { + listener = () => {}; + }; + }, + async send(input) { + if (input.type === "session.open") { + ids[index] = input.sessionId; + listener({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + cwd: "/tmp", + capabilities, + restoration: "core", + persistence: { version: 1, data: { index } }, + }); + listener({ + type: "session.ready", + requestId: input.requestId, + sessionId: input.sessionId, + }); + } else if (input.type === "catalog") { + listener({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [], modes: [], thinkingOptions: [] }, + }); + } else if (input.type === "session.close") { + listener({ type: "session.closed", sessionId: input.sessionId }); + listener({ type: "request.completed", requestId: input.requestId }); + } + }, + async close() {}, + }; + }, +})); +plugins.replace(registrations); +const store = new InMemoryAgentTimelineStore(); +const manager = new AgentManager({ + clients: plugins.clients(), + providerDefinitions: buildProviderRegistry(logger, { pluginProviders: plugins.definitions() }), + registry: new AgentStorage(root, logger), + timelineStore: store, + logger, +}); +const agents = await Promise.all( + Object.keys(plugins.clients()).map((provider) => + manager.createAgent({ provider, cwd: root }, undefined, {}), + ), +); +const sessions = agents.map((agent) => agent.session!); +agents.forEach((_agent, index) => { + emitters[index]!({ + type: "session.opened", + sessionId: `child-${index}`, + parentSessionId: ids[index]!, + capabilities: [], + restoration: "parent", + cwd: root, + }); +}); +global.gc(); +const before = process.memoryUsage().heapUsed; +const steps = 4; +const chunks = 200; +const expected = new Map(); +for (let step = 0; step < steps; step++) { + for (let index = 0; index < sessions.length; index++) { + const text = `${index}:${step}:` + "推理".repeat(15000) + "a\uD83D\uDE00b"; + expected.set(`${index}:${step}`, text); + for (let chunk = 1; chunk <= chunks; chunk++) { + for (const sessionId of [ids[index]!, `child-${index}`]) { + emitters[index]!({ + type: "timeline.item", + sessionId, + item: { + type: "reasoning", + id: `reason-${step}`, + text: text.slice(0, Math.ceil((chunk * text.length) / chunks)), + }, + }); + } + } + emitters[index]!({ + type: "timeline.item", + sessionId: ids[index]!, + item: { + type: "tool_call", + id: `tool-${step}`, + callId: `tool-${step}`, + name: "read", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: "输出".repeat(16000) }, + }, + }); + emitters[index]!({ + type: "timeline.item", + sessionId: `child-${index}`, + item: { + type: "assistant_message", + id: `answer-${step}`, + text: "Child step complete.", + }, + }); + } + // Drain the manager's event queue; terminal tool states flush its real coalescer. + await delay(80); +} +global.gc(); +const retained = process.memoryUsage().heapUsed - before; +assert(retained < 24 * 1024 * 1024, `Retained heap exceeded 24 MiB: ${retained} bytes`); +for (let index = 0; index < sessions.length; index++) { + const items = store.getItems(agents[index]!.id); + assert.equal(items.length, steps * 2); + for (let step = 0; step < steps; step++) { + const item = items[step * 2]!; + assert.equal(item.type === "reasoning" && item.text, expected.get(`${index}:${step}`)); + } + const children = manager.listProviderSubagents(agents[index]!.id); + assert.equal(children.length, 1); + let rootItems = 0; + let childItems = 0; + for await (const event of sessions[index]!.streamHistory()) { + if (event.type === "timeline") rootItems++; + if (event.type === "provider_subagent" && event.event.type === "timeline") childItems++; + } + assert.equal(rootItems, steps * 2); + assert.equal(childItems, steps * 2); + await manager.archiveAgent(agents[index]!.id); + assert.equal(store.has(agents[index]!.id), false); + assert.equal( + manager.listAgents().some((agent) => agent.id === agents[index]!.id), + false, + ); +} +await plugins.shutdown(); +await manager.flushForShutdown(); +await rm(root, { recursive: true, force: true }); +global.gc(); +const closed = process.memoryUsage().heapUsed - before; +// Keep closed session references alive, as callers can retain them after archive. +assert.equal(sessions.length, 3); +process.stdout.write(JSON.stringify({ retained, closed })); diff --git a/packages/server/src/server/agent/test-utils/timeline-page-reference.ts b/packages/server/src/server/agent/test-utils/timeline-page-reference.ts new file mode 100644 index 0000000000..b3a51f22e5 --- /dev/null +++ b/packages/server/src/server/agent/test-utils/timeline-page-reference.ts @@ -0,0 +1,249 @@ +// Frozen pre-index page selection: differential oracle for retained projection tests. +import type { AgentTimelineRow } from "../agent-timeline-store-types.js"; +import { + TimelineProjection, + projectTimelineRows, + type TimelineProjectionEntry, + type ProjectedTimelineRow, + type TimelineLimitDirection, + type ProjectedTimelinePageSelection, +} from "../timeline-projection.js"; + +function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): TimelineProjectionEntry[] { + 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 getTimelineBounds( + rows: readonly AgentTimelineRow[], +): { minSeq: number; maxSeq: number } | null { + const first = rows[0]; + const last = rows[rows.length - 1]; + if (!first || !last) { + return null; + } + 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( + entry: TimelineProjectionEntry, + startSeq: number, + endSeq: number, +): number | null { + for (const range of entry.sourceSeqRanges) { + const firstSeq = Math.max(range.startSeq, startSeq); + if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq; + } + return null; +} + +interface ProjectedEntryCandidate { + entry: TimelineProjectionEntry; + index: number; + firstSourceSeq: number; +} + +function selectProjectedEntriesAfter(input: { + entries: readonly TimelineProjectionEntry[]; + startSeq: number; + maxSeq: number; + limit: number; +}): { entries: TimelineProjectionEntry[]; endSeq: number | null } { + const eligible = input.entries + .map((entry, index) => ({ + entry, + index, + firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq), + })) + .filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null) + .sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index); + const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit); + const selectedEntries = selected + .sort((left, right) => left.index - right.index) + .map((candidate) => candidate.entry); + if (selectedEntries.length === 0) return { entries: [], endSeq: null }; + + const selectedRanges = selectedEntries + .flatMap((entry) => entry.sourceSeqRanges) + .sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq); + // 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; + for (const range of selectedRanges) { + if (range.endSeq <= endSeq) continue; + if (range.startSeq > endSeq + 1) break; + endSeq = Math.min(range.endSeq, input.maxSeq); + } + + return { + entries: selectedEntries, + endSeq: endSeq >= input.startSeq ? endSeq : null, + }; +} + +function selectProjectedEntriesBefore(input: { + entries: readonly TimelineProjectionEntry[]; + endSeq: number; + limit: number; +}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } { + // Older history follows projected display order. Lifecycle updates can move an + // entry's seqEnd without moving its display anchor, so seqStart keeps the full + // projected item on exactly one backward page. + const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq); + const selected = + input.limit === 0 || input.limit >= eligible.length + ? eligible + : eligible.slice(eligible.length - input.limit); + + return { + entries: selected, + startSeq: selected[0]?.seqStart ?? null, + hasOlder: selected.length < eligible.length, + }; +} + +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 selectReferenceProjectedTimelinePage(input: { + rows: readonly AgentTimelineRow[]; + bounds?: { minSeq: number; maxSeq: number }; + direction: TimelineLimitDirection; + cursorSeq?: number; + limit?: number; +}): ProjectedTimelinePageSelection { + const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit)); + const bounds = input.bounds ?? getTimelineBounds(input.rows); + const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" }); + if (!bounds) { + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: false, + }; + } + + if (projectedAll.length === 0) { + if (input.direction === "after") { + const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: cursorSeq >= bounds.minSeq, + hasNewer: cursorSeq < bounds.maxSeq, + }; + } + if (input.direction === "before") { + const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: cursorSeq > bounds.minSeq, + hasNewer: cursorSeq <= bounds.maxSeq, + }; + } + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: false, + }; + } + + if (input.direction === "tail") { + return selectProjectedEntriesTail(projectedAll, limit, bounds); + } + + if (input.direction === "after") { + const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; + const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); + const selected = selectProjectedEntriesAfter({ + entries: projectedAll, + startSeq, + maxSeq: bounds.maxSeq, + limit, + }); + return { + entries: selected.entries, + startSeq: selected.endSeq === null ? null : startSeq, + endSeq: selected.endSeq, + hasOlder: startSeq > bounds.minSeq, + hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq, + }; + } + + const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; + const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); + if (endSeq < bounds.minSeq) { + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: false, + hasNewer: endSeq < bounds.maxSeq, + }; + } + + const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit }); + return { + entries: selected.entries, + startSeq: selected.startSeq, + endSeq, + hasOlder: selected.hasOlder, + hasNewer: endSeq < bounds.maxSeq, + }; +} + +// Existing selection fixtures exercise the retained owner through its public API. +export function selectProjectedTimelinePage( + input: Parameters[0] & { wholeTurns?: boolean }, +) { + const projection = new TimelineProjection(input.rows); + const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 }; + const page = projection.selectPage({ ...input, bounds }); + return { + ...page, + entries: page.entries.map((row) => { + const { seq: _seq, ...entry } = row as ProjectedTimelineRow; + return entry; + }), + }; +} diff --git a/packages/server/src/server/agent/test-utils/timeline-responsiveness.ts b/packages/server/src/server/agent/test-utils/timeline-responsiveness.ts new file mode 100644 index 0000000000..bfe5ee59c4 --- /dev/null +++ b/packages/server/src/server/agent/test-utils/timeline-responsiveness.ts @@ -0,0 +1,25 @@ +import { setImmediate as nextTurn } from "node:timers/promises"; +import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js"; +import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js"; + +// Measure a cold fetch on its own thread, without Vitest reporting or callbacks. +const wholeTurns = process.argv[2] === "true"; +const store = new InMemoryAgentTimelineStore(); +store.initialize("large"); +for (let index = 0; index < 100_000; index++) { + store.append("large", { type: "user_message", text: String(index) }); +} +// Finish fixture allocation and collect its temporary garbage before measuring. +globalThis.gc!(); +await nextTurn(); +const measured = await measureMainThreadWork( + `timeline tail main-thread work (wholeTurns=${wholeTurns})`, + () => { + let page = store.fetch("large", { limit: 200, wholeTurns }); + for (let repeat = 1; repeat < 8; repeat++) + page = store.fetch("large", { limit: 200, wholeTurns }); + return page; + }, +); + +process.stdout.write(JSON.stringify(measured)); diff --git a/packages/server/src/server/agent/timeline-projection.test.ts b/packages/server/src/server/agent/timeline-projection.test.ts index 2d8cf9d4a1..d5e4c5b637 100644 --- a/packages/server/src/server/agent/timeline-projection.test.ts +++ b/packages/server/src/server/agent/timeline-projection.test.ts @@ -1,9 +1,9 @@ +import { selectProjectedTimelinePage } from "./test-utils/timeline-page-reference.js"; import { describe, expect, test } from "vitest"; import type { AgentTimelineRow } from "./agent-manager.js"; import { projectTimelineRows, - selectProjectedTimelinePage, selectTimelineWindowByProjectedLimit, } from "./timeline-projection.js"; @@ -534,6 +534,154 @@ describe("selectProjectedTimelinePage", () => { }; } + test("whole-turn pages extend past the soft target and keep prompt jumps whole", () => { + const rows: AgentTimelineRow[] = Array.from({ length: 125 }, (_, index) => ({ + seq: index + 1, + timestamp: new Date(1000 + index).toISOString(), + turnId: ["short", "long", "latest"][Number(index >= 3) + Number(index >= 123)], + item: { + type: "tool_call", + callId: `tool-${index}`, + name: "shell", + status: "completed", + error: null, + detail: { type: "unknown", input: null, output: null }, + }, + })); + const tail = selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 40, + wholeTurns: true, + }); + expect(tail.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3).map((row) => row.seq), + ); + expect(tail).toMatchObject({ startSeq: 4, endSeq: 125, hasOlder: true, hasNewer: false }); + const before = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 124, + limit: 40, + wholeTurns: true, + }); + expect(before.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3, 123).map((row) => row.seq), + ); + expect(before.endSeq).toBe(123); + const older = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 4, + limit: 40, + wholeTurns: true, + }); + expect(older.entries.map((entry) => entry.seqStart)).toEqual([1, 2, 3]); + expect(older.hasOlder).toBe(false); + const jump = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 70, + limit: 40, + wholeTurns: true, + }); + expect(jump.entries.map((entry) => entry.seqStart)).toEqual( + rows.slice(3, 123).map((row) => row.seq), + ); + expect(jump).toMatchObject({ startSeq: 4, endSeq: 123, hasOlder: true, hasNewer: true }); + const nearPreviousPrompt = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 10, + limit: 40, + wholeTurns: true, + }); + expect(nearPreviousPrompt).toMatchObject({ startSeq: 1, endSeq: 123 }); + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 40 }).entries, + ).toHaveLength(40); + }); + + test("whole-turn pages share short runs and use the same deterministic legacy triggers", () => { + const rows: AgentTimelineRow[] = Array.from({ length: 24 }, (_, index) => ({ + seq: index + 1, + timestamp: new Date(1000 + index).toISOString(), + item: + index % 2 === 0 + ? { type: "user_message", text: `prompt-${index}` } + : { type: "assistant_message", text: `reply-${index}` }, + })); + const page = selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 7, + wholeTurns: true, + }); + expect(page.entries.map((entry) => entry.seqStart)).toEqual([17, 18, 19, 20, 21, 22, 23, 24]); + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 0, wholeTurns: true }).entries, + ).toHaveLength(24); + rows.push({ + ...toolRow(25, "completed"), + item: { ...toolRow(25, "completed").item, name: "heartbeat" } as AgentTimelineRow["item"], + }); + rows.push({ + seq: 26, + timestamp: new Date().toISOString(), + item: { type: "assistant_message", text: "awake" }, + }); + expect( + selectProjectedTimelinePage({ + rows, + direction: "tail", + limit: 1, + wholeTurns: true, + }).entries.map((entry) => entry.seqStart), + ).toEqual([25, 26]); + }); + + test("whole-turn tails close interleaved source coverage without changing forward catch-up", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: new Date(1).toISOString(), + turnId: "first", + item: { type: "user_message", text: "go" }, + }, + { ...toolRow(2, "running"), turnId: "first" }, + { + seq: 3, + timestamp: new Date(3).toISOString(), + turnId: "second", + item: { type: "user_message", text: "next" }, + }, + { + seq: 4, + timestamp: new Date(4).toISOString(), + turnId: "second", + item: { type: "assistant_message", text: "done" }, + }, + { ...toolRow(5, "completed"), turnId: "first" }, + ]; + expect( + selectProjectedTimelinePage({ rows, direction: "tail", limit: 1, wholeTurns: true }), + ).toMatchObject({ + startSeq: 1, + endSeq: 5, + hasOlder: false, + hasNewer: false, + }); + expect( + selectProjectedTimelinePage({ + rows, + direction: "after", + cursorSeq: 2, + limit: 1, + wholeTurns: true, + }), + ).toEqual(selectProjectedTimelinePage({ rows, direction: "after", cursorSeq: 2, limit: 1 })); + }); + test("tail page returns full projected items instead of tool lifecycle deltas", () => { const rows: AgentTimelineRow[] = [ { seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } }, diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index 1f72cdc498..bd5357e581 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -1,3 +1,4 @@ +import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn"; import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js"; import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; @@ -295,12 +296,45 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] /** A retained item. `seq` is its latest source event, not its display anchor. */ export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry; +interface ProjectedTimelinePageOptions { + wholeTurns?: boolean; + bounds: { minSeq: number; maxSeq: number }; + direction: TimelineLimitDirection; + cursorSeq?: number; + limit?: number; +} + /** Owns projected state; source events are consumed, never retained. */ export class TimelineProjection { private readonly rows: ProjectedTimelineRow[] = []; private readonly identities = new Map(); + private lastAssistantRowIndex: number | null = null; + // Disjoint source-coverage spans in display order. A tail starting inside a + // span must include its first anchor to cover interleaved lifecycle updates. + private readonly spans: { start: number; endSeq: number }[] = []; - append(row: AgentTimelineRow): void { + private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = []; + + constructor(rows: readonly AgentTimelineRow[] = []) { + for (const row of rows) this.appendProjected(row); + for (const [rowIndex, row] of this.rows.entries()) { + for (const range of row.sourceSeqRanges) this.sourceRanges.push({ ...range, rowIndex }); + } + this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq); + } + + append(row: AgentTimelineRow): ProjectedTimelineRow { + const rowIndex = this.appendProjected(row); + const last = this.sourceRanges.at(-1); + if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) { + last.endSeq = row.seq; + } else { + this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex }); + } + return { ...this.rows[rowIndex] }; + } + + private appendProjected(row: AgentTimelineRow): number { const entry = makeCanonicalEntries([row])[0]; const identity = timelineItemIdentity(row.item); const index = identity === null ? undefined : this.identities.get(identity); @@ -308,7 +342,8 @@ export class TimelineProjection { const merged = existing ? mergeIdentityEntries(existing, entry) : null; if (merged && index !== undefined) { this.rows[index] = { ...merged, seq: merged.seqEnd }; - return; + this.extendSpan(index); + return index; } const previous = this.rows.at(-1); const adjacent = previous @@ -317,16 +352,181 @@ export class TimelineProjection { if (previous && adjacent.length === 1) { const mergedText = adjacent[0]; this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd }; - return; + this.extendSpan(this.rows.length - 1); + return this.rows.length - 1; } if (identity !== null) this.identities.set(identity, this.rows.length); + if (entry.item.type === "assistant_message") this.lastAssistantRowIndex = this.rows.length; this.rows.push({ ...entry, seq: entry.seqEnd }); + const last = this.spans.at(-1); + if (last && last.endSeq >= entry.seqStart) { + last.endSeq = Math.max(last.endSeq, entry.seqEnd); + } else { + this.spans.push({ start: this.rows.length - 1, endSeq: entry.seqEnd }); + } + return this.rows.length - 1; + } + + private spanIndex(rowIndex: number): number { + let low = 0; + let high = this.spans.length; + while (low + 1 < high) { + const middle = (low + high) >>> 1; + if (this.spans[middle].start <= rowIndex) low = middle; + else high = middle; + } + return low; + } + + private extendSpan(rowIndex: number): void { + const index = this.spanIndex(rowIndex); + const span = this.spans[index]; + span.endSeq = Math.max(span.endSeq, this.rows[rowIndex].seqEnd); + let end = index + 1; + while (end < this.spans.length && this.rows[this.spans[end].start].seqStart <= span.endSeq) { + span.endSeq = Math.max(span.endSeq, this.spans[end].endSeq); + end++; + } + this.spans.splice(index + 1, end - index - 1); } getRows(): ProjectedTimelineRow[] { return this.rows.map((row) => ({ ...row })); } + selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + if (this.rows.length === 0) { + const cursor = + input.cursorSeq ?? + (input.direction === "before" ? input.bounds.maxSeq + 1 : input.bounds.minSeq - 1); + return { + entries: [], + startSeq: null, + endSeq: null, + hasOlder: + input.direction === "after" + ? cursor >= input.bounds.minSeq + : input.direction === "before" && cursor > input.bounds.minSeq, + hasNewer: + input.direction === "after" + ? cursor < input.bounds.maxSeq + : input.direction === "before" && cursor <= input.bounds.maxSeq, + }; + } + switch (input.direction) { + case "tail": + return this.selectTail(input); + case "after": + return this.selectAfter(input); + case "before": + return this.selectBefore(input); + } + } + + private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + const limit = Math.max(0, Math.floor(input.limit ?? 0)); + const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit); + const coveredStart = this.spans[this.spanIndex(requestedStart)].start; + const start = input.wholeTurns ? this.wholeTurnWindowStart(coveredStart) : coveredStart; + return { + entries: this.rows.slice(start), + startSeq: this.rows[start].seqStart, + endSeq: input.bounds.maxSeq, + hasOlder: start > 0, + hasNewer: false, + }; + } + + private wholeTurnWindowStart(requestedStart: number): number { + let start = requestedStart; + for (;;) { + while (start > 0 && continuesProjectedTurn(this.rows[start - 1], this.rows[start])) start--; + // Source coverage can pull in an earlier tool and therefore its entire run. + const coveredStart = this.spans[this.spanIndex(start)].start; + if (coveredStart === start) return start; + start = coveredStart; + } + } + + private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + const startSeq = Math.max( + input.bounds.minSeq, + (input.cursorSeq ?? input.bounds.minSeq - 1) + 1, + ); + let low = 0; + let high = this.sourceRanges.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (this.sourceRanges[middle].endSeq < startSeq) low = middle + 1; + else high = middle; + } + const indexes = new Set(); + const limit = Math.max(0, Math.floor(input.limit ?? 0)); + for (let i = low; i < this.sourceRanges.length; i++) { + const range = this.sourceRanges[i]; + if (range.startSeq > input.bounds.maxSeq) break; + indexes.add(range.rowIndex); + if (limit > 0 && indexes.size === limit) break; + } + const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]); + const selected = selectProjectedEntriesAfter({ + entries, + startSeq, + maxSeq: input.bounds.maxSeq, + limit, + }); + return { + entries: selected.entries, + startSeq: selected.endSeq === null ? null : startSeq, + endSeq: selected.endSeq, + hasOlder: startSeq > input.bounds.minSeq, + hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq, + }; + } + + private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection { + const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1); + if (endSeq >= input.bounds.minSeq) { + let low = 0; + let high = this.rows.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (this.rows[middle].seqStart <= endSeq) low = middle + 1; + else high = middle; + } + const limit = Math.max(0, Math.floor(input.limit ?? 0)); + const requestedStart = limit === 0 ? 0 : Math.max(0, low - limit); + const start = input.wholeTurns ? this.wholeTurnWindowStart(requestedStart) : requestedStart; + // Choose the window before extending the right edge, so a prompt jump + // cannot lose its target when the next turn is arbitrarily large. + if (input.wholeTurns) { + while (low < this.rows.length && continuesProjectedTurn(this.rows[low - 1], this.rows[low])) + low++; + } + let selectedEnd = endSeq; + if (input.wholeTurns) + selectedEnd = low === this.rows.length ? input.bounds.maxSeq : this.rows[low].seqStart - 1; + return { + entries: this.rows.slice(start, low), + startSeq: this.rows[start]?.seqStart ?? null, + endSeq: selectedEnd, + hasOlder: start > 0, + hasNewer: selectedEnd < input.bounds.maxSeq, + }; + } + return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true }; + } + + getLastItem(seq: number): AgentTimelineItem | null { + const last = this.sourceRanges.at(-1); + return last?.endSeq === seq ? this.rows[last.rowIndex].item : null; + } + + getLastAssistantMessage(): string | null { + const row = this.lastAssistantRowIndex === null ? null : this.rows[this.lastAssistantRowIndex]; + return row?.item.type === "assistant_message" ? row.item.text : null; + } + enrichSubmittedUserMessage( clientMessageId: string, providerMessageId: string, @@ -444,21 +644,6 @@ export function selectTimelineWindowByProjectedLimit(input: { }; } -function getTimelineBounds( - rows: readonly AgentTimelineRow[], -): { minSeq: number; maxSeq: number } | null { - const first = rows[0]; - const last = rows[rows.length - 1]; - if (!first || !last) { - return null; - } - 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( entry: TimelineProjectionEntry, startSeq: number, @@ -515,140 +700,11 @@ function selectProjectedEntriesAfter(input: { }; } -function selectProjectedEntriesBefore(input: { - entries: readonly TimelineProjectionEntry[]; - endSeq: number; - limit: number; -}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } { - // Older history follows projected display order. Lifecycle updates can move an - // entry's seqEnd without moving its display anchor, so seqStart keeps the full - // projected item on exactly one backward page. - const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq); - const selected = - input.limit === 0 || input.limit >= eligible.length - ? eligible - : eligible.slice(eligible.length - input.limit); - - return { - entries: selected, - startSeq: selected[0]?.seqStart ?? null, - hasOlder: selected.length < eligible.length, - }; -} - -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 }; - direction: TimelineLimitDirection; - cursorSeq?: number; - limit?: number; -}): ProjectedTimelinePageSelection { - const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit)); - const bounds = input.bounds ?? getTimelineBounds(input.rows); - const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" }); - if (!bounds) { - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: false, - hasNewer: false, - }; - } - - if (projectedAll.length === 0) { - if (input.direction === "after") { - const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: cursorSeq >= bounds.minSeq, - hasNewer: cursorSeq < bounds.maxSeq, - }; - } - if (input.direction === "before") { - const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: cursorSeq > bounds.minSeq, - hasNewer: cursorSeq <= bounds.maxSeq, - }; - } - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: false, - hasNewer: false, - }; - } - - if (input.direction === "tail") { - return selectProjectedEntriesTail(projectedAll, limit, bounds); - } - - if (input.direction === "after") { - const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; - const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); - const selected = selectProjectedEntriesAfter({ - entries: projectedAll, - startSeq, - maxSeq: bounds.maxSeq, - limit, - }); - return { - entries: selected.entries, - startSeq: selected.endSeq === null ? null : startSeq, - endSeq: selected.endSeq, - hasOlder: startSeq > bounds.minSeq, - hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq, - }; - } - - const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; - const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); - if (endSeq < bounds.minSeq) { - return { - entries: [], - startSeq: null, - endSeq: null, - hasOlder: false, - hasNewer: endSeq < bounds.maxSeq, - }; - } - - const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit }); - return { - entries: selected.entries, - startSeq: selected.startSeq, - endSeq, - hasOlder: selected.hasOlder, - hasNewer: endSeq < bounds.maxSeq, - }; +function continuesProjectedTurn( + previous: TimelineProjectionEntry, + next: TimelineProjectionEntry, +): boolean { + return continuesTimelineTurn(previous.turnId, next.turnId, isTimelineTurnTrigger(next.item)); } /** diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 6b84f17161..0ca6c5a3e4 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,4 @@ +import { registerPluginTools } from "./plugin-tools.js"; import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js"; import type { AgentMessageDelivery } from "../agent-messages/index.js"; import { @@ -704,6 +705,7 @@ function createToolCatalog( } const localHostOperations = new Map(); const tools = new Map(); + const builtinNames = new Set(); const hostTools = new Map([ ["create_agent", "createAgent"], ["create_workspace", "createWorkspace"], @@ -738,6 +740,7 @@ function createToolCatalog( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries. handler: (input: any, context: PaseoToolExecutionContext) => Promise, ) => { + builtinNames.add(name); if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) { return; } @@ -3272,6 +3275,14 @@ function createToolCatalog( }, ); + registerPluginTools({ + agentManager, + callerAgentId, + scope, + tools, + builtinNames, + policy: options.paseoToolPolicy, + }); return toCatalog(); } diff --git a/packages/server/src/server/agent/tools/plugin-tools.ts b/packages/server/src/server/agent/tools/plugin-tools.ts new file mode 100644 index 0000000000..c0c94d11ab --- /dev/null +++ b/packages/server/src/server/agent/tools/plugin-tools.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; +import type { AgentManager } from "../agent-manager.js"; +import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; +import type { PaseoToolDefinition } from "./types.js"; +import { addModelVisibleStructuredContent } from "./paseo-tool-serialization.js"; + +export function registerPluginTools(options: { + agentManager: AgentManager; + callerAgentId?: string; + scope: "agent" | "top-level"; + tools: Map; + builtinNames: Set; + policy?: ProviderPaseoToolsPolicy; +}): void { + const { agentManager, callerAgentId, scope, tools, builtinNames, policy } = options; + if (scope !== "agent" || !callerAgentId) return; + for (const tool of agentManager.getPluginTools(callerAgentId)) { + if (builtinNames.has(tool.name)) { + throw new Error( + `Agent tool ${tool.name} from plugin ${tool.pluginId} collides with Paseo tool ${tool.name}`, + ); + } + if (!isPaseoToolEnabled(policy, tool.name)) continue; + tools.set(tool.name, { + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: z.fromJSONSchema(tool.inputSchema), + async handler(input, { signal }) { + const result = await agentManager.executePluginTool(tool, callerAgentId, input, signal); + return addModelVisibleStructuredContent({ + content: result.text === undefined ? [] : [{ type: "text", text: result.text }], + structuredContent: result.structuredContent, + isError: result.isError, + }); + }, + }); + } +} diff --git a/packages/server/src/server/atomic-file.test.ts b/packages/server/src/server/atomic-file.test.ts new file mode 100644 index 0000000000..a521a1cc3c --- /dev/null +++ b/packages/server/src/server/atomic-file.test.ts @@ -0,0 +1,147 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js"; + +let directory: string; +beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-")); +}); +afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }); +}); + +it.each(["EPERM", "EACCES", "EBUSY"])( + "commits a completed receipt after transient Windows %s replace failures", + async (code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + let failures = 3; + let elapsed = 0; + await writeJsonFileAtomic( + file, + { state: "completed" }, + { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + elapsed += milliseconds; + }, + rename: async (source, destination) => { + if (failures-- > 0) { + expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({ + state: "pending", + }); + expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" }); + throw Object.assign(new Error("destination held open"), { code }); + } + await fs.rename(source, destination); + }, + }, + ); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); + expect(elapsed).toBeGreaterThan(0); + }, +); + +it.each(["EPERM", "EACCES", "EBUSY"])( + "preserves the previous file and removes the temp file when Windows %s persists", + async (code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + const error = Object.assign(new Error("destination held open"), { code }); + const waits: number[] = []; + let elapsed = 0; + const attempts: number[] = []; + await expect( + writeJsonFileAtomic( + file, + { state: "completed" }, + { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + waits.push(milliseconds); + elapsed += milliseconds; + }, + rename: async () => { + attempts.push(elapsed); + throw error; + }, + }, + ), + ).rejects.toBe(error); + expect(elapsed).toBe(1000); + expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]); + expect(Math.max(...waits)).toBe(100); + expect(attempts.every((time) => time < 1000)).toBe(true); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); + }, +); + +it.each([ + ["linux", "EPERM"], + ["darwin", "EACCES"], + ["linux", "EBUSY"], + ["win32", "ENOENT"], + ["win32", "ENOSPC"], + ["win32", "EXDEV"], +] as const)("does not retry %s %s failures", async (platform, code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + const error = Object.assign(new Error("rename failed"), { code }); + let attempts = 0; + const waits: number[] = []; + await expect( + writeFileAtomic(file, "replacement", { + platform, + now: () => 0, + sleep: async (milliseconds) => { + waits.push(milliseconds); + }, + rename: async () => { + attempts++; + throw error; + }, + }), + ).rejects.toBe(error); + expect(attempts).toBe(1); + expect(waits).toEqual([]); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); +}); + +it("does not retry after a rename consumes the Windows retry budget", async () => { + const file = path.join(directory, "receipt.json"); + const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" }); + let elapsed = 0; + const waits: number[] = []; + await expect( + writeFileAtomic(file, "replacement", { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + waits.push(milliseconds); + }, + rename: async () => { + elapsed += 1000; + throw error; + }, + }), + ).rejects.toBe(error); + expect(waits).toEqual([]); + expect(await fs.readdir(directory)).toEqual([]); +}); + +it("creates parent directories and replaces binary and JSON files through the default runtime", async () => { + const file = path.join(directory, "nested", "file"); + const bytes = Buffer.from([0, 255, 128, 1]); + await writeFileAtomic(file, bytes); + expect(await fs.readFile(file)).toEqual(bytes); + await writeJsonFileAtomic(file, { state: "completed" }); + expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}'); + expect(await fs.readdir(path.dirname(file))).toEqual(["file"]); +}); diff --git a/packages/server/src/server/atomic-file.ts b/packages/server/src/server/atomic-file.ts index cedc8d51ea..9f9c9f9568 100644 --- a/packages/server/src/server/atomic-file.ts +++ b/packages/server/src/server/atomic-file.ts @@ -1,10 +1,26 @@ import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; + +interface AtomicFileRuntime { + platform: NodeJS.Platform; + rename: (source: string, destination: string) => Promise; + now: () => number; + sleep: (milliseconds: number) => Promise; +} + +const runtime: AtomicFileRuntime = { + platform: process.platform, + rename: fs.rename, + now: () => performance.now(), + sleep, +}; export async function writeFileAtomic( filePath: string, data: string | NodeJS.ArrayBufferView, + operations: AtomicFileRuntime = runtime, ): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); const tempPath = path.join( @@ -13,13 +29,48 @@ export async function writeFileAtomic( ); try { await fs.writeFile(tempPath, data, "utf8"); - await fs.rename(tempPath, filePath); + await replaceFile(tempPath, filePath, operations); } catch (error) { await fs.rm(tempPath, { force: true }); throw error; } } -export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise { - await writeFileAtomic(filePath, JSON.stringify(value, null, 2)); +async function replaceFile( + source: string, + destination: string, + operations: AtomicFileRuntime, +): Promise { + // Windows readers/scanners can briefly deny replacement. Yield with backoff, + // but keep interactive persistence failures bounded to a one-second retry budget. + const deadline = operations.now() + 1000; + let delay = 10; + for (;;) { + try { + await operations.rename(source, destination); + return; + } catch (error) { + if ( + operations.platform !== "win32" || + !(error instanceof Error) || + !("code" in error) || + !["EPERM", "EACCES", "EBUSY"].includes(String(error.code)) + ) { + throw error; + } + const remaining = deadline - operations.now(); + if (remaining <= 0) throw error; + await operations.sleep(Math.min(delay, remaining)); + if (operations.now() >= deadline) throw error; + delay = Math.min(delay * 2, 100); + } + } +} + +export async function writeJsonFileAtomic( + filePath: string, + value: unknown, + operations: AtomicFileRuntime = runtime, +): Promise { + await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations); } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 80599a74a3..f7a5b17857 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -182,6 +182,9 @@ const INBOUND_PERMISSION = { set_agent_model_request: "workspace.write", set_agent_thinking_request: "workspace.write", set_daemon_config_request: "daemon.manage", + "speech.local_models.list.request": "daemon.read", + "speech.local_models.download.request": "daemon.manage", + "speech.local_models.delete.request": "daemon.manage", set_voice_mode: "workspace.write", shutdown_server_request: "daemon.manage", start_workspace_script_request: "workspace.write", @@ -417,6 +420,9 @@ const OUTBOUND_PERMISSION = { set_agent_model_response: "workspace.write", set_agent_thinking_response: "workspace.write", set_daemon_config_response: "daemon.manage", + "speech.local_models.list.response": "daemon.read", + "speech.local_models.download.response": "daemon.manage", + "speech.local_models.delete.response": "daemon.manage", set_voice_mode_response: "workspace.write", start_workspace_script_response: "workspace.write", stash_list_response: "workspace.read", diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index f6304491ab..ac045741f1 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => { test("rolls back standalone listener without starting plugins when main listen fails", async () => { const mainPort = await findFreePort(); - const standalonePort = await findFreePort(); const occupiedMain = http.createServer((_req, res) => { res.end("occupied-main"); }); await new Promise((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve)); + const standalonePort = await findFreePort(); const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-")); const paseoHome = path.join(paseoHomeRoot, ".paseo"); @@ -1069,7 +1069,7 @@ function probeWebSocketConnection( }); } -test("startup closes stale active records before serving status without resuming sessions", async () => { +test("disabled recovery closes stale active records without resuming sessions", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paseo-stale-status-")); const logger = pino({ level: "silent" }); const storagePath = path.join(root, ".paseo", "agents"); @@ -1121,6 +1121,7 @@ test("startup closes stale active records before serving status without resuming let sessionsCreated = 0; const handle = await createTestPaseoDaemon({ paseoHomeRoot: root, + resumeOnStartup: false, agentClients: createTestAgentClients({ beforeCreateSession: async () => { sessionsCreated++; @@ -1131,6 +1132,7 @@ test("startup closes stale active records before serving status without resuming try { const expected = records.map((record) => ({ ...record, + resumeOnStartup: false, lastStatus: record.lastStatus === "running" || record.lastStatus === "initializing" ? "closed" diff --git a/packages/server/src/server/bootstrap.test.ts b/packages/server/src/server/bootstrap.test.ts index 6f705ec559..23d3dcd1a7 100644 --- a/packages/server/src/server/bootstrap.test.ts +++ b/packages/server/src/server/bootstrap.test.ts @@ -1,6 +1,17 @@ -import { expect, test } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test, onTestFinished } from "vitest"; +import { MutableDaemonConfigSchema } from "@getpaseo/protocol/messages"; +import { DaemonConfigStore } from "./daemon-config-store.js"; +import { loadPersistedConfig } from "./persisted-config.js"; +import { + createIdleRuntimeCleanup, + type RuntimeCleanupCandidate, +} from "./agent/idle-runtime-cleanup/index.js"; +import { createTestLogger } from "../test-utils/test-logger.js"; -import { fanOutReconciledWorkspaceUpdates } from "./bootstrap.js"; +import { fanOutReconciledWorkspaceUpdates, connectIdleRuntimeCleanupConfig } from "./bootstrap.js"; test("reconciliation emits workspace updates when observer sync fails", async () => { const emittedWorkspaceIds: string[][] = []; @@ -54,3 +65,132 @@ test("reconciliation isolates workspace update failures between sessions", async expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]); expect(warnings).toHaveLength(1); }); + +// Exercise bootstrap's actual live-config binding with the real collector and store. + +function liveCleanupHost() { + const home = mkdtempSync(path.join(tmpdir(), "paseo-live-cleanup-")); + writeFileSync(path.join(home, "config.json"), JSON.stringify({ version: 1 })); + const store = new DaemonConfigStore( + home, + MutableDaemonConfigSchema.parse({ + relay: { enabled: true }, + mcp: { injectIntoAgents: false }, + idleRuntimeCleanup: true, + }), + undefined, + { + reloadSource: { + resolve: (persisted) => ({ + mutable: MutableDaemonConfigSchema.parse({ + relay: { enabled: true }, + mcp: { injectIntoAgents: false }, + idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true, + }), + overrideControlledPaths: [], + }), + }, + }, + ); + let minutes = 61; + let closed = false; + let protectedCaller = false; + const runtime: RuntimeCleanupCandidate = { + identity: {}, + agentId: "agent", + provider: "test", + sessionId: "session", + parentAgentId: undefined, + lastActivityAt: 0, + isEligible: () => !closed, + closeIfEligible: async (eligible) => { + if (!eligible()) return false; + closed = true; + return true; + }, + }; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + now: () => minutes * 60_000, + runtimes: () => [runtime], + isProtected: () => protectedCaller, + logger: createTestLogger(), + schedule: () => () => {}, + }); + const unsubscribe = connectIdleRuntimeCleanupConfig(store, cleanup); + onTestFinished(async () => { + unsubscribe(); + await cleanup.close(); + rmSync(home, { recursive: true, force: true }); + }); + return { + home, + store, + cleanup, + unsubscribe, + protect: (value: boolean) => { + protectedCaller = value; + }, + sweep: (at = minutes) => { + minutes = at; + return cleanup.sweep(); + }, + }; +} + +test("bootstrap's host toggle prevents collection live, persists, and leaves other hosts enabled", async () => { + const host = liveCleanupHost(); + const other = liveCleanupHost(); + await host.store.patch({ idleRuntimeCleanup: false }); + expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false); + expect((await host.sweep()).collected).toEqual([]); + expect((await other.sweep()).collected).toHaveLength(1); + await host.store.patch({ idleRuntimeCleanup: true }); + host.protect(true); + expect((await host.sweep()).collected).toEqual([]); + host.protect(false); + expect((await host.sweep(62)).collected).toEqual([]); + expect((await host.sweep(122)).collected).toHaveLength(1); +}); + +test("bootstrap's config-file reload binding disables and enables eligible collection live", async () => { + const host = liveCleanupHost(); + for (const enabled of [false, true]) { + writeFileSync( + path.join(host.home, "config.json"), + JSON.stringify({ version: 1, agents: { idleRuntimeCleanup: enabled } }), + ); + expect(await host.store.reload()).toEqual({ + appliedPaths: ["agents.idleRuntimeCleanup"], + restartRequiredPaths: [], + overrideControlledPaths: [], + }); + expect((await host.sweep()).collected).toHaveLength(enabled ? 1 : 0); + } +}); + +test.each([false, true])( + "failed config apply restores prior idle cleanup behavior (previous: %s)", + async (previous) => { + const host = liveCleanupHost(); + await host.store.patch({ idleRuntimeCleanup: previous }); + const persistedBefore = loadPersistedConfig(host.home); + const refuse = host.store.onApply(() => { + throw new Error("Another live owner refused"); + }); + await expect(host.store.patch({ idleRuntimeCleanup: !previous })).rejects.toThrow( + "Another live owner refused", + ); + expect(host.store.get().idleRuntimeCleanup).toBe(previous); + expect(loadPersistedConfig(host.home)).toEqual(persistedBefore); + expect((await host.sweep()).collected).toHaveLength(previous ? 1 : 0); + refuse(); + }, +); + +test("disposing bootstrap's binding stops configuration delivery", async () => { + const host = liveCleanupHost(); + host.unsubscribe(); + await host.store.patch({ idleRuntimeCleanup: false }); + expect((await host.sweep()).collected).toHaveLength(1); +}); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 03decb5f67..fa896ef898 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,6 @@ +import { createRestartRecovery } from "./agent/restart-recovery/index.js"; +import { sendPromptToAgent } from "./agent/agent-prompt.js"; +import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js"; import { createFinishSubscriptions, createFinishObservation, @@ -137,6 +140,7 @@ import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/confi import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; import { createSpeechService } from "./speech/speech-runtime.js"; +import { createSpeechSettingsOwner } from "./speech/speech-settings.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; @@ -159,7 +163,11 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js"; import { createOrchestrationSkills } from "./orchestration-skills/index.js"; -import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js"; +import { + resolveReloadableConfig, + type ResolvedDaemonSettings, + type CliConfigOverrides, +} from "./config.js"; import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js"; import { BrowserToolsBroker } from "./browser-tools/broker.js"; import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js"; @@ -174,7 +182,7 @@ import { import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; -import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js"; +import { startWorkerTerminalManager } from "../terminal/worker-terminal-manager.js"; import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js"; @@ -194,7 +202,7 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; -import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; +import { type PersistedConfig } from "./persisted-config.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js"; @@ -378,6 +386,8 @@ export interface PaseoSpeechSttLanguages { export interface PaseoSpeechConfig { providers: RequestedSpeechProviders; + // Where local models live, whether or not a feature currently uses the local provider. + localModelsDir: string; sttLanguages?: PaseoSpeechSttLanguages; local?: PaseoLocalSpeechConfig; } @@ -388,12 +398,14 @@ export type DaemonLifecycleIntent = clientId: string; requestId: string; reason: string; + keepTerminals: boolean; } | { type: "restart"; clientId: string; requestId: string; reason: string; + keepTerminals: boolean; }; export interface PaseoDaemonConfig { @@ -408,6 +420,7 @@ export interface PaseoDaemonConfig { trustedProxies?: true | string[]; mcpEnabled?: boolean; mcpInjectIntoAgents?: boolean; + usageEnabled?: boolean; browserToolsEnabled?: boolean; git?: { maxProcessesPerSecond: number; @@ -453,6 +466,9 @@ export interface PaseoDaemonConfig { dictationFinalTimeoutMs?: number; downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; + idleRuntimeCleanup?: boolean; + resumeOnStartup?: boolean; + resumePrompt?: string; providerCatalogRefreshTimeoutMs?: number; metadataGeneration?: { providers?: Array<{ @@ -484,7 +500,8 @@ export interface PaseoDaemon { scriptRuntimeStore: WorkspaceScriptRuntimeStore; browserToolsBroker: BrowserToolsBroker; start(): Promise; - stop(): Promise; + /** Stops the daemon. Terminals keep running for the next daemon only with keepTerminals. */ + stop(options?: { keepTerminals?: boolean }): Promise; getListenTarget(): ListenTarget | null; getServerId(): string; } @@ -546,14 +563,18 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri return config.trustedProxies ?? ["loopback"]; } -function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] { +function createInitialRelayConfig(config: ResolvedDaemonSettings): MutableDaemonConfig["relay"] { return { enabled: config.relayEnabled ?? true, requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false, }; } -function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig { +function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv { + return config.configReload?.env ?? process.env; +} + +function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): MutableDaemonConfig { const providers = config.providerOverrides ?? {}; const initialConfig: MutableDaemonConfig = { @@ -570,6 +591,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae ...(config.providerCatalogRefreshTimeoutMs !== undefined ? { catalogRefreshTimeoutMs: config.providerCatalogRefreshTimeoutMs } : {}), + usage: { enabled: config.usageEnabled !== false }, browserTools: { enabled: config.browserToolsEnabled ?? false }, providers, metadataGeneration: { @@ -577,6 +599,9 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae }, autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false, + idleRuntimeCleanup: config.idleRuntimeCleanup !== false, + resumeOnStartup: config.resumeOnStartup !== false, + resumePrompt: config.resumePrompt ?? "Resume working.", appendSystemPrompt: config.appendSystemPrompt ?? "", pluginsEnabled: config.pluginsEnabled ?? false, plugins: config.plugins ?? {}, @@ -612,19 +637,30 @@ export async function createPaseoDaemon( const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url); const initialMutableConfig = createInitialMutableDaemonConfig(config); + const speechService = createSpeechService({ + logger, + openaiConfig: config.openai, + speechConfig: config.speech, + }); + logger.info({ elapsed: elapsed() }, "Speech service created"); const daemonConfigStore = new DaemonConfigStore(config.paseoHome, initialMutableConfig, logger, { relayEnabledMutable: config.relayEnabledMutable ?? true, startupPersisted: config.configReload?.startupPersisted, + speech: createSpeechSettingsOwner({ + paseoHome: config.paseoHome, + env: resolveReloadEnv(config), + speechService, + }), reloadSource: { resolve: (persisted) => { - const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, { - env: config.configReload?.env ?? process.env, + const reloaded = resolveReloadableConfig(persisted, { + env: resolveReloadEnv(config), cli: config.configReload?.cli, relayEnabledFallback: config.configReload?.relayEnabledFallback, }); return { mutable: createInitialMutableDaemonConfig(reloaded), - overrideControlledPaths: reloaded.configReload?.overrideControlledPaths ?? [], + overrideControlledPaths: reloaded.overrideControlledPaths, }; }, }, @@ -684,7 +720,10 @@ export async function createPaseoDaemon( }); let boundListenTarget: ListenTarget | null = null; let workspaceRegistry: FileBackedWorkspaceRegistry | null = null; - const terminalManager = createConfiguredTerminalManager({ + const terminalManager = await startWorkerTerminalManager({ + paseoHome: config.paseoHome, + paseoVersion: daemonVersion, + logger, getTerminalActivityUrl: () => createTerminalActivityUrl(boundListenTarget), }); applyTerminalAgentHookSetting({ store: daemonConfigStore, logger }); @@ -932,7 +971,7 @@ export async function createPaseoDaemon( if (!hasActiveWorkspace) workspaceRuntimeStore.remove(cwd); } if (mutation.kind === "archive" && mutation.workspace) { - pluginRuntime.emit("workspace.archived", { + pluginRuntime.emit("workspace.archive.after", { workspace: describeHookWorkspace(mutation.workspace), }); } @@ -974,6 +1013,7 @@ export async function createPaseoDaemon( const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const agentManager = new AgentManager({ pluginLifecycle: pluginRuntime, + pluginAgentTools: pluginRuntime, clients: initialAgentManagerState.clients, providerDefinitions: initialAgentManagerState.providerDefinitions, registry: agentStorage, @@ -994,13 +1034,29 @@ export async function createPaseoDaemon( const unsubscribePluginProviders = pluginRuntime.subscribeProviderRegistrations(syncPluginProviders); + const unsubscribePluginSessionChanges = pluginRuntime.subscribeSessionOpenChanges(() => { + agentManager.invalidatePluginSessions(); + }); + const detachAgentStoragePersistence = attachAgentStoragePersistence( logger, agentManager, agentStorage, ); await agentStorage.initialize(); - await closeStaleAgentRecords(agentStorage); + const restartRecovery = createRestartRecovery({ + storage: agentStorage, + settings: () => ({ + enabled: daemonConfigStore.get().resumeOnStartup !== false, + prompt: daemonConfigStore.get().resumePrompt ?? "Resume working.", + }), + eligible: async (record) => + !record.workspaceId || !(await workspaceRegistry?.get(record.workspaceId))?.archivedAt, + dispatch: (agentId, prompt) => + sendPromptToAgent({ agentId, prompt, agentManager, agentStorage, logger, unarchive: false }), + report: (agentId, err) => logger.error({ agentId, err }, "Agent restart recovery failed"), + }); + await restartRecovery.prepareStartup(); logger.info({ elapsed: elapsed() }, "Agent storage initialized"); await bootstrapWorkspaceRegistries({ serverId, @@ -1176,6 +1232,7 @@ export async function createPaseoDaemon( worktreesRoot: config.worktreesRoot, createPaseoWorktree: async (workflowInput, workflowOptions) => { return createRegisteredPaseoWorktree(workflowInput, { + lifecycle: pluginRuntime, github, ...(workflowOptions?.resolveDefaultBranch ? { @@ -1398,6 +1455,7 @@ export async function createPaseoDaemon( archiveWorkspace: archiveScheduleWorkspaceExternal, }); await scheduleService.start(); + agentManager.setAgentArchivedCallback(async (agentId) => { try { await scheduleService.completeForAgent(agentId); @@ -1432,6 +1490,7 @@ export async function createPaseoDaemon( ...createFinishObservation({ agentManager, agentStorage, + waitForResume: restartRecovery.waitForResume, serverId, logger, messages: () => { @@ -1445,6 +1504,20 @@ export async function createPaseoDaemon( }), }); + const idleRuntimeCleanup = createIdleRuntimeCleanup({ + enabled: daemonConfigStore.get().idleRuntimeCleanup !== false, + runtimes: () => agentManager.getRuntimeCleanupCandidates(), + isProtected: (agentId) => + finishSubscriptions.hasPendingForCaller(agentId) || + (wsServer?.agentMessages.hasPending(agentId) ?? false), + logger, + }); + + const unsubscribeIdleRuntimeCleanup = connectIdleRuntimeCleanupConfig( + daemonConfigStore, + idleRuntimeCleanup, + ); + const createAgentToolHostDependencies = ( runtime: PaseoToolRuntimeContext, ): PaseoToolHostDependencies => ({ @@ -1500,7 +1573,7 @@ export async function createPaseoDaemon( spawnWorkspaceScript, assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId), - globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts, + getGlobalServicePorts: () => daemonConfigStore.readServicePorts(), }), markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, @@ -1659,13 +1732,6 @@ export async function createPaseoDaemon( logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted"); } - const speechService = createSpeechService({ - logger, - openaiConfig: config.openai, - speechConfig: config.speech, - }); - logger.info({ elapsed: elapsed() }, "Speech service created"); - logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); const start = async () => { @@ -1824,6 +1890,7 @@ export async function createPaseoDaemon( pluginRuntime.bindPaseoSessionHost(wsServer); await pluginRuntime.start(); providerSnapshotManager.settlePluginProviders(); + restartRecovery.resumePending(); await finishSubscriptions.start(); wsServer.beginAcceptingConnections(); const relaySecurityLogger = logger.child({ module: "relay-security" }); @@ -1885,6 +1952,7 @@ export async function createPaseoDaemon( localCredential = null; await deleteLocalCredential(config.paseoHome); unsubscribePluginProviders(); + unsubscribePluginSessionChanges(); await pluginRuntime.stopAllPlugins().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined); await agentProviderRuntime.shutdown().catch(() => undefined); @@ -1896,7 +1964,9 @@ export async function createPaseoDaemon( } }; - const stop = async () => { + const stop = async (options?: { keepTerminals?: boolean }) => { + const keepTerminals = options?.keepTerminals === true; + let terminalShutdownError: unknown = null; localCredential = null; await deleteLocalCredential(config.paseoHome); // Stop tracking plugin provider registrations before anything tears plugins @@ -1904,14 +1974,18 @@ export async function createPaseoDaemon( // that is still open. Plugins themselves are stopped once every session // they serve has been closed, further down. unsubscribePluginProviders(); + unsubscribePluginSessionChanges(); await hubRelationships.stop(); workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); + unsubscribeIdleRuntimeCleanup(); + await idleRuntimeCleanup.close(); // Freeze both ingress and registration before taking the agent closure snapshot. wsServer?.prepareForShutdown(); await finishSubscriptions.close(); await wsServer?.agentMessages.close(); agentManager.prepareForShutdown(); + await restartRecovery.captureRunningAgents(); await closeAllAgents(logger, agentManager); await withTimeout({ promise: pluginRuntime.drainEvents(), @@ -1923,7 +1997,19 @@ export async function createPaseoDaemon( await agentStorage.flush().catch(() => undefined); await agentProviderRuntime.shutdown(); await pluginRuntime.stopAllPlugins(); - terminalManager.killAll(); + try { + if (keepTerminals) { + await terminalManager.detach(); + logger.info("Terminals left running for the next daemon"); + } else { + await terminalManager.closeTerminals(); + } + } catch (error) { + // Finish the rest of the shutdown, then report it: the supervisor and `daemon stop` + // retry the cleanup from the record the reaper kept. + terminalShutdownError = error; + logger.error({ err: error }, "Terminal shutdown failed"); + } await speechService.stop(); await scheduleService.stop().catch(() => undefined); await relayRuntime?.stop().catch(() => undefined); @@ -1946,6 +2032,9 @@ export async function createPaseoDaemon( if (listenTarget.type === "socket" && existsSync(listenTarget.path)) { unlinkSync(listenTarget.path); } + if (terminalShutdownError) { + throw terminalShutdownError; + } }; return { @@ -1963,16 +2052,6 @@ export async function createPaseoDaemon( }; } -async function closeStaleAgentRecords(agentStorage: AgentStorage): Promise { - // No provider runtimes are loaded at startup. Repair interrupted - // shutdowns before any status consumer sees the persisted active states. - for (const record of await agentStorage.list()) { - if (record.lastStatus === "running" || record.lastStatus === "initializing") { - await agentStorage.upsert({ ...record, lastStatus: "closed" }); - } - } -} - /** * Closing an agent asks its provider to close the session and waits for the * answer. A provider that never answers must not hold the daemon open, so a @@ -1997,3 +2076,11 @@ async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promi }), ); } + +export function connectIdleRuntimeCleanupConfig( + store: DaemonConfigStore, + cleanup: Pick, "setEnabled">, +): () => void { + cleanup.setEnabled(store.get().idleRuntimeCleanup !== false); + return store.onFieldChange("idleRuntimeCleanup", (value) => cleanup.setEnabled(value !== false)); +} diff --git a/packages/server/src/server/checkout-diff-cache.test.ts b/packages/server/src/server/checkout-diff-cache.test.ts index 37f9283320..a587f89894 100644 --- a/packages/server/src/server/checkout-diff-cache.test.ts +++ b/packages/server/src/server/checkout-diff-cache.test.ts @@ -132,3 +132,22 @@ test.each(["base", "uncommitted"] as const)( expect(await read("next", "next-after")).toEqual({ diff: "next-after" }); }, ); + +test("superseding a diff cancels its highlighting while identical readers share work", async () => { + const cache = new CheckoutDiffCache(() => 0); + const release = Promise.withResolvers<{ diff: string }>(); + let activeSignal: AbortSignal | undefined; + const load = (signal: AbortSignal) => { + activeSignal = signal; + return release.promise; + }; + const first = cache.read({ cwd: "repo", compare: { mode: "base" }, load }); + await Promise.resolve(); + expect(activeSignal).toBeDefined(); + expect(activeSignal!.aborted).toBe(false); + expect(cache.read({ cwd: "repo", compare: { mode: "base" }, load })).toBe(first); + cache.invalidate("repo", "base"); + expect(activeSignal!.aborted).toBe(true); + release.resolve({ diff: "old snapshot" }); + await first; +}); diff --git a/packages/server/src/server/checkout-diff-cache.ts b/packages/server/src/server/checkout-diff-cache.ts index 4547cdf2e5..71d7d95497 100644 --- a/packages/server/src/server/checkout-diff-cache.ts +++ b/packages/server/src/server/checkout-diff-cache.ts @@ -4,6 +4,7 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../utils/checkout- interface ActiveRead { promise: Promise; invalidated: boolean; + controller: AbortController; } interface CachedDiff { @@ -17,7 +18,7 @@ interface CheckoutDiffReadInput { compare: CheckoutDiffCompare; force?: boolean; reason?: string; - load: () => Promise; + load: (signal: AbortSignal) => Promise; } /** Completed payloads are bounded; active work survives invalidation and eviction. */ @@ -51,6 +52,7 @@ export class CheckoutDiffCache { if (pending) { if (force) { pending.invalidated = true; + pending.controller.abort(); this.values.delete(key); } if (!pending.invalidated) return pending.promise; @@ -66,8 +68,9 @@ export class CheckoutDiffCache { // Defer load until ownership is installed, including synchronous invalidations. const read: ActiveRead = { invalidated: false, + controller: new AbortController(), promise: Promise.resolve() - .then(load) + .then(() => load(read.controller.signal)) .then((value) => { // Never repopulate the cache with a snapshot invalidated during its build. if (!read.invalidated) @@ -86,7 +89,10 @@ export class CheckoutDiffCache { if (cachedCwd !== cwd || (cachedMode !== mode && cachedMode !== "all")) continue; this.values.delete(key); const pending = this.active.get(key); - if (pending) pending.invalidated = true; + if (pending) { + pending.invalidated = true; + pending.controller.abort(); + } } } } diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index a71b5621e8..53a77338b6 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -1,10 +1,16 @@ +import { measureMainThreadWork } from "../test-utils/main-thread-work.js"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; -import { loadConfig, resolveBundledWebUiDistDir, resolveConfigFromPersisted } from "./config.js"; +import { + loadConfig, + resolveBundledWebUiDistDir, + resolveConfigFromPersisted, + resolveReloadableConfig, +} from "./config.js"; import { loadPersistedConfig } from "./persisted-config.js"; const roots: string[] = []; @@ -14,6 +20,26 @@ describe("server config", () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + test("resolves reloadable settings without resolving the startup password", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-reload-")); + roots.push(paseoHome); + const env = { PASEO_PASSWORD: "synthetic-audit-password" }; + const persisted = loadPersistedConfig(paseoHome); + const startup = resolveConfigFromPersisted(paseoHome, persisted, { env }); + const { result: reloaded, blockingMs } = await measureMainThreadWork( + "config resolve main-thread work", + () => + resolveReloadableConfig( + { ...persisted, daemon: { ...persisted.daemon, browserTools: { enabled: true } } }, + { env }, + ), + ); + expect(reloaded).not.toHaveProperty("auth"); + expect(reloaded.browserToolsEnabled).toBe(true); + expect(startup.auth?.password).toBeTypeOf("string"); + expect(blockingMs).toBeLessThan(50); + }); + test("records when the daemon is managed by Paseo Desktop", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-")); roots.push(paseoHome); @@ -27,6 +53,17 @@ describe("server config", () => { expect(standaloneConfig.desktopManaged).toBe(false); }); + test("idle runtime cleanup defaults on and supports a host-wide opt-out", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-idle-cleanup-")); + roots.push(paseoHome); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true); + await writeFile( + path.join(paseoHome, "config.json"), + JSON.stringify({ agents: { idleRuntimeCleanup: false } }), + ); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false); + }); + test("loads the provider catalog refresh timeout", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-")); roots.push(paseoHome); @@ -104,9 +141,13 @@ describe("server config", () => { name: "OpenAI speech providers", providers: { dictation: "openai", voiceStt: "openai", voiceTts: "openai" }, expected: [ + "features.dictation.stt.apiKey", "features.dictation.stt.confidenceThreshold", "features.dictation.stt.model", + "features.voiceMode.stt.apiKey", + "features.voiceMode.stt.confidenceThreshold", "features.voiceMode.stt.model", + "features.voiceMode.tts.apiKey", "features.voiceMode.tts.model", "features.voiceMode.tts.voice", ], @@ -115,8 +156,9 @@ describe("server config", () => { name: "mixed local and OpenAI speech providers", providers: { dictation: "local", voiceStt: "openai", voiceTts: "local" }, expected: [ - "features.dictation.stt.confidenceThreshold", "features.dictation.stt.model", + "features.voiceMode.stt.apiKey", + "features.voiceMode.stt.confidenceThreshold", "features.voiceMode.stt.model", "features.voiceMode.tts.model", ], diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index cc4eee4fec..07c5c287b8 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -22,7 +22,7 @@ import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; import { hashDaemonPassword } from "./auth.js"; import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; -import type { RequestedSpeechProviders } from "./speech/speech-types.js"; +import { readSpeechSettings } from "./speech/speech-settings.js"; import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js"; import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js"; @@ -523,6 +523,10 @@ function resolveAppendSystemPrompt(persisted: ReturnType): boolean { + return persisted.daemon?.usage?.enabled !== false; +} + function resolveBrowserToolsEnabled(persisted: ReturnType): boolean { return persisted.daemon?.browserTools?.enabled ?? false; } @@ -548,6 +552,7 @@ function resolveStaticLoadConfigSettings( mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true, mcpInjectIntoAgents: cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false, + usageEnabled: resolveUsageEnabled(persisted), browserToolsEnabled: resolveBrowserToolsEnabled(persisted), autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false, appendSystemPrompt: resolveAppendSystemPrompt(persisted), @@ -568,31 +573,46 @@ interface ResolveConfigFromPersistedOptions { relayEnabledFallback?: boolean; } +// Startup values, including authentication, never enter runtime settings resolution. +export type ResolvedDaemonSettings = Pick< + PaseoDaemonConfig, + | "corsAllowedOrigins" + | "hostnames" + | "trustedProxies" + | "mcpEnabled" + | "mcpInjectIntoAgents" + | "usageEnabled" + | "browserToolsEnabled" + | "git" + | "autoArchiveAfterMerge" + | "enableTerminalAgentHooks" + | "appendSystemPrompt" + | "terminalProfiles" + | "agentProfiles" + | "skillSelection" + | "pluginsEnabled" + | "plugins" + | "relayEnabled" + | "relayRequireProtectedSecurity" + | "appBaseUrl" + | "idleRuntimeCleanup" + | "resumeOnStartup" + | "resumePrompt" + | "providerCatalogRefreshTimeoutMs" + | "metadataGeneration" + | "providerOverrides" +>; + export function resolveConfigFromPersisted( paseoHome: string, persisted: PersistedConfig, options?: ResolveConfigFromPersistedOptions, ): PaseoDaemonConfig { - const resolvedOptions = options ?? {}; - const env = configurationEnvironment(resolvedOptions.env ?? process.env); - const cli = resolvedOptions.cli; + const env = configurationEnvironment(options?.env ?? process.env); + const cli = options?.cli; const relayEnabledFallback = - resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined; - - const listen = resolveListenAddress(env, cli, persisted); - const { - mcpEnabled, - mcpInjectIntoAgents, - browserToolsEnabled, - autoArchiveAfterMerge, - appendSystemPrompt, - terminalProfiles, - agentProfiles, - hostnames, - trustedProxies, - appBaseUrl, - } = resolveStaticLoadConfigSettings(env, cli, persisted); - + options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined; + const { overrideControlledPaths, ...settings } = resolveReloadableConfig(persisted, options); const relay = resolveRelayConfig({ env, persisted, @@ -600,42 +620,14 @@ export function resolveConfigFromPersisted( cliRelayUseTls: cli?.relayUseTls, enabledFallback: relayEnabledFallback, }); - const serviceProxy = resolveServiceProxyConfig(env, persisted); - const webUi = resolveWebUiConfig(paseoHome, env, cli, persisted); - - const { openai, speech } = resolveSpeechConfig({ - paseoHome, - env, - persisted, - }); - + const { openai, speech } = resolveSpeechConfig({ paseoHome, env, persisted }); const voiceLlm = resolveVoiceLlmConfig(env, persisted); - const providerOverrides = extractProviderOverrides( - persisted.agents?.providers as Record | undefined, - ); - - const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, speech.providers); - return { - listen, + ...settings, + listen: resolveListenAddress(env, cli, persisted), paseoHome, desktopManaged: env.PASEO_DESKTOP_MANAGED === "1", worktreesRoot: resolveWorktreesRoot(paseoHome, persisted), - corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), - hostnames, - trustedProxies, - mcpEnabled, - mcpInjectIntoAgents, - browserToolsEnabled, - git: resolveGitProcessConfig(env, persisted), - autoArchiveAfterMerge, - enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false, - appendSystemPrompt, - terminalProfiles, - agentProfiles, - skillSelection: persisted.agents?.skills?.selection, - pluginsEnabled: persisted.pluginsEnabled ?? false, - plugins: persisted.plugins, pluginRegistries: persisted.pluginRegistries, pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, mcpDebug: env.MCP_DEBUG === "1", @@ -643,26 +635,20 @@ export function resolveConfigFromPersisted( agentStoragePath: path.join(paseoHome, "agents"), staticDir: "public", agentClients: {}, - relayEnabled: relay.enabled, relayEnabledMutable: relay.enabledMutable, - relayRequireProtectedSecurity: relay.requireProtectedSecurity, relayEndpoint: relay.endpoint, relayPublicEndpoint: relay.publicEndpoint, relayUseTls: relay.useTls, relayPublicUseTls: relay.publicUseTls, - serviceProxy, - webUi, - appBaseUrl, + serviceProxy: resolveServiceProxyConfig(env, persisted), + webUi: resolveWebUiConfig(paseoHome, env, cli, persisted), auth: resolveAuthConfig(env, persisted), openai, speech, voiceLlmProvider: voiceLlm.provider, voiceLlmProviderExplicit: voiceLlm.providerExplicit, voiceLlmModel: voiceLlm.model, - agentProviderSettings: extractAgentProviderSettings(providerOverrides), - providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs, - metadataGeneration: persisted.agents?.metadataGeneration, - providerOverrides, + agentProviderSettings: extractAgentProviderSettings(settings.providerOverrides), log: resolveLogConfigFromEnv(env, persisted), configReload: { env: { ...env }, @@ -674,6 +660,41 @@ export function resolveConfigFromPersisted( }; } +export function resolveReloadableConfig( + persisted: PersistedConfig, + options?: ResolveConfigFromPersistedOptions, +): ResolvedDaemonSettings & { overrideControlledPaths: string[] } { + const env = configurationEnvironment(options?.env ?? process.env); + const cli = options?.cli; + const relay = resolveRelayConfig({ + env, + persisted, + cliRelayEnabled: cli?.relayEnabled, + cliRelayUseTls: cli?.relayUseTls, + enabledFallback: + options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined, + }); + return { + ...resolveStaticLoadConfigSettings(env, cli, persisted), + corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), + git: resolveGitProcessConfig(env, persisted), + enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false, + skillSelection: persisted.agents?.skills?.selection, + pluginsEnabled: persisted.pluginsEnabled ?? false, + plugins: persisted.plugins, + relayEnabled: relay.enabled, + relayRequireProtectedSecurity: relay.requireProtectedSecurity, + idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true, + ...resolveRestartRecoveryConfig(persisted.agents), + providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs, + metadataGeneration: persisted.agents?.metadataGeneration, + providerOverrides: extractProviderOverrides( + persisted.agents?.providers as Record | undefined, + ), + overrideControlledPaths: resolveOverrideControlledPaths(env, cli, persisted), + }; +} + export function loadConfig( paseoHome: string, options?: Omit, @@ -691,13 +712,13 @@ function parsePositiveGitOverride(value: string | undefined): boolean { function resolveOverrideControlledPaths( env: NodeJS.ProcessEnv, cli: CliConfigOverrides | undefined, - speechProviders: RequestedSpeechProviders, + persisted: PersistedConfig, ): string[] { return Array.from( new Set([ ...resolveDaemonOverrideControlledPaths(env, cli), ...resolveLogOverrideControlledPaths(env), - ...resolveSpeechOverrideControlledPaths(env, speechProviders), + ...resolveSpeechOverrideControlledPaths(env, persisted), ]), ).sort(); } @@ -806,67 +827,19 @@ function resolveLogOverrideControlledPaths(env: NodeJS.ProcessEnv): string[] { return paths; } -function isEnabledSpeechProvider( - provider: RequestedSpeechProviders[keyof RequestedSpeechProviders], - expected: "local" | "openai", -): boolean { - return provider.enabled !== false && provider.provider === expected; -} - function resolveSpeechOverrideControlledPaths( env: NodeJS.ProcessEnv, - providers: RequestedSpeechProviders, + persisted: PersistedConfig, ): string[] { - const paths: string[] = []; - const add = (envName: string, ...configPaths: string[]) => { - if (env[envName] !== undefined) paths.push(...configPaths); - }; - - add("PASEO_DICTATION_ENABLED", "features.dictation.enabled"); - add("PASEO_DICTATION_STT_PROVIDER", "features.dictation.stt.provider"); - if ( - env.PASEO_DICTATION_LOCAL_STT_MODEL !== undefined && - isEnabledSpeechProvider(providers.dictationStt, "local") - ) { - paths.push("features.dictation.stt.model"); - } - add("PASEO_DICTATION_LANGUAGE", "features.dictation.stt.language"); - add("PASEO_VOICE_MODE_ENABLED", "features.voiceMode.enabled"); - add("PASEO_VOICE_LLM_PROVIDER", "features.voiceMode.llm.provider"); - add("PASEO_VOICE_STT_PROVIDER", "features.voiceMode.stt.provider"); - if ( - env.PASEO_VOICE_LOCAL_STT_MODEL !== undefined && - isEnabledSpeechProvider(providers.voiceStt, "local") - ) { - paths.push("features.voiceMode.stt.model"); - } - add("PASEO_VOICE_LANGUAGE", "features.voiceMode.stt.language"); - add("PASEO_VOICE_TURN_DETECTION_PROVIDER", "features.voiceMode.turnDetection.provider"); - add("PASEO_VOICE_TTS_PROVIDER", "features.voiceMode.tts.provider"); - if ( - env.PASEO_VOICE_LOCAL_TTS_MODEL !== undefined && - isEnabledSpeechProvider(providers.voiceTts, "local") - ) { - paths.push("features.voiceMode.tts.model"); - } - add("PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", "features.voiceMode.tts.speakerId"); - add("PASEO_VOICE_LOCAL_TTS_SPEED", "features.voiceMode.tts.speed"); - add("PASEO_LOCAL_MODELS_DIR", "providers.local.modelsDir"); - const openAiDictationStt = isEnabledSpeechProvider(providers.dictationStt, "openai"); - const openAiVoiceStt = isEnabledSpeechProvider(providers.voiceStt, "openai"); - if (env.STT_CONFIDENCE_THRESHOLD !== undefined && (openAiDictationStt || openAiVoiceStt)) { - paths.push("features.dictation.stt.confidenceThreshold"); - } - if (env.STT_MODEL !== undefined) { - if (openAiDictationStt) paths.push("features.dictation.stt.model"); - if (openAiVoiceStt) paths.push("features.voiceMode.stt.model"); - } - if (isEnabledSpeechProvider(providers.voiceTts, "openai")) { - add("TTS_MODEL", "features.voiceMode.tts.model"); - add("TTS_VOICE", "features.voiceMode.tts.voice"); - } - if (env.PASEO_DICTATION_LANGUAGE !== undefined && env.PASEO_VOICE_LANGUAGE === undefined) { - paths.push("features.voiceMode.stt.language"); - } + const paths = Object.keys(readSpeechSettings(persisted, env).envOverrides); + if (env.PASEO_VOICE_LLM_PROVIDER !== undefined) paths.push("features.voiceMode.llm.provider"); + if (env.PASEO_LOCAL_MODELS_DIR !== undefined) paths.push("providers.local.modelsDir"); return paths; } + +function resolveRestartRecoveryConfig(agents: PersistedConfig["agents"]) { + return { + resumeOnStartup: agents?.resumeOnStartup ?? true, + resumePrompt: agents?.resumePrompt ?? "Resume working.", + }; +} diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 8a2d4f9c39..264589f82e 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import pino from "pino"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -9,6 +10,47 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo import type { HostDirectory } from "@getpaseo/protocol/hosts"; const cleanup: (() => Promise)[] = []; + +// These asynchronous failures otherwise disappear into the fixture's silent logger. +// Only completion errors and filesystem metadata belong in CI, never prompts or credentials. +function completionLogger() { + return pino( + { + level: "warn", + serializers: { + err: (error) => ({ + type: error.name, + code: error.code, + syscall: error.syscall, + path: typeof error.path === "string" ? basename(error.path) : undefined, + dest: typeof error.dest === "string" ? basename(error.dest) : undefined, + }), + }, + }, + { + write(line) { + const record = JSON.parse(line); + if ( + record.context !== "Failed to dispatch queued agent message" && + ![ + "Agent message batch dispatch failed", + "Retrying finish subscription", + "Finish subscription failed", + ].includes(record.msg) + ) + return; + console.error("Completion diagnostic", { + time: record.time, + message: record.msg, + agentId: record.agentId, + messageIds: record.messageIds, + subscription: record.subscription, + error: record.err, + }); + }, + }, + ); +} afterEach(async () => { for (const close of cleanup.splice(0).toReversed()) await close(); }); @@ -742,6 +784,211 @@ test("old capabilities fail before execution and authentication errors recover t expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId()); }); +async function updatableDestination() { + let features: { connectedHosts: boolean; hostMultiplexing: boolean } | undefined; + let holding = false; + const held: (() => void)[] = []; + let connections = 0; + const http = createServer(); + const ws = new WebSocketServer({ server: http }); + ws.on("connection", (socket) => { + connections++; + socket.on("message", (data) => { + const frame = JSON.parse(data.toString()); + if (frame.type === "ping") socket.send(JSON.stringify({ type: "pong" })); + if (frame.type !== "hello") return; + const reply = () => + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "srv_updated", + hostname: "updated", + version: "0.12.0", + ...(features ? { features } : {}), + }, + }, + }), + ); + if (holding) held.push(reply); + else reply(); + }); + }); + http.listen(0, "127.0.0.1"); + await once(http, "listening"); + cleanup.push(async () => { + for (const socket of ws.clients) socket.terminate(); + await new Promise((resolve) => ws.close(() => resolve())); + await new Promise((resolve) => http.close(() => resolve())); + }); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Missing destination port"); + return { + port: address.port, + connections: () => connections, + hold: () => { + holding = true; + }, + update: () => { + features = { connectedHosts: true, hostMultiplexing: true }; + holding = false; + for (const reply of held.splice(0)) reply(); + }, + }; +} +async function capabilityRetry(paseo: ReturnType): Promise { + let retryAt = ""; + await expect + .poll( + async () => { + const row = (await paseo.hosts.list()).hosts[0]; + retryAt = row?.nextRetryAt ?? ""; + return row; + }, + { timeout: 5000 }, + ) + .toMatchObject({ + status: "offline", + nextRetryAt: expect.any(String), + error: { code: "unsupported_capability" }, + }); + return Date.parse(retryAt); +} + +test("a destination updated past unsupported_capability keeps its guidance while retrying and comes online, while duplicate_host stays terminal", async () => { + const destination = await updatableDestination(); + const a = await daemon(); + const { paseo } = await api(a); + const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` }); + await capabilityRetry(paseo); + destination.hold(); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[0], { timeout: 5000 }) + .toMatchObject({ status: "connecting", error: { code: "unsupported_capability" } }); + await expect(paseo.hosts.client("srv_updated")).rejects.toMatchObject({ + code: "unsupported_capability", + }); + destination.update(); + expect((await online(paseo)).hosts[0]).toMatchObject({ + registrationId: host.registrationId, + serverId: "srv_updated", + nextRetryAt: null, + error: null, + }); + await paseo.hosts.add({ connection: `ws://127.0.0.1:${destination.port}/ws` }); + await expect + .poll(async () => (await paseo.hosts.list()).hosts[1], { timeout: 5000 }) + .toMatchObject({ status: "error", nextRetryAt: null, error: { code: "duplicate_host" } }); +}); + +test("disabling or removing a host cancels its pending capability retry", async () => { + const destination = await updatableDestination(); + const a = await daemon(); + const { paseo } = await api(a); + const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` }); + let retryAt = await capabilityRetry(paseo); + let connections = destination.connections(); + await paseo.hosts.update(host.registrationId, { enabled: false }); + await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500)); + expect(destination.connections()).toBe(connections); + await paseo.hosts.update(host.registrationId, { enabled: true }); + retryAt = await capabilityRetry(paseo); + connections = destination.connections(); + await paseo.hosts.remove(host.registrationId); + await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500)); + expect(destination.connections()).toBe(connections); +}); + +import { + createDaemonChannel, + exportPublicKey, + generateKeyPair, + openDaemonSecureChannel, + type Transport, +} from "@getpaseo/relay/e2ee"; +test("a capability retry over a relay keeps the protected endpoint security it negotiated", async () => { + const keys = generateKeyPair(); + const legacyReceived: unknown[] = []; + let connections = 0; + let retryClosed = false; + const http = createServer(); + const ws = new WebSocketServer({ server: http }); + ws.on("connection", (socket) => { + connections++; + const wire: Transport = { + send: (data) => socket.send(data), + close: (code, reason) => socket.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + socket.on("message", (data, isBinary) => { + const bytes = Buffer.from(data as Buffer); + wire.onmessage?.({ + data: isBinary + ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + : bytes.toString(), + isBinary, + }); + }); + socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString())); + if (connections > 1) { + socket.on("close", () => { + retryClosed = true; + }); + void createDaemonChannel(wire, keys, { + onmessage: (data) => legacyReceived.push(data), + }).catch(() => {}); + return; + } + const channel = openDaemonSecureChannel(wire, { + daemonKeyPair: keys, + events: { + onmessage: (data) => { + if (typeof data !== "string" || JSON.parse(data).type !== "hello") return; + void channel.then((secure) => + secure.send( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "srv_relayed", + hostname: "relayed", + version: "0.12.0", + }, + }, + }), + ), + ); + }, + }, + }); + void channel.catch(() => {}); + }); + http.listen(0, "127.0.0.1"); + await once(http, "listening"); + cleanup.push(async () => { + for (const socket of ws.clients) socket.terminate(); + await new Promise((resolve) => ws.close(() => resolve())); + await new Promise((resolve) => http.close(() => resolve())); + }); + const address = http.address(); + if (!address || typeof address === "string") throw new Error("Missing relay port"); + const a = await daemon(); + const { paseo } = await api(a); + await paseo.hosts.add({ + connection: `relay://127.0.0.1:${address.port}/srv_relayed?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`, + }); + await capabilityRetry(paseo); + await expect.poll(() => retryClosed, { timeout: 15000 }).toBe(true); + expect(legacyReceived).toEqual([]); +}); + test("an unreachable saved target becomes online after its daemon starts without being added again", async () => { const reservation = createServer(); reservation.listen(0, "127.0.0.1"); @@ -1502,7 +1749,7 @@ test( ); test( - "remote agent reports, local CLI sends and automatic completion share the recipient batch", + "remote agent reports, local CLI sends and automatic completion reach the same recipient", { timeout: 30000 }, async () => { const parentPrompts: string[] = []; @@ -1512,6 +1759,7 @@ test( finishChild.resolve(); }); const a = await daemon({ + logger: completionLogger(), agentClients: createTestAgentClients({ onStartTurn: (prompt, config) => { if (config.title === "Batch parent") @@ -1590,7 +1838,10 @@ test( }); expect(report.isError).not.toBe(true); expect(report.structuredContent).toMatchObject({ deliveryStatus: "queued", serverId: aId }); - expect(parentPrompts).toEqual([]); + // A CLI process can take longer than the two-second quiet window to start. + // Exercise each delivery across windows; delivery.test.ts owns batching with a clock. + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>"); const cli = await promisify(execFile)( process.execPath, @@ -1613,7 +1864,8 @@ test( }, ); expect(JSON.parse(cli.stdout)).toMatchObject({ status: "queued" }); - expect(parentPrompts).toEqual([]); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); + expect(parentPrompts[1]).toContain("LOCAL_CLI_REPORT"); const receiptStates = async () => { const directory = join(a.paseoHome, "agent-requests"); const receipts = await Promise.all( @@ -1628,14 +1880,16 @@ test( .map((receipt) => receipt.state) .sort(); }; - expect(await receiptStates()).toEqual(["pending", "pending"]); finishChild.resolve(); - await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); - expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>"); - expect(parentPrompts[0]).toContain("LOCAL_CLI_REPORT"); - expect(parentPrompts[0]).toContain('event="finished"'); - expect(parentPrompts[0]).toContain(`source-agent-id="${child.id}"`); - await expect.poll(receiptStates, { timeout: 10000 }).toEqual(["completed", "completed"]); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(3); + expect(parentPrompts[2]).toContain('event="finished"'); + expect(parentPrompts[2]).toContain(`source-agent-id="${child.id}"`); + await expect + .poll(receiptStates, { + timeout: 10000, + message: "Parent accepted the batch; waiting for durable receipt commits", + }) + .toEqual(["completed", "completed"]); const timeline = await origin.client.fetchAgentTimeline(parent.id); const messages = timeline.entries.flatMap((row) => row.item.type === "tool_call" && row.item.agentMessage ? [row.item.agentMessage] : [], @@ -1822,6 +2076,7 @@ test( }); const a = await daemon({ mcpEnabled: true, + logger: completionLogger(), agentClients: createTestAgentClients({ onStartTurn: (prompt) => { if (String(prompt).includes('kind="agent-notification"')) @@ -1883,8 +2138,14 @@ test( await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); expect(parentPrompts[1]).toContain('event="finished"'); await expect - .poll(async () => - JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + // Provider start precedes dispatch acknowledgment and the durable removal commit. + .poll( + async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + { + timeout: 10000, + message: "Parent accepted completion; waiting for durable intent removal", + }, ) .toEqual([]); }, @@ -1957,6 +2218,7 @@ test.each(["removed", "disabled"] as const)( cleanup.push(async () => finish.resolve()); const a = await daemon({ mcpEnabled: true, + logger: completionLogger(), agentClients: createTestAgentClients({ onStartTurn: (prompt) => { if (String(prompt).includes('kind="agent-notification"')) @@ -2015,8 +2277,10 @@ test.each(["removed", "disabled"] as const)( expect(parentPrompts[0]).toContain("is unknown."); expect(parentPrompts[0]).not.toContain("finished."); await expect - .poll(async () => - JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + .poll( + async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + { timeout: 10000 }, ) .toEqual([]); if (availability === "removed") @@ -2032,3 +2296,80 @@ test.each(["removed", "disabled"] as const)( ).toEqual([]); }, ); + +test( + "a 50 MiB timeline crosses a routed host without dropping content or the route", + { timeout: 30000 }, + async () => { + const b = await daemon(); + const a = await daemon(); + const destination = await api(b); + const origin = await api(a); + const workspace = await destination.paseo.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + }); + const agent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: "Large history", + }); + // Keep each row below the established item budget; exercise transport size across rows. + const text = "x".repeat(240 * 1024); + const rowCount = 220; + for (let index = 0; index < rowCount; index++) { + await b.daemon.agentManager.appendTimelineItem(agent.id, { type: "user_message", text }); + } + await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` }); + const serverId = (await online(origin.paseo)).hosts[0]!.serverId!; + const remote = await origin.paseo.hosts.client(serverId); + const response = await remote.agents.ref(agent.id).timeline.refetch({ limit: 0 }); + const messages = response.entries.flatMap((row) => + row.item.type === "user_message" ? [row.item.text] : [], + ); + expect(messages).toHaveLength(rowCount); + expect(messages.every((message) => message === text)).toBe(true); + expect( + messages.reduce((bytes, message) => bytes + Buffer.byteLength(message), 0), + ).toBeGreaterThan(50 * 1024 * 1024); + expect((await remote.workspaces.list()).entries).toHaveLength(1); + await remote.dispose(); + }, +); + +test("a pre-write host budget rejection leaves sibling routes connected", async () => { + const { HostTransportBudget } = await import("@getpaseo/client/node"); + const b = await daemon(); + const origin = createHostClient( + `127.0.0.1:${b.port}`, + { clientId: "budget-origin", reconnect: { enabled: false } }, + new HostTransportBudget(1024 * 1024), + ); + cleanup.push(() => origin.close()); + await origin.connect(); + const serverId = origin.getLastServerInfoMessage()!.serverId; + const routeIds = [ + await origin.openHostLink("budget-first"), + await origin.openHostLink("budget-sibling"), + ]; + const peers = routeIds.map( + (id, index) => + new DaemonClient({ + url: "ws://host-link/ws", + clientId: `budget-child-${index}`, + reconnect: { enabled: false }, + transportFactory: createHostChannelTransport(origin, serverId, `budget-${index}`, id), + }), + ); + for (const peer of peers) { + cleanup.push(() => peer.close()); + await peer.connect(); + } + await expect(async () => + origin.sendHostFrame(routeIds[0]!, new Uint8Array(2 * 1024 * 1024)), + ).rejects.toMatchObject({ code: "resource_limit" }); + await peers[1]!.ping({ timeoutMs: 2000 }); + expect([origin.isConnected, ...peers.map((peer) => peer.isConnected)]).toEqual([ + true, + true, + true, + ]); +}, 30000); diff --git a/packages/server/src/server/connected-hosts/index.ts b/packages/server/src/server/connected-hosts/index.ts index 1a8f7c2731..bfff76239c 100644 --- a/packages/server/src/server/connected-hosts/index.ts +++ b/packages/server/src/server/connected-hosts/index.ts @@ -7,10 +7,12 @@ import { z } from "zod"; import { createHostClient, createHostChannelTransport, + createHostTransportFactory, parseHostTarget, HostTransportBudget, } from "@getpaseo/client/node"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types"; import { HostSummarySchema, HostAddSchema, @@ -21,6 +23,7 @@ import { type HostUpdate, } from "@getpaseo/protocol/hosts"; import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages"; +import { webSocketProxyAgent } from "../network/index.js"; export class HostError extends Error { constructor( @@ -234,22 +237,30 @@ export class ConnectedHosts { if (route.registrationId === id) route.close(code, "Host access was disabled, replaced or removed"); } - private start(row: Saved): void { + private start( + row: Saved, + resume?: { failures: number; transports: DaemonTransportFactory }, + ): void { if (!row.enabled || this.closed) return; + const transports = + resume?.transports ?? + createHostTransportFactory(row.connection, this.budget, { + webSocketAgent: webSocketProxyAgent, + }); let stopped = false; - const client = createHostClient( - row.connection, - { - clientId: `host-link-${this.self.serverId}-${row.registrationId}`, - connectTimeoutMs: 10000, - }, - this.budget, - ); + let attempts = resume?.failures ?? 0; + let retry: ReturnType | undefined; + const client = createHostClient(row.connection, { + clientId: `host-link-${this.self.serverId}-${row.registrationId}`, + connectTimeoutMs: 10000, + transportFactory: transports, + }); const live: Live = { client, accepted: false, stop: () => { stopped = true; + clearTimeout(retry); void client.close(); }, }; @@ -283,7 +294,7 @@ export class ConnectedHosts { if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true) throw new HostError( "unsupported_capability", - "Update the destination daemon to use connected hosts.", + "Update the destination daemon to use connected hosts; retrying automatically.", ); // Pin and persist under the same writer as management to prevent discovery races. await this.mutate(async () => { @@ -302,16 +313,22 @@ export class ConnectedHosts { if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return; Object.assign(row, next); live.accepted = true; + attempts = 0; this.changed(); }); } catch (error) { if (stopped) return; - row.status = "error"; row.error = connectionFailure(error, client.authFailureReason !== null) ?? { code: "host_offline", message: "Host connection failed", }; - row.nextRetryAt = null; + if (terminalFailures.has(row.error.code)) { + row.status = "error"; + row.nextRetryAt = null; + } else { + stopped = true; + retry = this.scheduleAcceptanceRetry(row, attempts, transports); + } this.changed(); await client.close(); } @@ -327,18 +344,17 @@ export class ConnectedHosts { if (route.registrationId === row.registrationId) route.close(); row.status = state.status === "connecting" ? "connecting" : "offline"; row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null; - row.error = - state.status === "disconnected" - ? (connectionFailure( - Object.assign(new Error("Check the SSH configuration and daemon credentials"), { - code: state.failureCode, - }), - client.authFailureReason !== null, - ) ?? { - code: "host_offline", - message: "Cannot reach host. Check the connection; retrying automatically.", - }) - : null; + if (state.status === "disconnected") + row.error = connectionFailure( + Object.assign(new Error("Check the SSH configuration and daemon credentials"), { + code: state.failureCode, + }), + client.authFailureReason !== null, + ) ?? { + code: "host_offline", + message: "Cannot reach host. Check the connection; retrying automatically.", + }; + else if (row.error?.code !== "unsupported_capability") row.error = null; if (row.error?.code === "authentication_failed") { row.status = "error"; row.nextRetryAt = null; @@ -349,6 +365,18 @@ export class ConnectedHosts { }); void client.connect().catch(() => {}); } + private scheduleAcceptanceRetry( + row: Saved, + failures: number, + transports: DaemonTransportFactory, + ): ReturnType { + const delay = acceptanceRetryDelayMs(failures); + row.status = "offline"; + row.nextRetryAt = new Date(Date.now() + delay).toISOString(); + const retry = setTimeout(() => this.start(row, { failures: failures + 1, transports }), delay); + retry.unref(); + return retry; + } async openRoute(input: { serverId: string; scopeId: string; @@ -508,10 +536,13 @@ export class ConnectedHosts { return; } if (!verified) throw new HostError("host_offline", "Host identity has not been verified"); - try { - transport.send(data); - } catch { + const rejectWrite = () => route.close("resource_limit", "Host buffer limit reached or transport unavailable"); + try { + const write = transport.send(data); + if (write) void write.catch(rejectWrite); + } catch { + rejectWrite(); } }, }; @@ -526,6 +557,20 @@ export class ConnectedHosts { } } +const ACCEPTANCE_RETRY_BASE_MS = 1500; +const ACCEPTANCE_RETRY_MAX_MS = 30_000; + +function acceptanceRetryDelayMs(failures: number): number { + return Math.min(ACCEPTANCE_RETRY_BASE_MS * 2 ** failures, ACCEPTANCE_RETRY_MAX_MS); +} + +const terminalFailures = new Set([ + "server_id_mismatch", + "self_host", + "duplicate_host", + "authentication_failed", +]); + function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] { if (error instanceof HostError) return { code: error.code, message: error.message }; if (error instanceof Error && "code" in error && error.code === "authentication_failed") diff --git a/packages/server/src/server/connected-hosts/link.ts b/packages/server/src/server/connected-hosts/link.ts index 85fe06ed4e..a18c89aeb0 100644 --- a/packages/server/src/server/connected-hosts/link.ts +++ b/packages/server/src/server/connected-hosts/link.ts @@ -1,7 +1,11 @@ import type { SessionDelivery } from "../session/owned-subscriptions/index.js"; import { EventEmitter } from "node:events"; import { createHash } from "node:crypto"; -import { encodeHostRouteFrame, type HostRouteFrame } from "@getpaseo/protocol/binary-frames/index"; +import { + MAX_HOST_CHANNEL_FRAME_BYTES, + encodeHostRouteFrame, + type HostRouteFrame, +} from "@getpaseo/protocol/binary-frames/index"; import type { DaemonPermission, SessionInboundMessage, @@ -10,6 +14,7 @@ import type { /** A logical socket has no network resources or authority of its own. */ export class VirtualHostSocket extends EventEmitter { + readonly maxFrameBytes = MAX_HOST_CHANNEL_FRAME_BYTES; readyState = 1; constructor( private readonly output: (data: string | Uint8Array) => Promise, diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index b6518d773d..a442dfe6a9 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1318,9 +1318,9 @@ test("emits disabled voice capability reasons on fresh daemon startup", async () expect(voice).toBeTruthy(); expect(voice?.dictation.enabled).toBe(false); - expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config."); + expect(voice?.dictation.reason).toBe("Dictation is off. Turn it on in Settings > Voice."); expect(voice?.voice.enabled).toBe(false); - expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config."); + expect(voice?.voice.reason).toBe("Voice mode is off. Turn it on in Settings > Voice."); } finally { await client.close().catch(() => undefined); await isolatedCtx.cleanup(); diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index c68cda02d2..d9b2e94181 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -1,13 +1,19 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; +import { loadConfig } from "./config.js"; import { loadPersistedConfig } from "./persisted-config.js"; +import { readSpeechSettings } from "./speech/speech-settings.js"; import type { PersistedConfig } from "./persisted-config.js"; import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; +function usageEnabled(persisted: PersistedConfig): boolean { + return persisted.daemon?.usage?.enabled !== false; +} + function reloadableConfig( persisted: PersistedConfig, options: { relayEnabledFallback?: boolean } = {}, @@ -21,11 +27,13 @@ function reloadableConfig( enabled: relay.enabled ?? options.relayEnabledFallback ?? true, }, mcp: { enabled: true, injectIntoAgents: false }, + usage: { enabled: usageEnabled(persisted) }, browserTools: { enabled: daemon.browserTools?.enabled ?? false }, providers: (agents.providers ?? {}) as MutableDaemonConfig["providers"], metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] }, autoArchiveAfterMerge: daemon.autoArchiveAfterMerge ?? false, enableTerminalAgentHooks: daemon.enableTerminalAgentHooks ?? false, + idleRuntimeCleanup: agents.idleRuntimeCleanup !== false, appendSystemPrompt: daemon.appendSystemPrompt ?? "", terminalProfiles: daemon.terminalProfiles, agentProfiles: daemon.agentProfiles, @@ -98,7 +106,7 @@ describe("DaemonConfigStore", () => { } }); - test("patch persists relay state and emits its field change", () => { + test("patch persists relay state and emits its field change", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -114,13 +122,13 @@ describe("DaemonConfigStore", () => { const changes: unknown[] = []; store.onFieldChange("relay.enabled", (value) => changes.push(value)); - store.patch({ relay: { enabled: true } }); + await store.patch({ relay: { enabled: true } }); expect(changes).toEqual([true]); expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true); }); - test("patch persists the relay protected security requirement independently of relay enablement", () => { + test("patch persists the relay protected security requirement independently of relay enablement", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore( @@ -142,14 +150,14 @@ describe("DaemonConfigStore", () => { const changes: unknown[] = []; store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value)); - store.patch({ relay: { requireProtectedSecurity: true } }); + await store.patch({ relay: { requireProtectedSecurity: true } }); expect(changes).toEqual([true]); expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true }); expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true); }); - test("patch round-trips agent profiles through the strictly-parsed persisted config", () => { + test("patch round-trips agent profiles through the strictly-parsed persisted config", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -163,7 +171,7 @@ describe("DaemonConfigStore", () => { appendSystemPrompt: "", }); - store.patch({ + await store.patch({ agentProfiles: [ { id: "profile_ui", @@ -195,7 +203,7 @@ describe("DaemonConfigStore", () => { expect(store.get().agentProfiles).toHaveLength(1); }); - test("patch replaces the whole agent profile list rather than merging entries", () => { + test("patch replaces the whole agent profile list rather than merging entries", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -213,13 +221,13 @@ describe("DaemonConfigStore", () => { ], }); - store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] }); + await store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] }); expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]); expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1); }); - test("rolls back config when a field transition fails", () => { + test("rolls back config when a field transition fails", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -238,14 +246,14 @@ describe("DaemonConfigStore", () => { } }); - expect(() => store.patch({ relay: { enabled: true } })).toThrow( + await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow( "Relay transport failed to start", ); expect(store.get().relay?.enabled).toBe(false); expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false); }); - test("rolls back live owners when a later transactional owner fails", () => { + test("rolls back live owners when a later transactional owner fails", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore(paseoHome, { @@ -269,7 +277,7 @@ describe("DaemonConfigStore", () => { throw new Error("Provider refresh failed"); }); - expect(() => store.patch({ browserTools: { enabled: true } })).toThrow( + await expect(store.patch({ browserTools: { enabled: true } })).rejects.toThrow( "Provider refresh failed", ); expect(browserToolsEnabled).toBe(false); @@ -277,7 +285,7 @@ describe("DaemonConfigStore", () => { expect(loadPersistedConfig(paseoHome).daemon?.browserTools?.enabled).toBeUndefined(); }); - test("rejects relay patches when a launch override owns the setting", () => { + test("rejects relay patches when a launch override owns the setting", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const store = new DaemonConfigStore( @@ -296,12 +304,12 @@ describe("DaemonConfigStore", () => { { relayEnabledMutable: false }, ); - expect(() => store.patch({ relay: { enabled: true } })).toThrow( + await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow( "Relay is controlled by a daemon launch override", ); }); - test("unrelated patches do not persist a one-launch relay override", () => { + test("unrelated patches do not persist a one-launch relay override", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const persisted = loadPersistedConfig(paseoHome); @@ -328,12 +336,12 @@ describe("DaemonConfigStore", () => { { relayEnabledMutable: false }, ); - store.patch({ browserTools: { enabled: true } }); + await store.patch({ browserTools: { enabled: true } }); expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false); }); - test("unrelated patches persist only requested file intent", () => { + test("unrelated patches persist only requested file intent", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); const before = loadPersistedConfig(paseoHome); @@ -359,7 +367,7 @@ describe("DaemonConfigStore", () => { { relayEnabledMutable: false }, ); - store.patch({ + await store.patch({ appendSystemPrompt: "Only this field", // Reload-only runtime state is accepted as unknown wire data for forward // compatibility but is not part of the patch capability. @@ -373,7 +381,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists provider enabled flags into config.json", () => { + test("patch persists provider enabled flags into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -414,7 +422,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ providers: { gemini: { enabled: false }, }, @@ -429,7 +437,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists provider Paseo-tool policy without changing availability", () => { + test("patch persists provider Paseo-tool policy without changing availability", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); writeFileSync( @@ -446,14 +454,14 @@ describe("DaemonConfigStore", () => { appendSystemPrompt: "", }); - store.patch({ + await store.patch({ providers: { claude: { paseoTools: { enabled: true, disabledTools: ["list_agents"] }, }, }, }); - store.patch({ + await store.patch({ providers: { claude: { paseoTools: { disabledTools: ["create_agent"] }, @@ -471,7 +479,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch removes provider entries from config.json", () => { + test("patch removes provider entries from config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -516,7 +524,7 @@ describe("DaemonConfigStore", () => { undefined, ); - const next = store.patch({ removeProviders: ["gemini"] }); + const next = await store.patch({ removeProviders: ["gemini"] }); expect(next.providers.gemini).toBeUndefined(); expect(next.providers.claude).toEqual({ enabled: false }); @@ -525,7 +533,7 @@ describe("DaemonConfigStore", () => { expect(persisted.agents?.providers?.claude).toEqual({ enabled: false }); }); - test("patch removes the providers object when the last provider is deleted", () => { + test("patch removes the providers object when the last provider is deleted", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -564,13 +572,13 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ removeProviders: ["gemini"] }); + await store.patch({ removeProviders: ["gemini"] }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.agents?.providers).toBeUndefined(); }); - test("patch removes deleted providers from metadata generation", () => { + test("patch removes deleted providers from metadata generation", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -626,7 +634,7 @@ describe("DaemonConfigStore", () => { undefined, ); - const next = store.patch({ removeProviders: ["gemini"] }); + const next = await store.patch({ removeProviders: ["gemini"] }); expect(next.metadataGeneration.providers).toEqual([{ provider: "claude", model: "haiku" }]); const persisted = loadPersistedConfig(paseoHome); @@ -635,7 +643,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists provider removal when in-memory config is already clean", () => { + test("patch persists provider removal when in-memory config is already clean", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -677,7 +685,7 @@ describe("DaemonConfigStore", () => { undefined, ); - const next = store.patch({ removeProviders: ["gemini"] }); + const next = await store.patch({ removeProviders: ["gemini"] }); expect(next.providers.gemini).toBeUndefined(); const persisted = loadPersistedConfig(paseoHome); @@ -685,7 +693,7 @@ describe("DaemonConfigStore", () => { expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] }); }); - test("patch persists append system prompt into config.json", () => { + test("patch persists append system prompt into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -703,7 +711,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ appendSystemPrompt: "Prefer terse replies.", }); @@ -711,7 +719,7 @@ describe("DaemonConfigStore", () => { expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); }); - test("patch persists browser tools opt-in into config.json", () => { + test("patch persists browser tools opt-in into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -728,13 +736,13 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ browserTools: { enabled: true } }); + await store.patch({ browserTools: { enabled: true } }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.daemon?.browserTools).toEqual({ enabled: true }); }); - test("patch persists provider additional models into config.json", () => { + test("patch persists provider additional models into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -752,7 +760,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ providers: { claude: { additionalModels: [ @@ -776,7 +784,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists daemon append system prompt into config.json", () => { + test("patch persists daemon append system prompt into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -794,7 +802,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ appendSystemPrompt: "Prefer terse replies.", }); @@ -802,7 +810,7 @@ describe("DaemonConfigStore", () => { expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); }); - test("patch persists enable terminal agent hooks into config.json", () => { + test("patch persists enable terminal agent hooks into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -819,13 +827,13 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ enableTerminalAgentHooks: true }); + await store.patch({ enableTerminalAgentHooks: true }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.daemon?.enableTerminalAgentHooks).toBe(true); }); - test("patch persists metadata generation providers into config.json", () => { + test("patch persists metadata generation providers into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -843,7 +851,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ metadataGeneration: { providers: [ { provider: "claude", model: "haiku" }, @@ -861,7 +869,7 @@ describe("DaemonConfigStore", () => { }); }); - test("patch persists clearing metadata generation providers into config.json", () => { + test("patch persists clearing metadata generation providers into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -896,13 +904,59 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ metadataGeneration: { providers: [] } }); + await store.patch({ metadataGeneration: { providers: [] } }); const persisted = loadPersistedConfig(paseoHome); expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] }); }); - test("patch persists custom ACP provider overrides into config.json", () => { + test("patch persists the host idle runtime cleanup opt-out for the next daemon start", async () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify({ + version: 1, + agents: { metadataGeneration: { providers: [{ provider: "claude" }] } }, + })}\n`, + ); + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + idleRuntimeCleanup: true, + appendSystemPrompt: "", + metadataGeneration: { providers: [{ provider: "claude" }] }, + }, + undefined, + ); + + expect((await store.patch({ idleRuntimeCleanup: false })).idleRuntimeCleanup).toBe(false); + + expect(loadPersistedConfig(paseoHome).agents).toEqual({ + metadataGeneration: { providers: [{ provider: "claude" }] }, + idleRuntimeCleanup: false, + }); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false); + + await store.patch({ idleRuntimeCleanup: true }); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true); + await store.patch({ resumeOnStartup: false, resumePrompt: "Keep going.\n" }); + expect(loadPersistedConfig(paseoHome).agents).toMatchObject({ + resumeOnStartup: false, + resumePrompt: "Keep going.\n", + }); + expect(loadConfig(paseoHome, { env: {} })).toMatchObject({ + resumeOnStartup: false, + resumePrompt: "Keep going.\n", + }); + }); + + test("patch persists custom ACP provider overrides into config.json", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -920,7 +974,7 @@ describe("DaemonConfigStore", () => { undefined, ); - store.patch({ + await store.patch({ providers: { "paseo-e2e-acp": { extends: "acp", @@ -988,7 +1042,49 @@ describe("DaemonConfigStore reload", () => { writeFileSync(path.join(paseoHome, "config.json"), `${JSON.stringify(config, null, 2)}\n`); } - test("applies mutable edits and reports startup-only edits", () => { + test("applies overlapping plugin mutations to the latest committed sources", async () => { + const { store } = createReloadableStore({ + initialPersisted: { + plugins: { + a: { source: "directory", path: "/a", enabled: false }, + b: { source: "directory", path: "/b", enabled: true }, + }, + }, + }); + const enable = store.patch((current) => ({ + plugins: { + ...current.plugins, + a: { ...current.plugins.a!, enabled: true }, + }, + })); + const disable = store.patch((current) => ({ + plugins: { + ...current.plugins, + b: { ...current.plugins.b!, enabled: false }, + }, + })); + await Promise.all([enable, disable]); + expect(store.get().plugins).toEqual({ + a: { source: "directory", path: "/a", enabled: true }, + b: { source: "directory", path: "/b", enabled: false }, + }); + }); + + test("serializes overlapping patches before committing their combined state", async () => { + const { paseoHome, store } = createReloadableStore(); + const first = store.patch({ appendSystemPrompt: "first" }); + const second = store.patch({ browserTools: { enabled: true } }); + expect(store.get().appendSystemPrompt).toBe(""); + await Promise.all([first, second]); + expect(store.get().appendSystemPrompt).toBe("first"); + expect(store.get().browserTools.enabled).toBe(true); + expect(loadPersistedConfig(paseoHome).daemon).toMatchObject({ + appendSystemPrompt: "first", + browserTools: { enabled: true }, + }); + }); + + test("applies mutable edits and reports startup-only edits", async () => { const { paseoHome, store, persisted } = createReloadableStore(); writeConfig(paseoHome, { ...persisted, @@ -1000,7 +1096,7 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [ "daemon.browserTools.enabled", "daemon.git.maxProcessConcurrency", @@ -1013,7 +1109,30 @@ describe("DaemonConfigStore reload", () => { expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 }); }); - test("applies the global plugin switch in both directions", () => { + test("reloads idle runtime cleanup in both directions without requiring a restart", async () => { + const { paseoHome, store, persisted } = createReloadableStore(); + const changes: unknown[] = []; + store.onFieldChange("idleRuntimeCleanup", (value) => changes.push(value)); + for (const enabled of [false, true]) { + writeConfig(paseoHome, { ...persisted, agents: { idleRuntimeCleanup: enabled } }); + expect(await store.reload()).toEqual({ + appliedPaths: ["agents.idleRuntimeCleanup"], + restartRequiredPaths: [], + overrideControlledPaths: [], + }); + expect(store.get().idleRuntimeCleanup).toBe(enabled); + } + expect(changes).toEqual([false, true]); + await store.patch({ idleRuntimeCleanup: false }); + expect(await store.reload()).toEqual({ + appliedPaths: [], + restartRequiredPaths: [], + overrideControlledPaths: [], + }); + expect(store.get().idleRuntimeCleanup).toBe(false); + }); + + test("applies the global plugin switch in both directions", async () => { const { paseoHome, store, persisted } = createReloadableStore({ initialPersisted: { version: 1, pluginsEnabled: false }, }); @@ -1021,7 +1140,7 @@ describe("DaemonConfigStore reload", () => { store.onFieldChange("pluginsEnabled", (value) => changes.push(value)); writeConfig(paseoHome, { ...persisted, pluginsEnabled: true }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["pluginsEnabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -1029,7 +1148,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().pluginsEnabled).toBe(true); writeConfig(paseoHome, { ...persisted, pluginsEnabled: false }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["pluginsEnabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -1038,7 +1157,7 @@ describe("DaemonConfigStore reload", () => { expect(changes).toEqual([true, false]); }); - test("classifies every leaf when a parent subtree is added", () => { + test("classifies every leaf when a parent subtree is added", async () => { const { paseoHome, store } = createReloadableStore({ initialPersisted: { version: 1 }, }); @@ -1053,14 +1172,14 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["daemon.relay.enabled"], restartRequiredPaths: ["daemon.relay.endpoint", "daemon.relay.useTls"], overrideControlledPaths: [], }); }); - test("classifies every leaf when the daemon subtree is removed", () => { + test("classifies every leaf when the daemon subtree is removed", async () => { const { paseoHome, store } = createReloadableStore({ initialPersisted: { version: 1, @@ -1081,7 +1200,7 @@ describe("DaemonConfigStore reload", () => { }); writeConfig(paseoHome, { version: 1 }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [ "daemon.listen", @@ -1095,7 +1214,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().relay?.enabled).toBe(false); }); - test("keeps overridden leaves separate from restart-required siblings", () => { + test("keeps overridden leaves separate from restart-required siblings", async () => { const { paseoHome, store } = createReloadableStore({ initialPersisted: { version: 1 }, overrideControlledPaths: ["daemon.relay.enabled"], @@ -1107,25 +1226,25 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: ["daemon.relay.endpoint"], overrideControlledPaths: ["daemon.relay.enabled"], }); }); - test("invalid JSON and invalid schema apply nothing", () => { + test("invalid JSON and invalid schema apply nothing", async () => { const { paseoHome, store } = createReloadableStore(); writeFileSync(path.join(paseoHome, "config.json"), "{ nope\n"); - expect(() => store.reload()).toThrow("Invalid JSON"); + await expect(store.reload()).rejects.toThrow("Invalid JSON"); expect(store.get().browserTools.enabled).toBe(false); writeConfig(paseoHome, { daemon: { browserTools: { enabled: "yes" } } }); - expect(() => store.reload()).toThrow("Invalid config"); + await expect(store.reload()).rejects.toThrow("Invalid config"); expect(store.get().browserTools.enabled).toBe(false); }); - test("removing providers and optional profiles clears live state", () => { + test("removing providers and optional profiles clears live state", async () => { const { paseoHome, store, persisted } = createReloadableStore(); writeConfig(paseoHome, { ...persisted, @@ -1140,10 +1259,10 @@ describe("DaemonConfigStore reload", () => { }, }, }); - store.reload(); + await store.reload(); writeConfig(paseoHome, persisted); - const result = store.reload(); + const result = await store.reload(); expect(result.appliedPaths).toEqual([ "agents.providers", @@ -1155,7 +1274,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().agentProfiles).toBeUndefined(); }); - test("reports a launch-controlled edit without changing live state", () => { + test("reports a launch-controlled edit without changing live state", async () => { const { paseoHome, store, persisted } = createReloadableStore({ overrideControlledPaths: ["daemon.relay.enabled"], }); @@ -1165,7 +1284,7 @@ describe("DaemonConfigStore reload", () => { daemon: { ...persisted.daemon, relay: { enabled: !initialRelay } }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: ["daemon.relay.enabled"], @@ -1173,7 +1292,7 @@ describe("DaemonConfigStore reload", () => { expect(store.get().relay?.enabled).toBe(initialRelay); }); - test("an unrelated patch does not mark a manual override-owned edit as applied", () => { + test("an unrelated patch does not mark a manual override-owned edit as applied", async () => { const { paseoHome, store, persisted } = createReloadableStore({ overrideControlledPaths: ["daemon.relay.enabled"], }); @@ -1181,16 +1300,16 @@ describe("DaemonConfigStore reload", () => { ...persisted, daemon: { ...persisted.daemon, relay: { enabled: true } }, }); - store.patch({ appendSystemPrompt: "patched elsewhere" }); + await store.patch({ appendSystemPrompt: "patched elsewhere" }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: ["daemon.relay.enabled"], }); }); - test("reports startup-only launch overrides instead of restart warnings", () => { + test("reports startup-only launch overrides instead of restart warnings", async () => { const { paseoHome, store, persisted } = createReloadableStore({ overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"], }); @@ -1206,19 +1325,283 @@ describe("DaemonConfigStore reload", () => { }, }); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"], }); }); - test("a no-op reload returns empty path lists", () => { + test("a no-op reload returns empty path lists", async () => { const { store } = createReloadableStore(); - expect(store.reload()).toEqual({ + expect(await store.reload()).toEqual({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: [], }); }); }); + +describe("DaemonConfigStore speech settings", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + }); + + function createSpeechStore(initialPersisted?: unknown, env: NodeJS.ProcessEnv = {}) { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-speech-")); + tempDirs.push(paseoHome); + if (initialPersisted) { + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify(initialPersisted, null, 2)}\n`, + ); + } + const persisted = loadPersistedConfig(paseoHome); + const applied: PersistedConfig[] = []; + const speech = { + read: (next: PersistedConfig) => readSpeechSettings(next, env), + apply: (next: PersistedConfig) => applied.push(next), + }; + const store = new DaemonConfigStore(paseoHome, reloadableConfig(persisted), undefined, { + speech, + reloadSource: { + resolve: (next) => ({ mutable: reloadableConfig(next), overrideControlledPaths: [] }), + }, + }); + return { paseoHome, store, applied, persisted }; + } + + test("reads, patches, and clears local STT language without losing it on other saves", async () => { + const { paseoHome, store } = createSpeechStore({ + features: { dictation: { stt: { language: "fr" } } }, + }); + expect(store.get().features?.dictation.stt).toEqual({ provider: "local", language: "fr" }); + await store.patch({ features: { voiceMode: { enabled: true } } }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ + provider: "local", + language: "fr", + }); + const patched = await store.patch({ + features: { dictation: { stt: { provider: "local", language: "de" } } }, + }); + expect(patched.features?.dictation.stt).toEqual({ provider: "local", language: "de" }); + await store.patch({ features: { dictation: { stt: { language: null } } } }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local" }); + }); + + test("reports language env overrides for both local STT sites", async () => { + const { store } = createSpeechStore( + {}, + { PASEO_DICTATION_LANGUAGE: "fr", PASEO_VOICE_LANGUAGE: "de" }, + ); + expect(store.get().envOverrides).toMatchObject({ + "features.dictation.stt.language": "PASEO_DICTATION_LANGUAGE", + "features.voiceMode.stt.language": "PASEO_VOICE_LANGUAGE", + }); + }); + + test("keeps voice mode off until it is turned on", async () => { + const { paseoHome, store } = createSpeechStore(); + + expect(store.get().features?.dictation.enabled).toBe(true); + expect(store.get().features?.voiceMode.enabled).toBe(false); + + const result = await store.patch({ features: { voiceMode: { enabled: true } } }); + + expect(result.features?.voiceMode.enabled).toBe(true); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true); + }); + + test("stores the OpenAI key without ever returning it", async () => { + const { paseoHome, store, applied } = createSpeechStore(); + + const result = await store.patch({ + features: { dictation: { stt: { provider: "openai", apiKey: " sk-test " } } }, + }); + + expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: true }); + expect(JSON.stringify(result)).not.toContain("sk-test"); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toMatchObject({ + apiKey: "sk-test", + }); + expect(applied).toHaveLength(1); + }); + + test("applies a replaced key even though the visible settings do not change", async () => { + const { store, applied } = createSpeechStore({ + version: 1, + features: { dictation: { stt: { provider: "openai", apiKey: "sk-old" } } }, + }); + + await store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } }); + + expect(applied.map((next) => next.features?.dictation?.stt)).toEqual([ + { provider: "openai", apiKey: "sk-new" }, + ]); + }); + + test("switching provider clears the previous provider's model", async () => { + const { paseoHome, store } = createSpeechStore({ + version: 1, + features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } }, + }); + + const result = await store.patch({ features: { dictation: { stt: { provider: "openai" } } } }); + + expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: false }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ + provider: "openai", + }); + }); + + test("rejects a local model id outside the catalog without persisting it", async () => { + const { paseoHome, store, applied } = createSpeechStore(); + + await expect( + store.patch({ features: { voiceMode: { tts: { provider: "local", model: "nope" } } } }), + ).rejects.toThrow("Unknown local voice model: nope"); + expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined(); + expect(applied).toEqual([]); + }); + + test("reload applies hand-edited speech settings without a restart", async () => { + const { paseoHome, store, applied, persisted } = createSpeechStore(); + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`, + ); + + const result = await store.reload(); + + expect(result.appliedPaths).toContain("features.voiceMode"); + expect(result.restartRequiredPaths).toEqual([]); + expect(store.get().features?.voiceMode.enabled).toBe(false); + expect(applied).toHaveLength(1); + }); + test("merges same-provider fields and replaces the whole variant when provider changes", async () => { + const { paseoHome, store } = createSpeechStore(); + await store.patch({ + features: { + voiceMode: { + stt: { provider: "openai", apiKey: "secret", baseUrl: "https://site/v1", model: "first" }, + }, + }, + }); + await store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } }); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "secret", + model: "second", + }); + await store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } }); + expect(store.get().features?.voiceMode.stt).toEqual({ + provider: "openai", + model: "third", + hasApiKey: true, + }); + await store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } }); + await store.patch({ features: { voiceMode: { stt: { provider: "local" } } } }); + await store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } }); + expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", hasApiKey: false }); + }); + + test("keeps a saved OpenAI key when a full variant patch omits the write-only key", async () => { + const { paseoHome, store } = createSpeechStore({ + features: { voiceMode: { stt: { provider: "openai", apiKey: "saved-secret" } } }, + }); + const result = await store.patch({ + features: { voiceMode: { stt: { provider: "openai", model: "whisper-1" } } }, + }); + expect(result.features?.voiceMode.stt).toEqual({ + provider: "openai", + model: "whisper-1", + hasApiKey: true, + }); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "saved-secret", + model: "whisper-1", + }); + expect(JSON.stringify(result)).not.toContain("saved-secret"); + }); + + test("loads a legacy host, redacts its key and writes the normalized config after any save", async () => { + const { paseoHome, store } = createSpeechStore({ + providers: { openai: { apiKey: "legacy-secret", stt: { baseUrl: "https://old/v1" } } }, + features: { voiceMode: { enabled: true, stt: { provider: "openai" } } }, + }); + expect(store.get().features?.voiceMode.stt).toEqual({ + provider: "openai", + baseUrl: "https://old/v1", + hasApiKey: true, + }); + expect(JSON.stringify(store.get())).not.toContain("legacy-secret"); + await store.patch({ autoArchiveAfterMerge: true }); + const disk = JSON.parse(readFileSync(path.join(paseoHome, "config.json"), "utf8")); + expect(disk.providers).not.toHaveProperty("openai"); + expect(disk.features.voiceMode.stt.apiKey).toBe("legacy-secret"); + }); + + test("reports env overrides and removes fallback provenance after saving a key", async () => { + const { store } = createSpeechStore( + { features: { voiceMode: { stt: { provider: "openai", model: "configured" } } } }, + { + PASEO_VOICE_STT_PROVIDER: "local", + OPENAI_API_KEY: "env-secret", + }, + ); + expect(store.get().features?.voiceMode.stt.provider).toBe("local"); + expect(store.get().envOverrides).toEqual({ + "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER", + }); + const other = createSpeechStore( + { features: { voiceMode: { stt: { provider: "openai" } } } }, + { + OPENAI_API_KEY: "env-secret", + OPENAI_STT_BASE_URL: "https://env/v1", + STT_MODEL: "override", + }, + ).store; + expect(other.get().envOverrides).toEqual({ + "features.voiceMode.stt.apiKey": "OPENAI_API_KEY", + "features.voiceMode.stt.baseUrl": "OPENAI_STT_BASE_URL", + "features.voiceMode.stt.model": "STT_MODEL", + }); + expect(other.get().features?.voiceMode.stt.hasApiKey).toBe(false); + await other.patch({ + features: { voiceMode: { stt: { apiKey: "saved-secret", baseUrl: "https://saved/v1" } } }, + }); + expect(other.get().envOverrides).toEqual({ "features.voiceMode.stt.model": "STT_MODEL" }); + expect(JSON.stringify(other.get())).not.toContain("secret"); + }); +}); + +test("usage policy persists across restart and live reload", async () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "usage-policy-")); + try { + const initial = reloadableConfig({}); + const store = new DaemonConfigStore(paseoHome, initial, undefined, { + reloadSource: { + resolve: (persisted) => ({ + mutable: reloadableConfig(persisted), + overrideControlledPaths: [], + }), + }, + }); + await store.patch({ usage: { enabled: false } }); + expect(loadConfig(paseoHome, { env: {} }).usageEnabled).toBe(false); + const persisted = loadPersistedConfig(paseoHome); + const restarted = new DaemonConfigStore(paseoHome, reloadableConfig(persisted)); + expect(restarted.get().usage).toEqual({ enabled: false }); + writeFileSync( + path.join(paseoHome, "config.json"), + JSON.stringify({ daemon: { usage: { enabled: true } } }), + ); + await store.reload(); + expect(store.get().usage).toEqual({ enabled: true }); + } finally { + rmSync(paseoHome, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 489f68ed5d..e83874235f 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -1,6 +1,7 @@ import { loadPersistedConfig, - savePersistedConfig, + savePersistedConfigAsync, + loadPersistedConfigAsync, type PersistedConfig, } from "./persisted-config.js"; import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; @@ -9,6 +10,8 @@ import { MutableDaemonConfigPatchSchema, } from "@getpaseo/protocol/messages"; import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { mergeSpeechSettingsPatch, type SpeechSettingsOwner } from "./speech/speech-settings.js"; export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; @@ -19,20 +22,27 @@ type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOver interface SupportedMutableConfigPatch { relay?: { enabled?: boolean; requireProtectedSecurity?: boolean }; mcp?: { injectIntoAgents?: boolean }; + usage?: { enabled: boolean }; browserTools?: { enabled?: boolean }; providers?: MutableDaemonConfig["providers"]; removeProviders?: string[]; metadataGeneration?: MutableDaemonConfig["metadataGeneration"]; autoArchiveAfterMerge?: boolean; enableTerminalAgentHooks?: boolean; + idleRuntimeCleanup?: boolean; + resumeOnStartup?: boolean; + resumePrompt?: string; appendSystemPrompt?: string; terminalProfiles?: MutableDaemonConfig["terminalProfiles"]; agentProfiles?: MutableDaemonConfig["agentProfiles"]; skills?: MutableDaemonConfig["skills"]; pluginsEnabled?: boolean; plugins?: MutableDaemonConfig["plugins"]; + features?: SpeechSettingsPatch; } +const SPEECH_PERSISTED_PATHS = ["features.dictation", "features.voiceMode"]; + interface LoggerLike { child(bindings: Record): LoggerLike; info(...args: unknown[]): void; @@ -173,6 +183,7 @@ const RELOADABLE_PATHS = [ "daemon.relay.requireProtectedSecurity", "daemon.mcp.enabled", "daemon.mcp.injectIntoAgents", + "daemon.usage.enabled", "daemon.browserTools.enabled", "daemon.hostnames", "daemon.cors.allowedOrigins", @@ -185,6 +196,9 @@ const RELOADABLE_PATHS = [ "daemon.terminalProfiles", "daemon.agentProfiles", "app.baseUrl", + "agents.idleRuntimeCleanup", + "agents.resumeOnStartup", + "agents.resumePrompt", "agents.providers", "agents.catalogRefreshTimeoutMs", "agents.metadataGeneration", @@ -197,6 +211,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map([ ["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"], ["daemon.mcp.enabled", "mcp.enabled"], ["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"], + ["daemon.usage.enabled", "usage.enabled"], ["daemon.browserTools.enabled", "browserTools.enabled"], ["daemon.hostnames", "hostnames"], ["daemon.cors.allowedOrigins", "cors.allowedOrigins"], @@ -209,6 +224,9 @@ const PERSISTED_TO_MUTABLE_PATH = new Map([ ["daemon.terminalProfiles", "terminalProfiles"], ["daemon.agentProfiles", "agentProfiles"], ["app.baseUrl", "app.baseUrl"], + ["agents.idleRuntimeCleanup", "idleRuntimeCleanup"], + ["agents.resumeOnStartup", "resumeOnStartup"], + ["agents.resumePrompt", "resumePrompt"], ["agents.providers", "providers"], ["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"], ["agents.metadataGeneration", "metadataGeneration"], @@ -266,6 +284,7 @@ function pickRelayPatch( function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch { return { ...pickRelayPatch(patch.relay), + usage: patch.usage, ...(patch.mcp?.injectIntoAgents !== undefined ? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } } : {}), @@ -283,6 +302,11 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut ...(patch.enableTerminalAgentHooks !== undefined ? { enableTerminalAgentHooks: patch.enableTerminalAgentHooks } : {}), + ...(patch.resumeOnStartup !== undefined ? { resumeOnStartup: patch.resumeOnStartup } : {}), + ...(patch.resumePrompt !== undefined ? { resumePrompt: patch.resumePrompt } : {}), + ...(patch.idleRuntimeCleanup !== undefined + ? { idleRuntimeCleanup: patch.idleRuntimeCleanup } + : {}), ...(patch.appendSystemPrompt !== undefined ? { appendSystemPrompt: patch.appendSystemPrompt } : {}), @@ -290,6 +314,7 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut ...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}), ...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}), ...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}), + ...(patch.features !== undefined ? { features: patch.features } : {}), }; } @@ -323,6 +348,7 @@ export function applyMutableProviderConfigToOverrides( } export class DaemonConfigStore { + private mutations: Promise = Promise.resolve(); private current: MutableDaemonConfig; private readonly paseoHome: string; private readonly logger: LoggerLike | undefined; @@ -332,6 +358,7 @@ export class DaemonConfigStore { private readonly relayEnabledMutable: boolean; private readonly reloadSource: DaemonConfigReloadSource | undefined; private readonly startupPersisted: PersistedConfig; + private readonly speechOwner: SpeechSettingsOwner | undefined; private lastKnownPersisted: PersistedConfig; constructor( @@ -342,42 +369,60 @@ export class DaemonConfigStore { relayEnabledMutable?: boolean; reloadSource?: DaemonConfigReloadSource; startupPersisted?: PersistedConfig; + speech?: SpeechSettingsOwner; } = {}, ) { this.paseoHome = paseoHome; this.logger = getLogger(logger); + this.relayEnabledMutable = options.relayEnabledMutable ?? true; + this.reloadSource = options.reloadSource; + this.speechOwner = options.speech; + this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); + this.lastKnownPersisted = this.startupPersisted; this.current = MutableDaemonConfigSchema.parse({ ...initial, relay: initial.relay ?? { enabled: true }, + ...this.readSpeech(this.startupPersisted), }); - this.relayEnabledMutable = options.relayEnabledMutable ?? true; - this.reloadSource = options.reloadSource; - this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); - this.lastKnownPersisted = this.startupPersisted; } public get(): MutableDaemonConfig { return this.current; } - public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig { - const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial)); - return this.applySupportedPatch(parsedPatch); + public patch( + partial: + | MutableDaemonConfigPatch + | ((current: MutableDaemonConfig) => MutableDaemonConfigPatch), + ): Promise { + return this.enqueue(() => + this.applySupportedPatch( + pickSupportedPatchFields( + MutableDaemonConfigPatchSchema.parse( + typeof partial === "function" ? partial(this.current) : partial, + ), + ), + ), + ); } - public setAgentSkillSelection(selection: AgentSkillSelection): MutableDaemonConfig { - return this.applySupportedPatch({ skills: { selection } }); + public setAgentSkillSelection(selection: AgentSkillSelection): Promise { + return this.enqueue(() => this.applySupportedPatch({ skills: { selection } })); } - private applySupportedPatch(parsedPatch: SupportedMutableConfigPatch): MutableDaemonConfig { + private async applySupportedPatch( + parsedPatch: SupportedMutableConfigPatch, + ): Promise { if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) { throw new Error( "Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.", ); } - const { removeProviders = [], ...configPatch } = parsedPatch; + const { removeProviders = [], ...patch } = parsedPatch; + const { features: speechPatch, ...configPatch } = patch; const removedProviders = Array.from(new Set(removeProviders)); const merged = deepMerge(this.current, configPatch); + if (speechPatch !== undefined) Object.assign(merged, this.readPatchedSpeech(speechPatch)); if (parsedPatch.skills?.selection !== undefined) { merged.skills = { selection: parsedPatch.skills.selection }; } @@ -391,42 +436,68 @@ export class DaemonConfigStore { const configChanged = !isEqualValue(this.current, next); - if (!configChanged && removedProviders.length === 0) { + // A replaced API key leaves the speech view unchanged, so a speech patch always persists. + if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) { return this.current; } - const { previous: persistedBeforePatch, knownNext } = this.persistConfig( - configPatch, + const { previous: persistedBeforePatch, knownNext } = await this.persistConfig( + patch, removedProviders, ); - if (!configChanged) { - this.lastKnownPersisted = knownNext; - return this.current; - } - - try { - this.applyReplacement(next, { removedProviders }); - this.lastKnownPersisted = knownNext; - } catch (error) { - savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger); - throw error; + const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, knownNext); + if (configChanged) { + try { + this.applyReplacement(next, { removedProviders }); + } catch (error) { + await savePersistedConfigAsync(this.paseoHome, persistedBeforePatch, this.logger); + throw error; + } } + this.lastKnownPersisted = knownNext; + if (speechChanged) this.speechOwner?.apply(knownNext); return this.current; } - public reload(): DaemonConfigReloadResult { + private readSpeech( + persisted: PersistedConfig, + ): Pick { + return this.speechOwner ? this.speechOwner.read(persisted) : {}; + } + + private readPatchedSpeech( + speechPatch: SpeechSettingsPatch, + ): Pick { + if (!this.speechOwner) { + throw new Error("Speech settings are unavailable for this daemon instance"); + } + return this.speechOwner.read(mergeSpeechSettingsPatch(this.lastKnownPersisted, speechPatch)); + } + + private speechPersistedChanged(previous: PersistedConfig, next: PersistedConfig): boolean { + return diffPaths(previous, next).some((path) => + SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner)), + ); + } + + public reload(): Promise { + return this.enqueue(() => this.reloadPersisted()); + } + + private async reloadPersisted(): Promise { if (!this.reloadSource) { throw new Error("Daemon config reload is unavailable for this daemon instance"); } - const persisted = loadPersistedConfig(this.paseoHome, this.logger); + const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger); const resolved = this.reloadSource.resolve(persisted); // Plugin source changes require the plugin lifecycle operation or a daemon // restart. The global switch is independently reloadable. const desired = MutableDaemonConfigSchema.parse({ ...resolved.mutable, plugins: this.current.plugins, + ...this.readSpeech(persisted), }); const changedSinceLastApply = diffPaths(this.lastKnownPersisted, persisted); const overrideControlledPaths = compactOwnedPaths( @@ -435,7 +506,7 @@ export class DaemonConfigStore { ), resolved.overrideControlledPaths, ); - const appliedPaths = RELOADABLE_PATHS.filter((persistedPath) => { + const appliedPaths: string[] = RELOADABLE_PATHS.filter((persistedPath) => { if (resolved.overrideControlledPaths.some((owner) => pathBelongsTo(persistedPath, owner))) { return false; } @@ -452,6 +523,8 @@ export class DaemonConfigStore { diffPaths(this.startupPersisted, persisted).filter((path) => { if (path === "$schema" || path === "version") return false; if (RELOADABLE_PATHS.some((owner) => pathBelongsTo(path, owner))) return false; + if (this.speechOwner && SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner))) + return false; return !resolved.overrideControlledPaths.some((owner) => pathBelongsTo(path, owner)); }), [], @@ -460,8 +533,17 @@ export class DaemonConfigStore { const removedProviders = Object.keys(this.current.providers).filter( (provider) => !(provider in desired.providers), ); + const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, persisted); this.applyReplacement(desired, { removedProviders }); this.lastKnownPersisted = persisted; + if (speechChanged && this.speechOwner) { + this.speechOwner.apply(persisted); + appliedPaths.push( + ...SPEECH_PERSISTED_PATHS.filter((owner) => + changedSinceLastApply.some((path) => pathBelongsTo(path, owner)), + ), + ); + } return { appliedPaths: [...appliedPaths].sort(), @@ -570,11 +652,24 @@ export class DaemonConfigStore { }; } - private persistConfig( + private enqueue(operation: () => Promise): Promise { + const result = this.mutations.then(operation); + this.mutations = result.catch(() => {}); + return result; + } + + public readServicePorts(): Promise["servicePorts"]> { + return this.enqueue( + async () => + (await loadPersistedConfigAsync(this.paseoHome, this.logger)).worktrees?.servicePorts, + ); + } + + private async persistConfig( patch: Omit, removeProviders: readonly string[], - ): { previous: PersistedConfig; knownNext: PersistedConfig } { - const persisted = loadPersistedConfig(this.paseoHome, this.logger); + ): Promise<{ previous: PersistedConfig; knownNext: PersistedConfig }> { + const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger); const merge = (source: PersistedConfig) => mergeMutablePatchIntoPersistedConfig({ persisted: source, @@ -584,7 +679,7 @@ export class DaemonConfigStore { }); const nextPersisted = merge(persisted); const knownNext = merge(this.lastKnownPersisted); - savePersistedConfig(this.paseoHome, nextPersisted, this.logger); + await savePersistedConfigAsync(this.paseoHome, nextPersisted, this.logger); return { previous: persisted, knownNext }; } } @@ -598,8 +693,11 @@ function mergeMutablePatchIntoPersistedConfig(params: { const { persisted, patch, removeProviders, persistRelayEnabled } = params; const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled); const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders); + const withSpeech = patch.features + ? mergeSpeechSettingsPatch(persisted, patch.features) + : persisted; return { - ...persisted, + ...withSpeech, ...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}), ...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}), ...(daemon ? { daemon } : { daemon: undefined }), @@ -616,6 +714,8 @@ function mergeMutableAgentPatch( patch.providers === undefined && patch.metadataGeneration === undefined && patch.skills === undefined && + !hasRestartRecoveryPatch(patch) && + patch.idleRuntimeCleanup === undefined && removeProviders.length === 0 ) { return persistedAgents; @@ -648,6 +748,11 @@ function mergeMutableAgentPatch( next["skills"] = { selection: patch.skills.selection }; } + Object.assign(next, restartRecoveryPatch(patch)); + if (patch.idleRuntimeCleanup !== undefined) { + next["idleRuntimeCleanup"] = patch.idleRuntimeCleanup; + } + return Object.keys(next).length > 0 ? (next as PersistedConfig["agents"]) : undefined; } @@ -666,6 +771,7 @@ function mergeMutableDaemonPatch( if (patch.mcp?.injectIntoAgents !== undefined) { next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents }; } + if (patch.usage) next.usage = patch.usage; if (patch.browserTools?.enabled !== undefined) { next.browserTools = { ...next.browserTools, enabled: patch.browserTools.enabled }; } @@ -680,3 +786,13 @@ function mergeMutableDaemonPatch( if (patch.agentProfiles !== undefined) next.agentProfiles = patch.agentProfiles; return Object.keys(next).length > 0 ? next : undefined; } + +function hasRestartRecoveryPatch(patch: SupportedMutableConfigPatch) { + return patch.resumeOnStartup !== undefined || patch.resumePrompt !== undefined; +} +function restartRecoveryPatch(patch: SupportedMutableConfigPatch) { + return { + ...(patch.resumeOnStartup !== undefined ? { resumeOnStartup: patch.resumeOnStartup } : {}), + ...(patch.resumePrompt !== undefined ? { resumePrompt: patch.resumePrompt } : {}), + }; +} diff --git a/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts index 7eba32f22a..557f8360de 100644 --- a/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts @@ -1,6 +1,3 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -10,7 +7,7 @@ import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -test("background cards retain child actions after the parent finishes (#6253)", async () => { +test("background children keep their actions after the parent finishes (#6253)", async () => { const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-")); const logger = pino({ level: "silent" }); const daemon = await createTestPaseoDaemon({ @@ -62,22 +59,6 @@ test("background cards retain child actions after the parent finishes (#6253)", limit: 0, projection: "projected", }); - const { stdout } = await promisify(execFile)( - process.execPath, - [ - "--conditions=source", - "--import", - "tsx", - fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), - "--host", - `127.0.0.1:${daemon.port}`, - "logs", - agent.id, - ], - { env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 }, - ); - expect(stdout).toContain("[Bash] ls -1"); - expect(stdout).toContain("[Bash] pwd"); expect( atFinish.entries.filter( (row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent", @@ -94,9 +75,6 @@ test("background cards retain child actions after the parent finishes (#6253)", ), ).toBe(true); } - for (const card of cards) { - expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]"); - } } finally { await client.close(); await daemon.close(); diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts index 09da7506c9..56e7cdefb8 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -1,6 +1,7 @@ -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { rm } from "node:fs/promises"; +import { rm, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -87,13 +88,9 @@ test.each(["uncommitted", "base", "commit"] as const)( 60_000, ); -test.each( - (["uncommitted", "base"] as const).flatMap((mode) => - (["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })), - ), -)( - "$mode $shape diff includes hunks from a repository subdirectory", - async ({ mode, shape }) => { +test.each(["single", "batch", "rename"] as const)( + "%s diff includes hunks from a repository subdirectory before and after commit", + async (shape) => { const ctx = await createDaemonTestContext(); const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-")); @@ -111,9 +108,7 @@ test.each( } git(cwd, "add", "."); git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); - if (mode === "base") { - git(cwd, "checkout", "-b", "feature"); - } + git(cwd, "checkout", "-b", "feature"); const filename = shape === "rename" ? "renamed.txt" : "normal.txt"; if (shape === "rename") { git(cwd, "mv", "sub/normal.txt", `sub/${filename}`); @@ -122,36 +117,38 @@ test.each( if (shape === "batch") { writeFileSync(join(cwd, "sibling.txt"), "new sibling\n"); } - if (mode === "base") { - git(cwd, "add", "."); - git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + async function expectSubdirectoryDiff(mode: "uncommitted" | "base") { + const compare = { mode, baseRef: "main" }; + const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare); + const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare); + const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare); + const liveDiff = await subscription.ready; + await subscription.release(); + + expect(rootDiff.error).toBeNull(); + expect(subdirectoryDiff.error).toBeNull(); + expect(liveDiff.error).toBeNull(); + expect(liveDiff.files).toEqual(rootDiff.files); + expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1); + const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`); + expect(file).toMatchObject({ + additions: 1, + deletions: 1, + ...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}), + }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + expect(subdirectoryDiff.files).toEqual(rootDiff.files); } - const compare = { mode, baseRef: "main" }; - const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare); - const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare); - const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare); - const liveDiff = await subscription.ready; - await subscription.release(); - - expect(rootDiff.error).toBeNull(); - expect(subdirectoryDiff.error).toBeNull(); - expect(liveDiff.error).toBeNull(); - expect(liveDiff.files).toEqual(rootDiff.files); - expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1); - const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`); - expect(file).toMatchObject({ - additions: 1, - deletions: 1, - ...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}), - }); - expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ type: "remove", content: "before" }), - expect.objectContaining({ type: "add", content: "after" }), - ]), - ); - expect(subdirectoryDiff.files).toEqual(rootDiff.files); + await expectSubdirectoryDiff("uncommitted"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + await expectSubdirectoryDiff("base"); } finally { await ctx.cleanup(); rmSync(cwd, { recursive: true, force: true }); @@ -223,3 +220,76 @@ test("All changes subscriptions refresh on file edits and commits", async () => await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } }, 60_000); + +test("large source diffs stay renderable for reads, subscriptions, and commit files", async () => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-budget-")); + const runGit = promisify(execFile); + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + git(cwd, "config", "core.autocrlf", "false"); + const original = "const value = Math.max(42, 7);\n".repeat(Math.ceil((5 * 1024 * 1024) / 30)); + writeFileSync(join(cwd, "generated.ts"), original); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + await writeFile(join(cwd, "generated.ts"), original.replace("42", "43")); + const pending = ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); + const rpcStarted = performance.now(); + await ctx.client.fetchAgents({}); + const rpcMs = performance.now() - rpcStarted; + const initial = await pending; + expect(initial.error).toBeNull(); + const check = (file: (typeof initial.files)[number] | null | undefined, value: string) => { + expect(file).toMatchObject({ path: "generated.ts", additions: 1, deletions: 1 }); + const lines = file!.hunks.flatMap((hunk) => hunk.lines); + expect(lines.filter((line) => line.type === "add").map((line) => line.content)).toEqual([ + `const value = Math.max(${value}, 7);`, + ]); + expect(lines.find((line) => line.type === "add")?.tokens).toContainEqual({ + text: value, + style: "number", + }); + }; + check(initial.files[0], "43"); + const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" }); + try { + let latest = await subscription.ready; + check(latest.files[0], "43"); + subscription.subscribe({ + snapshot: (snapshot) => { + latest = snapshot; + }, + update: (message) => { + if (message.type === "checkout_diff_update") latest = message.payload; + }, + }); + await writeFile(join(cwd, "generated.ts"), original.replace("42", "44")); + await expect + .poll( + () => + latest.files[0]?.hunks.flatMap((hunk) => hunk.lines).find((line) => line.type === "add") + ?.content, + { timeout: 15_000 }, + ) + .toBe("const value = Math.max(44, 7);"); + check(latest.files[0], "44"); + } finally { + await subscription.release(); + } + await runGit("git", ["add", "."], { cwd }); + await runGit("git", ["-c", "commit.gpgsign=false", "commit", "-m", "edit"], { cwd }); + const { stdout } = await runGit("git", ["rev-parse", "HEAD"], { cwd }); + const commit = await ctx.client.getCommitFileDiff(cwd, stdout.trim(), "generated.ts"); + check(commit.file, "44"); + console.info({ + sourceBytes: Buffer.byteLength(original), + cheapRpcMs: rpcMs, + isolatedDaemonPort: ctx.daemon.port, + }); + } finally { + await ctx.cleanup(); + await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); diff --git a/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts index d8388f9dd2..55750831c8 100644 --- a/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts @@ -87,3 +87,53 @@ test("logs --follow preserves the same assistant text as one-shot logs", async ( await daemon.close(); } }, 60_000); + +test("omitted after limit pages forward while explicit zero preserves complete CLI history", async () => { + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome }); + const initial = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 }); + const cursor = { epoch: initial.epoch, seq: initial.window.maxSeq }; + for (let index = 0; index < 451; index++) { + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "user_message", + text: String(index), + }); + } + const first = await client.fetchAgentTimeline(agent.id, { direction: "after", cursor }); + expect(first.entries).toHaveLength(200); + expect(first.hasNewer).toBe(true); + expect(first.endCursor).toEqual({ epoch: cursor.epoch, seq: cursor.seq + 200 }); + const second = await client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: first.endCursor!, + }); + const third = await client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: second.endCursor!, + }); + expect(second.entries).toHaveLength(200); + expect(third.entries).toHaveLength(51); + expect(third.hasNewer).toBe(false); + expect( + [...first.entries, ...second.entries, ...third.entries].map((entry) => entry.item), + ).toEqual( + Array.from({ length: 451 }, (_, index) => ({ type: "user_message", text: String(index) })), + ); + const allAfter = await client.fetchAgentTimeline(agent.id, { + direction: "after", + cursor, + limit: 0, + }); + const allTail = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 }); + expect(allAfter.entries).toHaveLength(451); + expect(allAfter.hasNewer).toBe(false); + expect(allTail.entries).toHaveLength(initial.entries.length + 451); + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/daemon-e2e/codex-steer-starting.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-steer-starting.real.e2e.test.ts new file mode 100644 index 0000000000..60508205f2 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/codex-steer-starting.real.e2e.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createMessageCollector } from "../test-utils/message-collector.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { canRunRealProvider } from "./real-provider-test-config.js"; + +test("keeps two concurrent startup steer messages in one real Codex turn", async (context) => { + if (!(await canRunRealProvider("codex"))) { + context.skip(); + return; + } + const cwd = mkdtempSync(path.join(tmpdir(), "codex-steer-starting-")); + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: new CodexAppServerAgentClient(logger) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const collector = createMessageCollector(client); + const subscriptions: ReturnType[] = []; + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + for (let attempt = 0; attempt < 3; attempt += 1) { + const agent = await client.createAgent({ + cwd, + provider: "codex", + modeId: "full-access", + }); + const subscription = client.subscribeAgentTimeline(agent.id, () => {}); + subscriptions.push(subscription); + await subscription.ready; + await Promise.all([ + client.sendAgentMessage(agent.id, "Reply exactly: FIRST", { + messageId: `first-${attempt}`, + activeTurnBehavior: "steer", + }), + client.sendAgentMessage(agent.id, "Reply exactly: SECOND", { + messageId: `second-${attempt}`, + activeTurnBehavior: "steer", + }), + ]); + await client.waitForFinish(agent.id, 90_000); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + const users = timeline.entries.filter((entry) => entry.item.type === "user_message"); + const events = collector.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.agentId === agent.id + ? [message.payload.event] + : [], + ); + expect(users.map((entry) => entry.item.text).sort()).toEqual([ + "Reply exactly: FIRST", + "Reply exactly: SECOND", + ]); + expect(new Set(users.map((entry) => entry.turnId)).size).toBe(1); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1); + expect( + events.filter((event) => event.type === "turn_canceled" || event.type === "turn_failed"), + ).toHaveLength(0); + expect( + timeline.entries.some( + (entry) => + entry.item.type === "assistant_message" && entry.item.text.includes("[System Error]"), + ), + ).toBe(false); + } + } finally { + await Promise.all(subscriptions.map((subscription) => subscription.release())); + collector.unsubscribe(); + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 300_000); diff --git a/packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts b/packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts new file mode 100644 index 0000000000..c2293a2bc7 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "vitest"; +import { createDaemonTestContext } from "../test-utils/index.js"; +import { MockLoadTestAgentClient } from "../agent/providers/mock-load-test-agent.js"; +import type { AgentSnapshotPayload } from "../messages.js"; + +test("creation never regresses a running subscription to the pre-prompt idle snapshot", async () => { + const ctx = await createDaemonTestContext({ + agentClients: { mock: new MockLoadTestAgentClient() }, + mcpEnabled: false, + isDev: true, + }); + try { + const workspace = await ctx.client.createWorkspace({ + source: { kind: "directory", path: ctx.daemon.staticDir }, + }); + if (!workspace.workspace) throw new Error(workspace.error ?? "Missing workspace"); + const observations: { + list: string; + kind: string; + agent?: AgentSnapshotPayload; + agentId?: string; + }[] = []; + for (const [list, statuses] of [ + ["running", ["running", "initializing", "error"]], + ["done", ["idle", "closed"]], + ] as const) { + const result = await ctx.client.fetchAgents({ + filter: { statuses: [...statuses] }, + subscribe: {}, + }); + result.subscription.subscribe({ + snapshot() {}, + update(message) { + observations.push({ list, ...message.payload }); + }, + }); + } + const agent = await ctx.client.createAgent({ + workspaceId: workspace.workspace.id, + provider: "mock", + cwd: ctx.daemon.staticDir, + initialPrompt: "Work on this task", + }); + await expect + .poll(() => observations.filter((row) => row.agent?.id === agent.id).length) + .toBeGreaterThan(1); + // Fetching provides a round trip after creation so its queued update reaches the client. + expect((await ctx.client.fetchAgent({ agentId: agent.id }))?.agent.status).toBe("running"); + const relevant = observations.filter( + (row) => row.agent?.id === agent.id || row.agentId === agent.id, + ); + const firstRunning = relevant.findIndex((row) => row.agent?.status === "running"); + expect(firstRunning).toBeGreaterThanOrEqual(0); + expect( + relevant.slice(firstRunning).filter((row) => row.list === "done" && row.kind === "upsert"), + ).toEqual([]); + const timestamps = relevant.flatMap((row) => + row.agent ? [Date.parse(row.agent.updatedAt)] : [], + ); + expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b)); + } finally { + await ctx.cleanup(); + } +}, 60_000); diff --git a/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts b/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts index efc99c9b59..1658356eed 100644 --- a/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/daemon-restart-resume.e2e.test.ts @@ -76,10 +76,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -async function restartFixture(root: string) { +async function restartFixture( + root: string, + settings: { resumeOnStartup?: boolean; resumePrompt?: string } = {}, + fixture: { holdResume?: boolean } = {}, +) { const child = fork( fileURLToPath(new URL("../test-utils/finish-subscription-daemon.ts", import.meta.url)), - [root], + [root, JSON.stringify(settings), JSON.stringify(fixture)], { execArgv: ["--conditions=source", "--import", "tsx"], stdio: ["ignore", "pipe", "pipe", "ipc"], @@ -183,7 +187,25 @@ test.each([false, true])( await expect.poll(() => readPrompts(root), { timeout: 15000 }).toHaveLength(1); const prompts = await readPrompts(root); expect(prompts[0]).toContain(`Agent ${childId}`); - expect(prompts[0]).toContain("was closed"); + expect(prompts[0]).not.toContain("was closed"); + const recoveredPrompts = (await readFile(path.join(root, "all-prompts.jsonl"), "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(recoveredPrompts.filter((entry) => entry.prompt === "Resume working.")).toHaveLength( + 1, + ); + const restored = recoveredPrompts.find((entry) => entry.prompt === "Resume working.").config; + const original = recoveredPrompts.find((entry) => + String(entry.prompt).includes("HOLD_CHILD"), + ).config; + expect(restored).toMatchObject({ + provider: original.provider, + model: original.model, + modeId: original.modeId, + cwd: original.cwd, + }); + await expect .poll( async () => @@ -196,7 +218,7 @@ test.each([false, true])( client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" }); await client.connect(); await client.fetchAgents({ subscribe: {} }); - expect((await client.fetchAgent(childId))?.agent.status).toBe("closed"); + expect((await client.fetchAgent(childId))?.agent.status).toBe("idle"); await client.close(); client = undefined; await stopFixture(second.child, false); @@ -220,3 +242,120 @@ test.each([false, true])( }, 60000, ); + +test.each([false, true])( + "host restart recovery settings (enabled=%s)", + async (enabled) => { + const root = await mkdtemp(path.join(tmpdir(), "resume-settings-process-")); + const processes: ChildProcess[] = []; + let client: DaemonClient | undefined; + const settings = { resumeOnStartup: enabled, resumePrompt: "Continue exactly.\n" }; + try { + const first = await restartFixture(root, settings); + processes.push(first.child); + client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" }); + await client.connect(); + const working = await client.createAgent({ + provider: "claude", + cwd: first.cwd, + modeId: "full-access", + }); + const idle = await client.createAgent({ + provider: "claude", + cwd: first.cwd, + modeId: "full-access", + }); + await client.sendMessage(working.id, "HOLD_CHILD"); + await expect + .poll( + async () => + ( + await new AgentStorage(path.join(first.paseoHome, "agents"), createTestLogger()).get( + working.id, + ) + )?.lastStatus, + ) + .toBe("running"); + const original = await new AgentStorage( + path.join(first.paseoHome, "agents"), + createTestLogger(), + ).get(working.id); + await client.close(); + client = undefined; + await stopFixture(first.child, false); + const second = await restartFixture(root, settings); + processes.push(second.child); + client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" }); + await client.connect(); + const storage = new AgentStorage(path.join(second.paseoHome, "agents"), createTestLogger()); + // Resume runs in the background; it is done once the saved intent is consumed. + await expect.poll(async () => (await storage.get(working.id))?.resumeOnStartup).toBe(false); + const resumePrompts = async () => + (await readFile(path.join(root, "all-prompts.jsonl"), "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + .filter((entry) => entry.prompt === settings.resumePrompt); + await expect.poll(resumePrompts).toHaveLength(enabled ? 1 : 0); + expect((await client.fetchAgent(idle.id))?.agent.status).toBe("closed"); + const restored = await storage.get(working.id); + expect(restored?.persistence?.sessionId).toBe(original?.persistence?.sessionId); + } finally { + await client?.close(); + for (const child of processes.toReversed()) await stopFixture(child, true); + await rm(root, { recursive: true, force: true }); + } + }, + 60000, +); + +test("a provider that is slow to reopen does not hold daemon startup", async () => { + const root = await mkdtemp(path.join(tmpdir(), "resume-background-process-")); + const processes: ChildProcess[] = []; + let client: DaemonClient | undefined; + try { + const first = await restartFixture(root); + processes.push(first.child); + client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" }); + await client.connect(); + const working = await client.createAgent({ + provider: "claude", + cwd: first.cwd, + modeId: "full-access", + }); + await client.sendMessage(working.id, "HOLD_CHILD"); + await expect + .poll( + async () => + ( + await new AgentStorage(path.join(first.paseoHome, "agents"), createTestLogger()).get( + working.id, + ) + )?.lastStatus, + ) + .toBe("running"); + await client.close(); + client = undefined; + await stopFixture(first.child, false); + + const second = await restartFixture(root, {}, { holdResume: true }); + processes.push(second.child); + client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" }); + await client.connect(); + expect((await client.fetchAgent(working.id))?.agent.id).toBe(working.id); + second.child.send("release-resume"); + await expect + .poll(async () => + (await readFile(path.join(root, "all-prompts.jsonl"), "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line).prompt) + .filter((prompt) => prompt === "Resume working."), + ) + .toHaveLength(1); + } finally { + await client?.close(); + for (const child of processes.toReversed()) await stopFixture(child, true); + await rm(root, { recursive: true, force: true }); + } +}, 30000); 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 e03c66bca0..db278d0249 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 @@ -46,7 +46,7 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => { } }); - test("waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", async () => { + test("waitForFinish does not resolve before an OpenCode initial prompt completes", async () => { const cwd = tmpCwd(); const { client, daemon } = await createHarness(); diff --git a/packages/server/src/server/daemon-e2e/oversized-timeline.e2e.test.ts b/packages/server/src/server/daemon-e2e/oversized-timeline.e2e.test.ts new file mode 100644 index 0000000000..bc78db2eab --- /dev/null +++ b/packages/server/src/server/daemon-e2e/oversized-timeline.e2e.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { createDaemonTestContext } from "../test-utils/index.js"; + +// #2220: a timeline page is one WebSocket message, so a few huge entries used to close the socket. +test.each([40, 0])( + "fetching image-heavy history with limit %i keeps the direct connection usable", + async (limit) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(path.join(tmpdir(), "oversized-timeline-")); + try { + const agent = await ctx.client.createAgent({ provider: "codex", cwd }); + const imageUrl = `data:image/jpeg;base64,${"A".repeat(14 * 1024 * 1024)}`; + for (let index = 0; index < 5; index += 1) { + await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "tool_call", + callId: `screenshot-${index}`, + name: "node_repl.js", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { index }, + output: { _meta: { "codex/toolSurface": { screenshot: { url: imageUrl } } } }, + }, + }); + } + + const page = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit }); + const screenshots = page.entries.filter((entry) => entry.item.type === "tool_call"); + expect(screenshots).toHaveLength(5); + for (const entry of screenshots) { + expect(Buffer.byteLength(JSON.stringify(entry.item))).toBeLessThanOrEqual(64 * 1024); + } + + const next = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 1 }); + expect(next.entries).toHaveLength(1); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_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 984aa2fdcc..d2d839122e 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 @@ -219,7 +219,7 @@ test( ); test( - "real Pi daemon executes manual compact out-of-band instead of prompt text", + "real Pi daemon accepts a prompt after manual compact", async () => { const cwd = tmpCwd("pi-manual-compact-"); @@ -234,20 +234,24 @@ test( cwd, title: "pi-manual-compact", provider: "pi", - model: PI_COMPACTION_TEST_MODEL, + model: PI_REAL_TEST_MODEL, }); await client.sendMessage(agent.id, "Reply exactly: compact-ready"); const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); - expect(finish.status).toBe("idle"); + expect(finish.status, JSON.stringify(await fetchCanonicalTimeline(client, agent.id))).toBe( + "idle", + ); await client.sendMessage(agent.id, "/compact summarize this e2e run"); - await waitForTimelineItem( client, agent.id, (item) => item.type === "compaction" && item.status === "completed", ); + await client.sendMessage(agent.id, "Reply exactly: after-compaction"); + const followUpFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(followUpFinish.status).toBe("idle"); const items = await fetchCanonicalTimeline(client, agent.id); expect(items).toEqual( @@ -263,9 +267,12 @@ test( }), ]), ); - expect( - items.some((item) => item.type === "user_message" && item.text.includes("/compact")), - ).toBe(false); + expect(items).toContainEqual( + expect.objectContaining({ + type: "assistant_message", + text: "after-compaction", + }), + ); expect( items .filter((item) => item.type === "assistant_message") diff --git a/packages/server/src/server/daemon-e2e/real-provider-test-config.ts b/packages/server/src/server/daemon-e2e/real-provider-test-config.ts index e54b781e65..e15c618fbf 100644 --- a/packages/server/src/server/daemon-e2e/real-provider-test-config.ts +++ b/packages/server/src/server/daemon-e2e/real-provider-test-config.ts @@ -26,7 +26,7 @@ const PI_AUTH_CONFIG_PATH = join(homedir(), ".pi", "agent", "auth.json"); const CODEX_AUTH_CONFIG_PATH = join(homedir(), ".codex", "auth.json"); const CLAUDE_REAL_TEST_MODEL = "haiku"; const CODEX_REAL_TEST_MODEL = "~openai/gpt-latest"; -const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; +const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-3.5-flash-lite"; const PI_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; const PI_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.4"; const OMP_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 32d3a1f36c..f47accaa55 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -9,7 +9,7 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import { getDaemonAuthFailureReason, - type DaemonClient as HostClient, + DaemonClient as HostClient, } from "@getpaseo/client/internal/daemon-client"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, test } from "vitest"; @@ -593,72 +593,112 @@ async function waitForCapturedLog( } }, 90000); - test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => { - await startRelay({ useLocalRelay: true }); - const daemon = await createTestPaseoDaemon({ - relayEnabled: true, - relayEndpoint: `127.0.0.1:${relayPort}`, - relayUseTls: false, - relayPublicUseTls: false, - }); - let client: DaemonClient | null = null; - try { - const offerUrl = await getPairingOfferUrl({ - paseoHome: daemon.paseoHome, + test.each([true, false])( + "large messages cross the relay with unpaced upload admission preserved (supportsDrain=%s)", + async (supportsDrain) => { + await startRelay({ useLocalRelay: true }); + const daemon = await createTestPaseoDaemon({ relayEnabled: true, - relayEndpoint: daemon.config.relayEndpoint, + relayEndpoint: `127.0.0.1:${relayPort}`, + relayUseTls: false, + relayPublicUseTls: false, }); - const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); - client = new DaemonClient({ - url: buildRelayWebSocketUrl({ - endpoint: `127.0.0.1:${relayPort}`, - useTls: false, - serverId, - role: "client", - }), - e2ee: { enabled: true, daemonPublicKeyB64 }, - reconnect: { baseDelayMs: 100, maxDelayMs: 100 }, - }); - await client.connect(); - const statuses: string[] = []; - client.subscribeConnectionStatus((state) => statuses.push(state.status)); - for (const imageMiB of [30, 61]) { - // Attachments are base64 in the JSON, then E2EE base64 expands the frame again. - const images = [ - { data: "A".repeat(4 * Math.ceil((imageMiB * 1024 * 1024) / 3)), mimeType: "image/png" }, - ]; + let client: HostClient | null = null; + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: true, + relayEndpoint: daemon.config.relayEndpoint, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + client = new HostClient({ + clientId: "paced-relay-regression", + webSocketFactory: Object.assign((url: string) => new WebSocket(url), { supportsDrain }), + url: buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + e2ee: { enabled: true, daemonPublicKeyB64 }, + reconnect: { baseDelayMs: 100, maxDelayMs: 100 }, + }); + await client.connect(); + const statuses: string[] = []; + client.subscribeConnectionStatus((state) => statuses.push(state.status)); + for (const imageMiB of [30, 61]) { + const images = [ + { + data: "A".repeat(4 * Math.ceil((imageMiB * 1024 * 1024) / 3)), + mimeType: "image/png", + }, + ]; + const creation = client.createWorkspace({ + source: { kind: "directory", path: daemon.staticDir }, + agent: { + provider: "claude", + cwd: daemon.staticDir, + initialPrompt: "Describe these screens.", + images, + }, + }); + if (supportsDrain) + await withTimeout(creation, 30000, "Chunked workspace creation did not settle"); + else await expect(creation).rejects.toThrow(/too large.*32\.0 MiB/); + } + expect((await client.fetchAgents()).entries).toHaveLength(supportsDrain ? 2 : 0); await expect( - withTimeout( - client.createWorkspace({ - source: { kind: "directory", path: daemon.staticDir }, - agent: { - provider: "claude", - cwd: daemon.staticDir, - initialPrompt: "Describe these screens.", - images, - }, - }), - 10000, - "Workspace creation did not settle", - ), - ).rejects.toThrow(/too large.*32\.0 MiB/); - expect((await client.fetchAgents()).entries).toEqual([]); - } - const agent = await client.createAgent({ provider: "claude", cwd: daemon.staticDir }); - await expect( - client.sendMessage(agent.id, "Describe these screens.", { + client.createWorkspace({ + source: { kind: "directory", path: daemon.staticDir }, + agent: { + provider: "claude", + cwd: daemon.staticDir, + initialPrompt: "Describe these screens.", + images: [{ data: "A".repeat(101 * 1024 * 1024), mimeType: "image/png" }], + }, + }), + ).rejects.toThrow(/too large.*100\.0 MiB/); + expect((await client.fetchAgents()).entries).toHaveLength(supportsDrain ? 2 : 0); + const agent = await client.createAgent({ provider: "claude", cwd: daemon.staticDir }); + const attachment = client.sendMessage(agent.id, "Describe these screens.", { images: [{ data: "A".repeat(40 * 1024 * 1024), mimeType: "image/png" }], - }), - ).rejects.toThrow(/too large.*32\.0 MiB/); - await client.sendMessage(agent.id, "A small follow-up still works."); - expect((await client.fetchAgents()).entries).toHaveLength(1); - expect(statuses).toEqual(["connected"]); - } finally { - await client?.close(); - await daemon.close(); - await stopRelay(); - } - }, 60000); + }); + if (supportsDrain) await attachment; + else await expect(attachment).rejects.toThrow(/too large.*32\.0 MiB/); + await expect( + client.sendMessage(agent.id, "Describe these screens.", { + images: [{ data: "A".repeat(101 * 1024 * 1024), mimeType: "image/png" }], + }), + ).rejects.toThrow(/too large.*100\.0 MiB/); + await client.sendMessage(agent.id, "A small follow-up still works."); + expect((await client.fetchAgents()).entries).toHaveLength(supportsDrain ? 3 : 1); + // Respect the per-item content budget while exceeding the relay frame limit. + const text = "x".repeat(240 * 1024); + for (let row = 0; row < 220; row++) + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "user_message", + text, + }); + const timeline = await client.fetchAgentTimeline(agent.id, { + projection: "canonical", + limit: 0, + timeout: 30000, + }); + const delivered = timeline.entries.filter( + (row) => row.item.type === "user_message" && row.item.text === text, + ); + expect(delivered).toHaveLength(220); + expect(delivered.length * Buffer.byteLength(text)).toBeGreaterThan(50 * 1024 * 1024); + await client.ping(); + expect(statuses).toEqual(["connected"]); + } finally { + await client?.close(); + await daemon.close(); + await stopRelay(); + } + }, + 120000, + ); test("daemon connects to relay and client ping/pong works through relay", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; 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 2b789788ad..ed00f6cd9a 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 @@ -7,7 +7,6 @@ import { Terminal as HeadlessTerminal, type IBufferCell, type IBufferLine } from 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/daemon-client"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -46,7 +45,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac terminalId: terminal.id, headless: liveHeadless, }); - const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot"); + const initialSnapshot = waitForReplayEnd(client, terminal.id); const liveSubscription = await subscribeTerminal(client, terminal.id); await initialSnapshot; @@ -60,13 +59,13 @@ test("byte-stream headless terminal matches daemon state after high-output attac text: BYTE_DONE_MARKER, rows: BYTE_TEST_SIZE.rows, }); - const daemonSnapshot = await readTerminalSnapshot({ + const daemonSnapshot = await readReplayViewport({ port: daemon.port, terminalId: terminal.id, }); await liveHeadless.flush(); - expect(liveHeadless.visibleLines()).toEqual(visibleTerminalLines(daemonSnapshot)); + expect(liveHeadless.visibleLines()).toEqual(daemonSnapshot.lines); expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor); expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines); @@ -79,7 +78,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac terminalId: terminal.id, headless: restoreHeadless, }); - const restoreFrame = waitForTerminalStreamEvent(client, terminal.id, "restore"); + const restoreFrame = waitForReplayEnd(client, terminal.id); await subscribeTerminal(client, terminal.id, { restore: { mode: "visible-snapshot", scrollbackLines: 200, size: BYTE_TEST_SIZE }, @@ -116,12 +115,10 @@ class ClientHeadlessTerminal { } applyEvent(event: TerminalStreamEvent): void { - if (event.type === "snapshot") { - this.enqueueText(renderTerminalSnapshotToAnsi(event.state), { resetDecoder: true }); - return; - } - if (event.type === "restore") { - this.enqueueText(this.decoder.decode(event.data, { stream: false }), { resetDecoder: true }); + if (event.type === "replay") { + if (event.replay.type === "begin") this.enqueueText("\x1bc", { resetDecoder: true }); + if (event.replay.type === "data") + this.enqueueText(this.decoder.decode(event.replay.data, { stream: true })); return; } this.enqueueText(this.decoder.decode(event.data, { stream: true })); @@ -187,35 +184,32 @@ async function subscribeTerminal( return response.subscription; } -async function readTerminalSnapshot(input: { - port: number; - terminalId: string; -}): Promise { - const client = new DaemonClient({ - url: `ws://127.0.0.1:${input.port}/ws`, - appVersion: "0.1.96", - clientType: "mobile", - }); +async function readReplayViewport(input: { port: number; terminalId: string }): Promise<{ + lines: string[]; + cursor: TerminalState["cursor"]; +}> { + const client = new DaemonClient({ url: `ws://127.0.0.1:${input.port}/ws`, clientType: "mobile" }); + const headless = new ClientHeadlessTerminal(BYTE_TEST_SIZE); + const stop = pipeTerminalStreamToHeadless({ client, terminalId: input.terminalId, headless }); try { await client.connect(); - const snapshot = waitForTerminalStreamEvent(client, input.terminalId, "snapshot"); + const completed = waitForReplayEnd(client, input.terminalId); await subscribeTerminal(client, input.terminalId); - const event = await snapshot; - return event.state; + await completed; + await headless.flush(); + return { lines: headless.visibleLines(), cursor: headless.cursor() }; } finally { + stop(); await client.close(); } } -function waitForTerminalStreamEvent( - client: DaemonClient, - terminalId: string, - type: TType, -): Promise> { - return client.waitForTerminalStreamEvent( - (event) => event.terminalId === terminalId && event.type === type, - 10_000, - ) as Promise>; +async function waitForReplayEnd(client: DaemonClient, terminalId: string): Promise { + await client.waitForTerminalStreamEvent( + (event) => + event.terminalId === terminalId && event.type === "replay" && event.replay.type === "end", + 10000, + ); } async function createPackageLockTerminalCwd(): Promise { @@ -287,24 +281,16 @@ async function waitForCondition( throw new Error(`Timed out after ${timeoutMs}ms waiting for terminal byte parity`); } -function visibleTerminalLines(state: TerminalState): string[] { - return state.grid.map(terminalRowText); -} - -function terminalRowText(row: TerminalState["grid"][number]): string { - return row - .map((cell) => cell.char) - .join("") - .trimEnd(); -} - function bufferLineText(line: IBufferLine | undefined, reusableCell: IBufferCell): string { const cells: TerminalCell[] = []; for (let col = 0; col < BYTE_TEST_SIZE.cols; col += 1) { const cell = line?.getCell(col, reusableCell); cells.push({ char: cell?.getChars() || " " }); } - return terminalRowText(cells); + return cells + .map((cell) => cell.char) + .join("") + .trimEnd(); } function packageLockStreamScript(): string { diff --git a/packages/server/src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts new file mode 100644 index 0000000000..d606b4d4d9 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts @@ -0,0 +1,688 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:net"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { resolveDaemonVersion } from "../daemon-version.js"; +import { readDaemonInstance, stopDaemonInstance } from "../daemon-instance.js"; +import { currentBootId } from "../pid-lock.js"; +import { connectTerminalWorker } from "../../terminal/terminal-worker-client.js"; +import { createTerminalWorkerProcessInspector } from "../../terminal/terminal-worker-processes.js"; +import { reapTerminalWorkers } from "../../terminal/terminal-worker-reaper.js"; +import { launchTerminalWorker } from "../../terminal/terminal-worker-launch.js"; +import { prepareTerminalWorkerRuntime } from "../../terminal/terminal-worker-runtime.js"; +import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js"; +import { + listTerminalWorkerRecords, + writeTerminalWorkerRecord, + type TerminalWorkerRecord, +} from "../../terminal/terminal-worker-record.js"; + +// Real supervisors started through scripts/supervisor-entrypoint.ts, driven by DaemonClient the +// way the app drives them. Every daemon, worker and program here lives in a throwaway home and +// is killed in afterEach; nothing touches another daemon. + +const SERVER_ROOT = path.resolve(import.meta.dirname, "../../.."); +const SUPERVISOR_ENTRY = path.join(SERVER_ROOT, "scripts/supervisor-entrypoint.ts"); +const READY_TIMEOUT_MS = 90_000; +const POSIX = process.platform !== "win32"; +const inspector = createTerminalWorkerProcessInspector(); + +interface Harness { + home: string; + workDir: string; + port: number; + supervisor: ChildProcess; + output: () => string; +} + +const harnesses: Harness[] = []; +const extraPids = new Set(); +const extraDirs: string[] = []; + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor( + probe: () => Promise | T | null | undefined | false, + message: string, + timeoutMs = READY_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown = null; + while (Date.now() < deadline) { + try { + const value = await probe(); + if (value) return value; + } catch (error) { + lastError = error; + } + await delay(100); + } + throw new Error(`${message}${lastError ? ` (last error: ${String(lastError)})` : ""}`); +} + +async function getAvailablePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + await new Promise((resolve) => server.close(() => resolve())); + if (!address || typeof address === "string") throw new Error("no port"); + return address.port; +} + +async function startSupervisor( + options: { home?: string; port?: number; pathPrefix?: string } = {}, +): Promise { + const home = options.home ?? mkdtempSync(path.join(tmpdir(), "tw-life-")); + const workDir = path.join(home, "work"); + mkdirSync(workDir, { recursive: true }); + const port = options.port ?? (await getAvailablePort()); + const env: NodeJS.ProcessEnv = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + HOME: home, + USERPROFILE: home, + PASEO_HOME: home, + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_RELAY_ENABLED: "false", + PASEO_NODE_INSPECT: "0", + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + CI: "true", + }; + if (options.pathPrefix) { + env.PATH = `${options.pathPrefix}${path.delimiter}${env.PATH ?? ""}`; + } + let output = ""; + const supervisor = spawn( + process.execPath, + ["--import", "tsx", SUPERVISOR_ENTRY, "--dev", "--no-relay"], + { cwd: SERVER_ROOT, env, stdio: ["ignore", "pipe", "pipe"] }, + ); + supervisor.stdout?.on("data", (chunk) => (output = (output + String(chunk)).slice(-20_000))); + supervisor.stderr?.on("data", (chunk) => (output = (output + String(chunk)).slice(-20_000))); + const harness: Harness = { home, workDir, port, supervisor, output: () => output }; + harnesses.push(harness); + await waitFor(async () => { + const instance = await readDaemonInstance(home); + return instance?.pid === supervisor.pid && instance.listen ? instance : null; + }, `supervisor did not become ready:\n${output}`); + return harness; +} + +async function connect(harness: Harness): Promise { + return waitFor(async () => { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${harness.port}/ws`, + connectTimeoutMs: 5_000, + }); + try { + await client.connect(); + return client; + } catch (error) { + await client.close().catch(() => undefined); + throw error; + } + }, "cannot connect to the daemon"); +} + +// A plain `paseo daemon stop`: SIGTERM on POSIX, the shutdown RPC where there is no signal +// for it (Windows refuses a graceful stop without one). +async function stopDaemon(harness: Harness): Promise { + await stopDaemonInstance(harness.home, { + requestShutdown: async () => { + const client = await connect(harness); + try { + await client.shutdownServer(); + } finally { + await client.close(); + } + }, + }); +} + +async function daemonWorkerPid(client: DaemonClient): Promise { + return (await client.getDaemonStatus()).pid; +} + +async function waitForReplacement(harness: Harness, previousPid: number): Promise { + return waitFor(async () => { + const client = await connect(harness); + const pid = await daemonWorkerPid(client).catch(() => previousPid); + if (pid !== previousPid) return client; + await client.close(); + return null; + }, "the daemon worker was not replaced"); +} + +async function createWorkspace(client: DaemonClient, harness: Harness): Promise { + const result = await client.createWorkspace({ + source: { kind: "directory", path: harness.workDir }, + title: "lifecycle", + }); + if (!result.workspace) throw new Error(result.error ?? "workspace creation failed"); + return result.workspace.id; +} + +interface MarkedTerminal { + id: string; + programPid: number; +} + +// A terminal whose program writes its pid, prints a marker and echoes each input line. +async function createMarkedTerminal( + client: DaemonClient, + harness: Harness, + workspaceId: string, + marker: string, +): Promise { + const pidFile = path.join(harness.workDir, `${marker}.pid`); + const created = await client.createTerminal(harness.workDir, marker, undefined, { + workspaceId, + command: process.execPath, + args: [ + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); + console.log(${JSON.stringify(marker)}); + process.stdin.on("data", (data) => console.log("ECHO:" + data.toString().trim()));`, + ], + }); + if (!created.terminal) throw new Error(created.error ?? "terminal creation failed"); + const programPid = await waitFor( + () => (existsSync(pidFile) ? Number(readFileSync(pidFile, "utf8")) : null), + "terminal program did not start", + 10_000, + ); + await waitForScreen(client, created.terminal.id, marker); + return { id: created.terminal.id, programPid }; +} + +// A shell program that ignores SIGHUP. With `exec`, the shell becomes `sleep`: same pid and +// birth time, another command line. Without it, `sleep` runs as the shell's child. +async function createHupResistantTerminal( + client: DaemonClient, + harness: Harness, + workspaceId: string, + name: string, + mode: "exec" | "child", +): Promise<{ id: string; shellPid: number; sleepPid: number }> { + const shellPidFile = path.join(harness.workDir, `${name}.shell.pid`); + const sleepPidFile = path.join(harness.workDir, `${name}.sleep.pid`); + const script = + mode === "exec" + ? `trap '' HUP; echo $$ > '${shellPidFile}'; echo $$ > '${sleepPidFile}'; exec sleep 100000` + : `trap '' HUP; echo $$ > '${shellPidFile}'; sleep 100000 & echo $! > '${sleepPidFile}'; wait`; + const created = await client.createTerminal(harness.workDir, name, undefined, { + workspaceId, + command: "/bin/sh", + args: ["-c", script], + }); + if (!created.terminal) throw new Error(created.error ?? "terminal creation failed"); + const read = (file: string) => + existsSync(file) && readFileSync(file, "utf8").trim() + ? Number(readFileSync(file, "utf8").trim()) + : null; + const shellPid = await waitFor(() => read(shellPidFile), "shell did not start", 10_000); + const sleepPid = await waitFor(() => read(sleepPidFile), "sleep did not start", 10_000); + extraPids.add(shellPid); + extraPids.add(sleepPid); + // `exec` replaces the shell; wait until the process really is `sleep`. + await waitFor( + async () => { + const inspection = await inspector.inspect(sleepPid); + return inspection.status === "alive" && inspection.snapshot.commandLine?.startsWith("sleep"); + }, + "sleep did not start", + 10_000, + ); + return { id: created.terminal.id, shellPid, sleepPid }; +} + +async function screen(client: DaemonClient, terminalId: string): Promise { + return (await client.captureTerminal(terminalId, { stripAnsi: true })).lines.join("\n"); +} + +async function waitForScreen(client: DaemonClient, terminalId: string, text: string) { + await waitFor( + async () => (await screen(client, terminalId)).includes(text), + `terminal ${terminalId} never showed ${text}`, + 15_000, + ); +} + +async function expectKept( + client: DaemonClient, + terminal: MarkedTerminal, + marker: string, + trigger: string, +) { + const listed = await client.listTerminals(undefined, undefined, {}); + expect(listed.terminals.map((entry) => entry.id)).toContain(terminal.id); + expect(isAlive(terminal.programPid)).toBe(true); + expect(await screen(client, terminal.id)).toContain(marker); + client.sendTerminalInput(terminal.id, { type: "input", data: `after-${trigger}\r` }); + await waitForScreen(client, terminal.id, `ECHO:after-${trigger}`); +} + +async function waitUntilGone(pid: number, what: string) { + await waitFor(() => !isAlive(pid), `${what} (pid ${pid}) is still alive`, 15_000); +} + +async function liveRecords(home: string): Promise { + return (await listTerminalWorkerRecords(home)).flatMap((file) => + "record" in file ? [file.record] : [], + ); +} + +async function currentWorkerRecord(home: string): Promise { + const records = await liveRecords(home); + if (records.length !== 1) throw new Error(`expected one worker record, found ${records.length}`); + return records[0]!; +} + +async function expectAllClosed(home: string, pids: number[]) { + for (const pid of pids) { + await waitUntilGone(pid, "a process that should be closed"); + } + expect(await liveRecords(home)).toEqual([]); +} + +function spawnUnrelated(): ChildProcess { + const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1e9)"], { + detached: true, + stdio: "ignore", + }); + child.unref(); + extraPids.add(child.pid!); + return child; +} + +async function birthOf(pid: number): Promise { + const inspection = await inspector.inspect(pid); + if (inspection.status !== "alive" || !inspection.snapshot.startedAt) { + throw new Error(`no birth time for ${pid}`); + } + return inspection.snapshot.startedAt; +} + +afterEach(async () => { + for (const harness of harnesses.splice(0)) { + const pid = harness.supervisor.pid; + if (pid && isAlive(pid)) { + try { + process.kill(pid, "SIGCONT"); + } catch { + // gone + } + } + await stopDaemonInstance(harness.home, { force: true, timeoutMs: 15_000 }).catch( + () => undefined, + ); + if (pid && isAlive(pid)) process.kill(pid, "SIGKILL"); + for (const record of await liveRecords(harness.home)) { + if (isAlive(record.pid)) { + try { + process.kill(record.pid, "SIGCONT"); + } catch { + // gone + } + } + } + await reapTerminalWorkers({ paseoHome: harness.home, gracefulMs: 0 }).catch(() => undefined); + rmSync(harness.home, { recursive: true, force: true }); + } + for (const pid of extraPids) { + if (isAlive(pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // gone + } + } + } + extraPids.clear(); + for (const dir of extraDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("terminals across daemon restarts", { timeout: 240_000 }, () => { + test("Settings restart keeps the terminal; Settings restart and close terminals kills it", async () => { + const harness = await startSupervisor(); + let client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-SETTINGS"); + const workerPid = (await currentWorkerRecord(harness.home)).pid; + + // Exactly what the app sends from Settings: no killTerminals. + let daemonPid = await daemonWorkerPid(client); + await client.restartServer("settings_update"); + await client.close(); + client = await waitForReplacement(harness, daemonPid); + await expectKept(client, terminal, "MARKER-SETTINGS", "settings-restart"); + expect((await currentWorkerRecord(harness.home)).pid).toBe(workerPid); + + daemonPid = await daemonWorkerPid(client); + await client.restartServer("settings_update", undefined, { killTerminals: true }); + await client.close(); + client = await waitForReplacement(harness, daemonPid); + await waitUntilGone(terminal.programPid, "terminal program"); + await waitUntilGone(workerPid, "old terminal worker"); + expect((await client.listTerminals(undefined, undefined, {})).terminals).toEqual([]); + expect((await liveRecords(harness.home)).every((record) => record.pid !== workerPid)).toBe( + true, + ); + await client.close(); + }); + + test("a daemon crash keeps the terminal; the restarted daemon lists it", async () => { + const harness = await startSupervisor(); + let client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-CRASH"); + const daemonPid = await daemonWorkerPid(client); + process.kill(daemonPid, "SIGKILL"); + await client.close(); + client = await waitForReplacement(harness, daemonPid); + await expectKept(client, terminal, "MARKER-CRASH", "crash"); + await client.close(); + }); + + test("daemon.update.request restarts the daemon and keeps the terminal", async () => { + // Fake npm only: `ls` reports this checkout as the global install at the daemon's + // version, `install -g` does nothing, and anything else fails so real npm never runs. + const fakeBin = mkdtempSync(path.join(tmpdir(), "tw-npm-")); + extraDirs.push(fakeBin); + const listing = JSON.stringify({ + path: path.join(fakeBin, "global"), + dependencies: { + "@getpaseo/cli": { + version: resolveDaemonVersion(import.meta.url), + path: SERVER_ROOT, + }, + }, + }); + const fakeNpm = path.join(fakeBin, "fake-npm.cjs"); + writeFileSync( + fakeNpm, + `const args = process.argv.slice(2); +const has = (...tokens) => tokens.every((token) => args.includes(token)); +if (has("-g", "ls", "@getpaseo/cli", "--json", "--depth=0", "--long")) { + process.stdout.write(${JSON.stringify(listing)} + "\\n"); +} else if (args[0] === "install" && has("-g", "@getpaseo/cli@latest")) { + // The installed version is already the running one: nothing to do. +} else { + process.stderr.write("unexpected npm call: " + args.join(" ") + "\\n"); + process.exit(1); +} +`, + ); + const npmScript = path.join(fakeBin, "npm"); + writeFileSync(npmScript, `#!/bin/sh\nexec "${process.execPath}" "${fakeNpm}" "$@"\n`); + chmodSync(npmScript, 0o755); + writeFileSync(path.join(fakeBin, "npm.cmd"), `@"${process.execPath}" "${fakeNpm}" %*\r\n`); + const harness = await startSupervisor({ pathPrefix: fakeBin }); + let client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-UPDATE"); + const daemonPid = await daemonWorkerPid(client); + + const update = await client.updateDaemon(); + expect(update).toMatchObject({ success: true }); + await client.close(); + client = await waitForReplacement(harness, daemonPid); + await expectKept(client, terminal, "MARKER-UPDATE", "update"); + await client.close(); + }); + + test.skipIf(!POSIX)( + "POSIX: a kill restart whose shutdown hangs closes terminals before the respawn", + async () => { + const harness = await startSupervisor(); + let client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-HUNG"); + const daemonPid = await daemonWorkerPid(client); + const supervisorPid = harness.supervisor.pid!; + const logPath = path.join(harness.home, "daemon.log"); + + // Barrier: the supervisor is paused, so the restart intent waits in its IPC channel. + // The daemon's own log line marks the intent dispatched; then the daemon is paused, so + // its graceful shutdown can never run and the supervisor's force path must. + process.kill(supervisorPid, "SIGSTOP"); + await client.restartServer("settings_update", undefined, { killTerminals: true }); + await waitFor( + () => readFileSync(logPath, "utf8").includes("Lifecycle intent dispatched"), + "the daemon never dispatched the restart intent", + 15_000, + ); + process.kill(daemonPid, "SIGSTOP"); + expect(isAlive(terminal.programPid)).toBe(true); + process.kill(supervisorPid, "SIGCONT"); + await client.close(); + + client = await waitForReplacement(harness, daemonPid); + const log = readFileSync(logPath, "utf8"); + const forced = log.indexOf("forcing process tree kill"); + const cleaned = log.indexOf('"msg":"Terminal cleanup finished","when":"before_respawn"'); + const respawned = log.indexOf('"msg":"Spawning worker"', forced); + expect(forced).toBeGreaterThan(-1); + expect(cleaned).toBeGreaterThan(forced); + expect(respawned).toBeGreaterThan(cleaned); + expect(isAlive(terminal.programPid)).toBe(false); + expect((await client.listTerminals(undefined, undefined, {})).terminals).toEqual([]); + await client.close(); + }, + ); +}); + +describe("terminals when the daemon stops", { timeout: 240_000 }, () => { + test.skipIf(!POSIX)( + "POSIX: stop closes a terminal whose program ignores HUP, child process included", + async () => { + const harness = await startSupervisor(); + const client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createHupResistantTerminal( + client, + harness, + workspaceId, + "hup", + "child", + ); + const workerPid = (await currentWorkerRecord(harness.home)).pid; + await client.close(); + await stopDaemon(harness); + await expectAllClosed(harness.home, [terminal.shellPid, terminal.sleepPid, workerPid]); + }, + ); + + for (const force of [false, true]) { + test.skipIf(!POSIX)( + `POSIX: worker death reaps an exec'd HUP-resistant program and ${force ? "stop --force" : "stop"} closes its replacement`, + async () => { + const harness = await startSupervisor(); + const client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createHupResistantTerminal( + client, + harness, + workspaceId, + "orphan", + "exec", + ); + const workerPid = (await currentWorkerRecord(harness.home)).pid; + const daemonPid = await daemonWorkerPid(client); + process.kill(workerPid, "SIGKILL"); + await waitUntilGone(workerPid, "terminal worker"); + await waitUntilGone(terminal.sleepPid, "orphaned terminal program"); + await waitFor( + async () => (await client.listTerminals(undefined, undefined, {})).terminals.length === 0, + "dead terminal was not removed", + ); + expect(await daemonWorkerPid(client)).toBe(daemonPid); + const replacement = await createMarkedTerminal(client, harness, workspaceId, "RECOVERED"); + await expectKept(client, replacement, "RECOVERED", "worker-death"); + await client.close(); + await stopDaemonInstance(harness.home, { force }); + await expectAllClosed(harness.home, [terminal.sleepPid, replacement.programPid]); + }, + ); + } + + test.skipIf(!POSIX)( + "POSIX: stop --force kills a stopped (SIGSTOP) worker and its terminal", + async () => { + const harness = await startSupervisor(); + const client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-STOPPED"); + const workerPid = (await currentWorkerRecord(harness.home)).pid; + await client.close(); + process.kill(workerPid, "SIGSTOP"); + await stopDaemonInstance(harness.home, { force: true }); + await expectAllClosed(harness.home, [workerPid, terminal.programPid]); + }, + ); + + test("the worker socket rejects a wrong secret and leaves its terminals alone", async () => { + const harness = await startSupervisor(); + const client = await connect(harness); + const workspaceId = await createWorkspace(client, harness); + const terminal = await createMarkedTerminal(client, harness, workspaceId, "MARKER-AUTH"); + const record = await currentWorkerRecord(harness.home); + await expect( + connectTerminalWorker({ socketPath: record.socketPath, secret: "not-the-secret" }), + ).rejects.toThrow(); + await expectKept(client, terminal, "MARKER-AUTH", "auth"); + await client.close(); + }); + + test("stop and stop --force never signal processes a stale or earlier-boot record names", async () => { + const harness = await startSupervisor(); + const client = await connect(harness); + await client.close(); + const reused = spawnUnrelated(); + const earlierBoot = spawnUnrelated(); + const base = await currentWorkerRecord(harness.home); + // Same pid, another birth time: what a reused pid looks like, whatever its command line. + writeTerminalWorkerRecord(harness.home, { + ...base, + recordId: "stale-record", + pid: reused.pid!, + identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "node -e setInterval" }, + terminals: [{ id: "t-stale", pid: reused.pid!, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + // The right birth time, but written before this boot. + writeTerminalWorkerRecord(harness.home, { + ...base, + recordId: "earlier-boot-record", + pid: earlierBoot.pid!, + bootId: currentBootId() ? "another-boot" : null, + createdAt: "2000-01-01T00:00:00.000Z", + identity: { + startedAt: await birthOf(earlierBoot.pid!), + commandLine: `node --paseo-terminal-worker earlier-boot-record`, + }, + terminals: [ + { id: "t-boot", pid: earlierBoot.pid!, startedAt: await birthOf(earlierBoot.pid!) }, + ], + }); + + await stopDaemon(harness); + expect(isAlive(reused.pid!)).toBe(true); + expect(isAlive(earlierBoot.pid!)).toBe(true); + expect(await liveRecords(harness.home)).toEqual([]); + + // A second round through --force, with the stale record put back. + writeTerminalWorkerRecord(harness.home, { + ...base, + recordId: "stale-record", + pid: reused.pid!, + identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "node -e setInterval" }, + terminals: [{ id: "t-stale", pid: reused.pid!, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + await stopDaemonInstance(harness.home, { force: true }); + expect(isAlive(reused.pid!)).toBe(true); + expect(await liveRecords(harness.home)).toEqual([]); + }); +}); + +describe("a worker launched from its own copy", { timeout: 240_000 }, () => { + // What a built daemon does: bundle the worker, copy bundle + shell integration + node-pty into + // the home, launch from the copy. Here the copy's node-pty (ConPTY on Windows) opens a real + // terminal; nothing is loaded from this checkout once the worker runs. + test("opens a terminal and runs a command from the copied bundle and node-pty", async () => { + const home = mkdtempSync(path.join(tmpdir(), "tw-copy-home-")); + const bundleDirectory = mkdtempSync(path.join(tmpdir(), "tw-copy-bundle-")); + extraDirs.push(home, bundleDirectory); + const buildScript = path.join(SERVER_ROOT, "scripts/build-terminal-worker.mjs"); + const { buildTerminalWorkerBundle } = (await import(buildScript)) as { + buildTerminalWorkerBundle(input: { outDir: string }): Promise; + }; + await buildTerminalWorkerBundle({ outDir: bundleDirectory }); + const nodePtyDirectory = path.dirname( + createRequire(import.meta.url).resolve("node-pty/package.json"), + ); + const entry = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "0.0.0-copy", + bundleDirectory, + nodePtyDirectory, + }); + + const launched = await launchTerminalWorker({ + paseoHome: home, + paseoVersion: "0.0.0-copy", + entry, + }); + extraPids.add(launched.record.pid); + expect(launched.record.runtime).toBe(entry.runtime); + const manager = createWorkerTerminalManager({ + worker: await connectTerminalWorker({ + socketPath: launched.record.socketPath, + secret: launched.record.secret, + }), + closeTerminals: async () => { + await reapTerminalWorkers({ paseoHome: home }); + }, + }); + try { + const session = await manager.createTerminal({ + workspaceId: "ws-copy", + cwd: home, + command: process.execPath, + args: ["-e", "console.log('COPY-' + 'OK'); setInterval(() => {}, 1000);"], + }); + await waitFor( + async () => + (await manager.captureTerminal(session.id, { stripAnsi: true })).lines + .join("\n") + .includes("COPY-OK"), + "the terminal in the copied worker never printed its marker", + 30_000, + ); + } finally { + await manager.closeTerminals(); + } + expect(isAlive(launched.record.pid)).toBe(false); + expect(existsSync(path.dirname(entry.scriptPath))).toBe(false); + }); +}); 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 633bd3bc1c..36425ffeec 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -1,3 +1,4 @@ +import { Terminal as HeadlessTerminal } from "@xterm/headless"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; @@ -10,7 +11,6 @@ import { type WSOutboundMessage, } from "@getpaseo/protocol/messages"; import { - decodeTerminalSnapshotPayload, decodeTerminalStreamFrame, encodeTerminalStreamFrame, TerminalStreamOpcode, @@ -80,6 +80,24 @@ async function waitForCondition( throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); } +function readHeadlessState(terminal: HeadlessTerminal): TerminalState { + const buffer = terminal.buffer.active; + const lines: TerminalState["grid"] = []; + for (let row = 0; row < buffer.length; row++) { + const cells: TerminalState["grid"][number] = []; + for (let col = 0; col < terminal.cols; col++) + cells.push({ char: buffer.getLine(row)?.getCell(col)?.getChars() ?? "" }); + lines.push(cells); + } + return { + rows: terminal.rows, + cols: terminal.cols, + grid: lines.slice(buffer.baseY), + scrollback: lines.slice(0, buffer.baseY), + cursor: { row: buffer.cursorY, col: buffer.cursorX }, + }; +} + async function waitForTerminalSnapshot( client: DaemonClient, terminalId: string, @@ -92,16 +110,34 @@ async function waitForTerminalSnapshot( reject(new Error(`Timeout waiting for terminal snapshot (${timeout}ms)`)); }, timeout); + let terminal: HeadlessTerminal | null = null; + let writes = Promise.resolve(); const unsubscribe = client.onTerminalStreamEvent((event) => { - if (event.terminalId !== terminalId || event.type !== "snapshot") { - return; + if (event.terminalId !== terminalId || event.type !== "replay") return; + const replay = event.replay; + if (replay.type === "begin") { + terminal?.dispose(); + terminal = new HeadlessTerminal({ + rows: replay.rows ?? 24, + cols: replay.cols ?? 80, + scrollback: 1000, + allowProposedApi: true, + }); + } else if (replay.type === "data") { + const active = terminal!; + writes = writes.then(() => new Promise((done) => active.write(replay.data, done))); + } else { + void writes.then(() => { + const active = terminal!; + const state = readHeadlessState(active); + if (!predicate(state)) return null; + clearTimeout(timeoutHandle); + unsubscribe(); + active.dispose(); + resolve(state); + return null; + }); } - if (!predicate(event.state)) { - return; - } - clearTimeout(timeoutHandle); - unsubscribe(); - resolve(event.state); }); }); } @@ -160,7 +196,7 @@ function toWsBuffer(raw: WebSocket.RawData): Buffer | null { return null; } -async function connectRawWebSocket(port: number): Promise { +async function connectRawWebSocket(port: number, chunkedReplay = false): Promise { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); await new Promise((resolve, reject) => { ws.once("open", () => resolve()); @@ -180,6 +216,7 @@ async function connectRawWebSocket(port: number): Promise { clientId: `terminal-raw-${Date.now()}-${Math.random().toString(36).slice(2)}`, clientType: "cli", protocolVersion: 1, + capabilities: { terminal_chunked_replay: chunkedReplay }, }), ); @@ -386,17 +423,24 @@ async function collectRawBinaryFrames( }); } +async function decodeRestoreState( + frame: TerminalStreamFrame, + terminalId: string, +): Promise { + const size = ctx.daemon.daemon.terminalManager!.getTerminal(terminalId)!.getSize(); + const terminal = new HeadlessTerminal({ ...size, scrollback: 1000, allowProposedApi: true }); + try { + await new Promise((resolve) => terminal.write(frame.payload, resolve)); + return readHeadlessState(terminal); + } finally { + terminal.dispose(); + } +} + function getFrameText(frame: TerminalStreamFrame): string { if (frame.opcode === TerminalStreamOpcode.Output) { return new TextDecoder().decode(frame.payload); } - if (frame.opcode === TerminalStreamOpcode.Snapshot) { - const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state) { - return ""; - } - return extractStateText(state); - } if (frame.opcode === TerminalStreamOpcode.Restore) { return new TextDecoder().decode(frame.payload); } @@ -446,7 +490,10 @@ function startTerminalOutputCapture(ws: WebSocket, slot: number): TerminalOutput if (!frame || frame.slot !== slot) { return; } - if (frame.opcode === TerminalStreamOpcode.Snapshot) { + if ( + frame.opcode === TerminalStreamOpcode.Restore || + frame.opcode === TerminalStreamOpcode.ReplayEnd + ) { snapshotCount += 1; return; } @@ -634,7 +681,7 @@ async function measureRepeatCadenceForTerminal(input: { const slot = await subscribeRawTerminal(ws, terminalId, `sub-repeat-cadence-${input.name}`); await waitForRawBinaryFrame( ws, - (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore, 10000, ); await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -817,13 +864,7 @@ test("live terminal restore skips the initial snapshot", async () => { expect(outputText).toContain("after-live"); expect(outputText).not.toContain("before-live"); - expect( - outputFrames.some( - (frame) => - frame.opcode === TerminalStreamOpcode.Snapshot || - frame.opcode === TerminalStreamOpcode.Restore, - ), - ).toBe(false); + expect(outputFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false); } finally { await closeWebSocket(ws); } @@ -901,15 +942,12 @@ test("propagates debounced terminal titles through list responses and snapshots" } expect(listedTitle).toBe("Build Output"); - const snapshotPromise = waitForTerminalSnapshot( - ctx.client, - terminalId, - (state) => state.title === "Build Output", + const attached = await ctx.client.subscribeTerminal(terminalId); + expect(attached.error).toBeNull(); + const afterAttach = await ctx.client.listTerminals(cwd); + expect(afterAttach.terminals.find((terminal) => terminal.id === terminalId)?.title).toBe( + "Build Output", ); - await ctx.client.subscribeTerminal(terminalId); - const snapshot = await snapshotPromise; - - expect(snapshot.title).toBe("Build Output"); rmSync(cwd, { recursive: true, force: true }); }, 30000); @@ -973,13 +1011,10 @@ test("subscribe response is sent before the initial snapshot frame", async () => return; } const frame = decodeTerminalStreamFrame(new Uint8Array(buffer)); - if (frame?.opcode !== TerminalStreamOpcode.Snapshot) { - return; - } - const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state || !extractStateText(state).includes("hello-ordering")) { + if (frame?.opcode !== TerminalStreamOpcode.Restore) { return; } + if (!getFrameText(frame).includes("hello-ordering")) return; events.push("snapshot"); maybeResolve(); }; @@ -1113,7 +1148,7 @@ test("reconnected terminal streams replay active input modes after the snapshot" const firstSlot = await subscribeRawTerminal(firstWs, terminalId, "sub-input-mode-first"); await waitForRawBinaryFrame( firstWs, - (frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === firstSlot && frame.opcode === TerminalStreamOpcode.Restore, ); sendRawTerminalInput(firstWs, firstSlot, "e"); await waitForRawBinaryFrame( @@ -1140,7 +1175,7 @@ test("reconnected terminal streams replay active input modes after the snapshot" const secondSlot = await subscribeRawTerminal(secondWs, terminalId, "sub-input-mode-second"); await waitForRawBinaryFrame( secondWs, - (frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === secondSlot && frame.opcode === TerminalStreamOpcode.Restore, ); const replay = await waitForRawBinaryFrame( secondWs, @@ -1172,7 +1207,7 @@ test("reconnected terminal streams do not replay input modes before they are ena const slot = await subscribeRawTerminal(ws, terminalId, "sub-input-mode-inactive"); await waitForRawBinaryFrame( ws, - (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Restore, ); await waitForNoRawBinaryFrame(ws, 300); } finally { @@ -1188,7 +1223,7 @@ test("fast output to a slow websocket client falls back to a snapshot", async () const ws = await connectRawWebSocket(ctx.daemon.port); await subscribeRawTerminal(ws, terminalId, "sub-raw"); - await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, 10000); + await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000); const rawSocket = (ws as WebSocket & { _socket?: { pause: () => void; resume: () => void } }) ._socket; @@ -1203,12 +1238,12 @@ test("fast output to a slow websocket client falls back to a snapshot", async () await new Promise((resolve) => setTimeout(resolve, 750)); const catchUpFramePromise = waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 15000, ); rawSocket!.resume(); const catchUpFrame = await catchUpFramePromise; - expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); + expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Restore); await closeWebSocket(ws); rmSync(cwd, { recursive: true, force: true }); @@ -1260,7 +1295,7 @@ test("resize updates server dimensions without sending a live snapshot", async ( await new Promise((resolve) => setTimeout(resolve, 300)); - const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState(); + const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getSize(); expect(state?.rows).toBe(10); expect(state?.cols).toBe(40); @@ -1277,7 +1312,7 @@ test("the latest resize from any attached client owns the PTY size", async () => ); const readSize = () => { - const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState(); + const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getSize(); return state ? { rows: state.rows, cols: state.cols } : null; }; const expectSize = async (size: { rows: number; cols: number }) => { @@ -1403,10 +1438,10 @@ test("websocket terminate then new connection gets snapshot with all prior outpu await subscribeRawTerminal(firstSocket, terminalId, "sub-drop-a"); const initialSnapshot = await waitForRawBinaryFrame( firstSocket, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); - expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Snapshot); + expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Restore); ctx.client.sendTerminalInput(terminalId, { type: "input", @@ -1442,9 +1477,9 @@ test("websocket terminate then new connection gets snapshot with all prior outpu try { await subscribeRawTerminal(secondSocket, terminalId, "sub-drop-b"); const reconnectFrame = await waitForRawBinaryFrame(secondSocket, () => true, 10000); - expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); + expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Restore); - const state = decodeTerminalSnapshotPayload(reconnectFrame.payload); + const state = await decodeRestoreState(reconnectFrame, terminalId); expect(state).not.toBeNull(); expect(extractStateText(state!)).toContain("before-drop"); expect(extractStateText(state!)).toContain("while-dead"); @@ -1513,11 +1548,11 @@ test("snapshot fidelity through websocket decode preserves dimensions and visibl await subscribeRawTerminal(ws, terminalId, "sub-fidelity"); const snapshotFrame = await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); - const state = decodeTerminalSnapshotPayload(snapshotFrame.payload); + const state = await decodeRestoreState(snapshotFrame, terminalId); expect(state).not.toBeNull(); expect(state).toMatchObject({ rows: 24, @@ -1545,7 +1580,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async await subscribeRawTerminal(ws, terminalId, "sub-exit"); await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); @@ -1561,8 +1596,7 @@ test("terminal exit prevents resubscribe and sends no frames after exit", async const exitMessage = await exitMessagePromise; expect(exitMessage.message.type).toBe("terminal_stream_exit"); - const subscribeResult = await ctx.client.subscribeTerminal(terminalId); - expect(subscribeResult.error).toBe("Terminal not found"); + await expect(ctx.client.subscribeTerminal(terminalId)).rejects.toThrow("Terminal not found"); await waitForNoRawBinaryFrame(ws, 750); } finally { await closeWebSocket(ws); @@ -1580,7 +1614,7 @@ test("empty input frame does not crash the server", async () => { const slot = await subscribeRawTerminal(ws, terminalId, "sub-empty-input"); await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); @@ -1619,7 +1653,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as await subscribeRawTerminal(ws, terminalId, "sub-large-output"); await waitForRawBinaryFrame( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + (frame) => frame.opcode === TerminalStreamOpcode.Restore, 10000, ); @@ -1636,7 +1670,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as .reduce((sum, frame) => sum + frame.payload.byteLength, 0); return ( outputBytes >= 1024 * 1024 || - collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot) + collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore) ); }, 20000, @@ -1646,7 +1680,7 @@ test("1MB output burst keeps frames decodable and terminal usable afterward", as for (const frame of frames) { expect( frame.opcode === TerminalStreamOpcode.Output || - frame.opcode === TerminalStreamOpcode.Snapshot, + frame.opcode === TerminalStreamOpcode.Restore, ).toBe(true); } @@ -1836,3 +1870,194 @@ describe("list terminals across directories", () => { ]); }); }); + +test.each([3000, 12050])( + "chunked SDK replay retains requested history for %s printed lines", + async (count) => { + const cwd = tmpCwd(); + tempDirs.push(cwd); + const created = await createTerminalInWorkspace(ctx.client, { + cwd, + options: { + command: "/bin/sh", + args: [ + "-lc", + `i=1; while [ $i -le ${count} ]; do printf '\\033[31mreplay-%04d界🙂 payload-abcdefghijklmnopqrstuvwxyz0123456789\\033[0m\\n' $i; i=$((i+1)); done; sleep 30`, + ], + }, + }); + const id = created.terminal!.id; + await waitForCondition( + async () => + (await ctx.client.captureTerminal(id)).lines + .join("\n") + .includes(`replay-${String(count).padStart(4, "0")}`), + 10000, + ); + const events: import("@getpaseo/client").TerminalStreamEvent[] = []; + let end!: () => void; + const completed = new Promise((resolve) => { + end = resolve; + }); + const subscription = ctx.client.observeTerminal( + id, + (event) => { + events.push(event); + if (event.type === "replay" && event.replay.type === "end") end(); + }, + { restore: { mode: "visible-snapshot", scrollbackLines: 10000 } }, + ); + try { + await subscription.ready; + await completed; + const replay = events.flatMap((event) => (event.type === "replay" ? [event.replay] : [])); + expect(replay[0]).toMatchObject({ + type: "begin", + retainedHistoryLines: count === 12050 ? 10000 : expect.any(Number), + }); + expect(replay.at(-1)).toEqual({ type: "end" }); + const chunks = replay.flatMap((event) => (event.type === "data" ? [event.data] : [])); + expect(chunks.every((chunk) => chunk.length <= 65536)).toBe(true); + const text = Buffer.concat(chunks).toString("utf8"); + const begin = replay[0]; + if (begin?.type !== "begin" || begin.rows === undefined) + throw new Error("Missing replay dimensions"); + expect(text.match(/replay-\d{4,5}/g)).toHaveLength(Math.min(count, 10000 + begin.rows - 1)); + expect(text).toContain(`replay-${String(count).padStart(4, "0")}界🙂`); + if (count === 3000) expect(text).toContain("replay-0001界🙂"); + else { + expect(text).not.toContain("replay-0001"); + expect(chunks.length).toBeGreaterThan(1); + } + } finally { + await subscription.release(); + } + }, + 30000, +); + +test.each([false, true])( + "default SDK observation replays a terminal without waiting for output (quietScreen=%s)", + async (quietScreen) => { + const cwd = tmpCwd(); + tempDirs.push(cwd); + const created = await createTerminalInWorkspace(ctx.client, { + cwd, + options: { + command: process.execPath, + args: [ + "-e", + `${quietScreen ? 'process.stdout.write("QUIET-SCREEN\\r\\n");' : ""} process.stdin.on("data", data => process.stdout.write("LIVE:" + data)); setInterval(() => {}, 1000);`, + ], + }, + }); + const id = created.terminal!.id; + if (quietScreen) + await waitForCondition( + async () => (await ctx.client.captureTerminal(id)).lines.join("").includes("QUIET-SCREEN"), + 10000, + ); + const events: import("@getpaseo/client").TerminalStreamEvent[] = []; + const observation = ctx.client.observeTerminal(id, (event) => events.push(event)); + try { + await observation.ready; + await waitForCondition( + () => events.some((event) => event.type === "replay" && event.replay.type === "end"), + 10000, + ); + const replay = events.flatMap((event) => (event.type === "replay" ? [event.replay] : [])); + const begin = replay[0]; + if (begin?.type !== "begin" || !begin.rows || !begin.cols) + throw new Error("Missing replay begin geometry"); + expect(replay.at(-1)).toEqual({ type: "end" }); + const terminal = new HeadlessTerminal({ + rows: begin.rows, + cols: begin.cols, + allowProposedApi: true, + }); + try { + for (const event of replay) + if (event.type === "data") + await new Promise((resolve) => terminal.write(event.data, resolve)); + const text = extractStateText(readHeadlessState(terminal)); + expect(text).toBe(quietScreen ? "QUIET-SCREEN" : ""); + } finally { + terminal.dispose(); + } + ctx.client.sendTerminalInput(id, { type: "input", data: "next\r" }); + await waitForCondition( + () => + events.some( + (event) => + event.type === "output" && new TextDecoder().decode(event.data).includes("LIVE:next"), + ), + 10000, + ); + } finally { + await observation.release(); + } + }, + 30000, +); + +test.each([false, true])( + "a paused terminal consumer catches up through ANSI replay instead of retaining every output frame (chunked=%s)", + async (chunked) => { + const { existsSync } = await import("node:fs"); + const cwd = tmpCwd(); + tempDirs.push(cwd); + const marker = path.join(cwd, "output-complete"); + const created = await createTerminalInWorkspace(ctx.client, { + cwd, + options: { + command: process.execPath, + args: [ + "-e", + ` + const fs = require('node:fs'); + process.stdin.setRawMode?.(true); + process.stdin.resume(); + process.stdin.once('data', () => { + const chunk = Buffer.alloc(256 * 1024, 120); + let remaining = 128; + const write = () => { + if (remaining-- === 0) { fs.writeFileSync(${JSON.stringify(marker)}, 'done'); return; } + process.stdout.write(chunk, write); + }; + write(); + }); + `, + ], + }, + }); + const ws = await connectRawWebSocket(ctx.daemon.port, chunked); + try { + const slot = await subscribeRawTerminal(ws, created.terminal!.id, "slow-terminal"); + await waitForRawBinaryFrame( + ws, + (frame) => + frame.slot === slot && + frame.opcode === + (chunked ? TerminalStreamOpcode.ReplayEnd : TerminalStreamOpcode.Restore), + ); + const capture = startTerminalOutputCapture(ws, slot); + try { + ws.pause(); + sendRawTerminalInput(ws, slot, "go\n"); + await waitForCondition(() => existsSync(marker), 20000); + ws.resume(); + await waitForCondition(() => capture.snapshotCount > 0, 10000); + expect(ws.readyState).toBe(WebSocket.OPEN); + expect(capture.frames.reduce((total, frame) => total + frame.payloadBytes, 0)).toBeLessThan( + 32 * 1024 * 1024, + ); + } finally { + ws.resume(); + capture.stop(); + } + } finally { + await closeWebSocket(ws); + } + }, + 40000, +); diff --git a/packages/server/src/server/daemon-instance.ts b/packages/server/src/server/daemon-instance.ts index 02f4446795..b3b07e7ff0 100644 --- a/packages/server/src/server/daemon-instance.ts +++ b/packages/server/src/server/daemon-instance.ts @@ -11,6 +11,7 @@ import { } from "./pid-lock.js"; import { daemonLaunchEnvironment } from "./config-environment.js"; import { readPersistedConfig } from "./persisted-config.js"; +import { reapTerminalWorkers } from "../terminal/terminal-worker-reaper.js"; import treeKill from "tree-kill"; const killTree = (pid: number, signal: string): Promise => new Promise((resolve, reject) => @@ -29,6 +30,10 @@ export { resolvePaseoHome } from "./paseo-home.js"; export { ensurePrivateDirectory } from "./private-files.js"; export { daemonLaunchEnvironment } from "./config-environment.js"; export { readLocalCredentialForTarget } from "./local-credential.js"; +export { + TerminalCleanupNotConfirmedError, + retryUnconfirmedTerminalCleanup, +} from "../terminal/terminal-worker-reaper.js"; export { isSamePidLock as isSameDaemonInstance, type PidLockInfo as DaemonInstance, @@ -97,11 +102,23 @@ async function requestInstanceStop( instance: PidLockInfo, options: { force?: boolean; + keepTerminals?: boolean; requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise; }, ) { let forced = false; let usedLifecycleRpc = false; + if (options.keepTerminals) { + // SIGTERM means "stop and close terminals"; keeping them always goes over the RPC. + if (!instance.listen || !options.requestShutdown) { + throw new DaemonInstanceError( + "STOP_NO_GRACEFUL_CHANNEL", + `PID ${instance.pid} for ${home} has no listen address, so it cannot be asked to keep its terminals.`, + ); + } + await options.requestShutdown({ ...instance, listen: instance.listen }); + return { forced, usedLifecycleRpc: true }; + } if (process.platform === "win32") { if (instance.listen && options.requestShutdown) { try { @@ -142,6 +159,8 @@ export async function stopDaemonInstance( options: { instance?: PidLockInfo; force?: boolean; + // Leave the terminal worker running so the next daemon adopts it. Not valid with force. + keepTerminals?: boolean; timeoutMs?: number; killTimeoutMs?: number; requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise; @@ -153,6 +172,7 @@ export async function stopDaemonInstance( usedLifecycleRpc: boolean; }> { const { timeoutMs = 15_000, killTimeoutMs = 3_000 } = options; + assertStopOptions(options); const deadline = Date.now() + timeoutMs; const instance = await getPidLockInfo(home); if (options.instance && instance && !isSamePidLock(instance, options.instance)) { @@ -164,6 +184,9 @@ export async function stopDaemonInstance( if (!instance || !isPidLockOwnerRunning(instance)) { if (instance) await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt }); + // Terminals a `stop --keep-terminals` left behind belong to no daemon now; a plain stop + // closes them. + await closeLeftoverTerminals(home, { keepTerminals: options.keepTerminals, forced: false }); return { action: "not_running", pid: instance?.pid ?? null, @@ -200,9 +223,29 @@ export async function stopDaemonInstance( `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 }); + // The supervisor already closed the terminals unless it was killed; this confirms it, and + // after --force (no graceful shutdown happened) does it at once. + await closeLeftoverTerminals(home, { keepTerminals: options.keepTerminals, forced }); return { action: "stopped", pid: instance.pid, forced, usedLifecycleRpc }; } +function assertStopOptions(options: { force?: boolean; keepTerminals?: boolean }): void { + if (options.force && options.keepTerminals) { + throw new DaemonInstanceError( + "INVALID_STOP_OPTIONS", + "--force kills terminals; it cannot be combined with --keep-terminals.", + ); + } +} + +async function closeLeftoverTerminals( + home: string, + options: { keepTerminals?: boolean; forced: boolean }, +): Promise { + if (options.keepTerminals) return; + await reapTerminalWorkers({ paseoHome: home, gracefulMs: options.forced ? 0 : undefined }); +} + export async function startDaemonInstance(input: { home: string; command: string; diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index bc5acbc404..8af60cd7bc 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -1,5 +1,6 @@ import { appendFileSync, mkdirSync } from "node:fs"; import path from "node:path"; +import { initializeEnvironmentProxy } from "./network/index.js"; import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js"; import { loadConfig } from "./config.js"; import { resolvePaseoHome } from "./paseo-home.js"; @@ -13,6 +14,7 @@ type SupervisorLifecycleMessage = | { type: "paseo:shutdown"; reason: string; + keepTerminals: boolean; } | { type: "paseo:ready"; @@ -22,6 +24,7 @@ type SupervisorLifecycleMessage = | { type: "paseo:restart"; reason?: string; + keepTerminals: boolean; }; interface BootstrapResult { @@ -130,6 +133,11 @@ function applyCliFlagOverrides(config: ReturnType): void { async function main() { const { paseoHome, logger, config } = bootstrapFromEnvironment(); + for (const variable of initializeEnvironmentProxy()) { + writeWorkerLifecycleLog(paseoHome, "Ignoring invalid or unsupported HTTP proxy variable", { + variable, + }); + } let daemon: Awaited> | null = null; let shutdownPromise: Promise | null = null; let exitHookInstalled = false; @@ -151,12 +159,15 @@ async function main() { options?: { reason?: string; successExitCode?: number; + keepTerminals?: boolean; }, ) => { const reason = options?.reason ?? `worker_received_${signal}`; + // Signals mean "stop and close terminals"; only an explicit keep request keeps them. + const keepTerminals = options?.keepTerminals === true; if (!shutdownPromise) { logger.info( - { signal, reason, ...getProcessDiagnostics() }, + { signal, reason, keepTerminals, ...getProcessDiagnostics() }, `${signal} received, shutting down gracefully...`, ); @@ -175,7 +186,7 @@ async function main() { clearTimeout(forceExit); return 1; } - await daemon.stop(); + await daemon.stop({ keepTerminals }); clearTimeout(forceExit); logger.info("Server closed"); return options?.successExitCode ?? 0; @@ -209,36 +220,53 @@ async function main() { }; const handleLifecycleIntent = (intent: DaemonLifecycleIntent) => { + const fields = { + clientId: intent.clientId, + requestId: intent.requestId, + reason: intent.reason, + keepTerminals: intent.keepTerminals, + }; if (intent.type === "shutdown") { - logger.warn( - { clientId: intent.clientId, requestId: intent.requestId, reason: intent.reason }, - "Shutdown requested via websocket", - ); - if (sendSupervisorLifecycleMessage({ type: "paseo:shutdown", reason: intent.reason })) { + logger.warn(fields, "Shutdown requested via websocket"); + const sent = sendSupervisorLifecycleMessage({ + type: "paseo:shutdown", + reason: intent.reason, + keepTerminals: intent.keepTerminals, + }); + recordLifecycleIntentDispatched(intent.type, fields); + if (sent) { return; } - beginShutdown("shutdown lifecycle intent", { reason: intent.reason }); + beginShutdown("shutdown lifecycle intent", { + reason: intent.reason, + keepTerminals: intent.keepTerminals, + }); return; } - logger.warn( - { clientId: intent.clientId, requestId: intent.requestId, reason: intent.reason }, - "Restart requested via websocket", - ); - if ( - sendSupervisorLifecycleMessage({ - type: "paseo:restart", - ...(intent.reason ? { reason: intent.reason } : {}), - }) - ) { + logger.warn(fields, "Restart requested via websocket"); + const sent = sendSupervisorLifecycleMessage({ + type: "paseo:restart", + ...(intent.reason ? { reason: intent.reason } : {}), + keepTerminals: intent.keepTerminals, + }); + recordLifecycleIntentDispatched(intent.type, fields); + if (sent) { return; } beginShutdown("restart lifecycle intent", { reason: intent.reason, successExitCode: 0, + keepTerminals: intent.keepTerminals, }); }; + // Written straight to daemon.log: the pino stream goes through the supervisor's pipe, which + // a paused or busy supervisor does not drain. Tests synchronize on this line. + const recordLifecycleIntentDispatched = (type: string, fields: Record) => { + writeWorkerLifecycleLog(paseoHome, "Lifecycle intent dispatched", { type, ...fields }); + }; + const installSupervisorLivenessGuard = () => { if (typeof process.send !== "function") { return; @@ -278,9 +306,10 @@ async function main() { return; } if (type === "paseo:graceful-shutdown") { - const reason = (message as { reason?: unknown }).reason; + const { reason, keepTerminals } = message as { reason?: unknown; keepTerminals?: unknown }; beginShutdown("Supervisor shutdown request", { reason: typeof reason === "string" ? reason : "supervisor_requested_shutdown", + keepTerminals: keepTerminals === true, }); } }); 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 975d6677b8..1d258553ca 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.test.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.test.ts @@ -1,9 +1,12 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import pino from "pino"; import { DictationStreamManager } from "./dictation-stream-manager.js"; -import { PersistedConfigSchema } from "../persisted-config.js"; +import { loadPersistedConfig } from "../persisted-config.js"; import { resolveSpeechConfig } from "../speech/speech-config-resolver.js"; import type { SpeechToTextProvider, @@ -131,12 +134,18 @@ describe("DictationStreamManager (provider-agnostic provider)", () => { env?: NodeJS.ProcessEnv; persisted?: unknown; }): string { - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: params.env ?? ({} as NodeJS.ProcessEnv), - persisted: PersistedConfigSchema.parse(params.persisted ?? {}), - }); - return result.speech.sttLanguages.dictation; + const home = mkdtempSync(path.join(tmpdir(), "stt-language-")); + try { + writeFileSync(path.join(home, "config.json"), JSON.stringify(params.persisted ?? {})); + const result = resolveSpeechConfig({ + paseoHome: home, + env: params.env ?? {}, + persisted: loadPersistedConfig(home), + }); + return result.speech.sttLanguages.dictation; + } finally { + rmSync(home, { recursive: true, force: true }); + } } async function startWithResolvedDictationLanguage(params: { diff --git a/packages/server/src/server/git-observation-paths.ts b/packages/server/src/server/git-observation-paths.ts new file mode 100644 index 0000000000..1ef0a4e571 --- /dev/null +++ b/packages/server/src/server/git-observation-paths.ts @@ -0,0 +1,106 @@ +import { realpath } from "node:fs"; +import { readlink } from "node:fs/promises"; +import { promisify } from "node:util"; +import pLimit from "p-limit"; +import { dirname, resolve as resolvePath, sep } from "node:path"; +import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js"; + +const nativeRealpath = promisify(realpath.native); +const jsRealpath = promisify(realpath); +const identityIo = pLimit(2); + +/** A subscription owns its lexical namespace, including the OS's real-root alias. */ +export class GitObservationPaths { + private constructor( + private readonly root: string, + private readonly aliases: string[], + private readonly windows: boolean, + ) {} + + static async create(root: string): Promise { + const windows = looksLikeDefiniteWindowsPath(root); + const normalizePath = (value: string) => + windows + ? normalizePathPreservingCase(value, true).replaceAll("\\", "/").toLowerCase() + : normalizePathPreservingCase(value, false); + const aliases = new Set([normalizePath(root)]); + // Sequential: foreign paths must not fan out into the shared libuv pool. + for (const resolve of [nativeRealpath, jsRealpath]) { + try { + aliases.add(normalizePath(await identityIo(() => resolve(root)))); + } catch { + /* Missing roots retain lexical identity. */ + } + } + // A link may name /var/... while realpath returns /private/var/... on macOS. + // Retain literal targets too, including chains, without following links in callbacks. + let current = root; + const visited = new Set(); + for (let remaining = 40; remaining > 0; remaining--) { + const alias = normalizePath(current); + if (visited.has(alias)) break; + visited.add(alias); + aliases.add(alias); + try { + const target = await identityIo(() => readlink(current)); + current = resolvePath(dirname(current), target); + } catch { + break; + } + } + return new GitObservationPaths(root, [...aliases], windows); + } + + relative(candidate: string): string | null { + const normalized = this.normalizeEventPath(candidate); + const comparable = this.windows ? normalized.toLowerCase() : normalized; + for (const alias of this.aliases) { + if (comparable === alias) return ""; + const prefix = alias.endsWith("/") ? alias : `${alias}/`; + if (comparable.startsWith(prefix)) { + // Slice the original spelling, preserving case in ref names on Windows. + return normalized.slice(prefix.length); + } + } + return null; + } + + // Compile excluded subtrees in every established root namespace. The event callback + // only compares strings; neither an ignored root nor an event needs another realpath. + createEventFilter(excludedRoots: Iterable): (candidate: string) => boolean { + const excluded: string[] = []; + for (const root of excludedRoots) { + const relative = this.relative(root); + if (relative === null) continue; + for (const alias of this.aliases) { + const subtree = relative ? `${alias}/${relative}` : alias; + excluded.push(this.windows ? subtree.toLowerCase() : subtree); + } + } + const prefixes = excluded.map((root) => `${root}/`); + return (candidate) => { + const normalized = this.normalizeEventPath(candidate); + const comparable = this.windows ? normalized.toLowerCase() : normalized; + if (this.aliases.includes(comparable)) return false; + for (let i = 0; i < excluded.length; i++) { + if (comparable === excluded[i] || comparable.startsWith(prefixes[i])) return false; + } + return true; + }; + } + + private normalizeEventPath(candidate: string): string { + if (this.windows) return normalizePathPreservingCase(candidate, true).replaceAll("\\", "/"); + if (candidate.includes("/.") || candidate.includes("//") || candidate.endsWith("/")) + return normalizePathPreservingCase(candidate, false); + return candidate; + } + + matches = (candidate: string): boolean => this.relative(candidate) === ""; + + toWatchPath(candidate: string): string { + const relative = this.relative(candidate); + if (relative === null) return candidate; + return relative ? `${this.root}${sep}${relative}` : this.root; + } +} 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 88f376b3ba..266c55e196 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -248,7 +248,7 @@ test("ordinary Hub requests survive daemon restart and restore an archived works }), ).toMatchObject({ payload: { agentId, accepted: true } }); expect(hub.providerPromptTexts().filter((text) => text === "follow up")).toHaveLength(1); -}, 20_000); +}); test("Hub completes the standard hello before rejecting a second hello", async () => { const hub = await launchRelationship(); diff --git a/packages/server/src/server/hub/relationship-remote.ts b/packages/server/src/server/hub/relationship-remote.ts index 9c6f88a63d..6427b71fa6 100644 --- a/packages/server/src/server/hub/relationship-remote.ts +++ b/packages/server/src/server/hub/relationship-remote.ts @@ -1,5 +1,6 @@ import { WebSocket } from "ws"; import { z } from "zod"; +import { webSocketProxyAgent } from "../network/index.js"; import type { WebSocketLike } from "../websocket-server.js"; const SOCKET_PING_INTERVAL_MS = 10_000; @@ -166,6 +167,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote { openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection { let sessionProtocol: "legacy" | "session-v1" = "legacy"; const socket = new WebSocket(input.webSocketUrl, { + agent: webSocketProxyAgent(input.webSocketUrl), handshakeTimeout: this.requestTimeoutMs, headers: { authorization: `Bearer ${input.credential}`, diff --git a/packages/server/src/server/managed-processes/managed-processes.test.ts b/packages/server/src/server/managed-processes/managed-processes.test.ts index e247db027f..b4be4d16bd 100644 --- a/packages/server/src/server/managed-processes/managed-processes.test.ts +++ b/packages/server/src/server/managed-processes/managed-processes.test.ts @@ -12,6 +12,7 @@ import { type ManagedProcessInspection, type ManagedProcessSnapshot, type ManagedProcessTable, + windowsPowerShellPath, } from "./managed-processes.js"; import { spawnProcess } from "../../utils/spawn.js"; import { @@ -333,7 +334,7 @@ describe("system managed process table", () => { }); expect(commandRunner.commands).toEqual([ { - command: "powershell.exe", + command: windowsPowerShellPath(), args: [ "-NoProfile", "-NonInteractive", @@ -345,6 +346,17 @@ describe("system managed process table", () => { }); }); +describe("windowsPowerShellPath", () => { + test("names Windows PowerShell under SystemRoot, whatever PATH holds", () => { + expect(windowsPowerShellPath({ SystemRoot: "D:\\Win", PATH: "" })).toBe( + "D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + expect(windowsPowerShellPath({})).toBe( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + }); +}); + class FakeProcessTable implements ManagedProcessTable { private readonly snapshots: Map; private readonly errorPids: Set; diff --git a/packages/server/src/server/managed-processes/managed-processes.ts b/packages/server/src/server/managed-processes/managed-processes.ts index 0031080c67..43c1b3c810 100644 --- a/packages/server/src/server/managed-processes/managed-processes.ts +++ b/packages/server/src/server/managed-processes/managed-processes.ts @@ -7,6 +7,20 @@ import { writeJsonFileAtomic } from "../atomic-file.js"; import { execCommand } from "../../utils/spawn.js"; import type { ProcessTerminator, TreeKillTarget } from "../../utils/tree-kill.js"; +/** + * Windows PowerShell by its install path. Process identity must not depend on the caller's + * PATH: a daemon or terminal worker can run with a PATH that does not reach System32. + */ +export function windowsPowerShellPath(env: NodeJS.ProcessEnv = process.env): string { + return path.win32.join( + env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + const MANAGED_PROCESS_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000; const MANAGED_PROCESS_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; const MANAGED_PROCESS_EXIT_POLL_INTERVAL_MS = 50; @@ -183,7 +197,7 @@ class SystemManagedProcessTable implements ManagedProcessTable { `$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}';`, "if ($process) { $process | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress }", ].join(" "); - const { stdout } = await this.commandRunner.exec("powershell.exe", [ + const { stdout } = await this.commandRunner.exec(windowsPowerShellPath(), [ "-NoProfile", "-NonInteractive", "-Command", diff --git a/packages/server/src/server/network/index.ts b/packages/server/src/server/network/index.ts new file mode 100644 index 0000000000..46565d801a --- /dev/null +++ b/packages/server/src/server/network/index.ts @@ -0,0 +1,116 @@ +import type { Agent } from "node:http"; +import { EnvHttpProxyAgent, Pool, setGlobalDispatcher } from "undici"; +import { HttpsProxyAgent } from "https-proxy-agent"; + +interface ProxyEnvironment { + httpProxy: string; + httpsProxy: string; + noProxy: string; +} + +let environment = readEnvironment(process.env, process.platform).environment; + +function readEnvironment( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): { + environment: ProxyEnvironment; + ignored: string[]; +} { + // process.env lookups are case-insensitive on Windows. Read each actual entry once. + const values = new Map( + Object.entries(env).map(([name, value]) => [ + platform === "win32" ? name.toLowerCase() : name, + value, + ]), + ); + const ignored: string[] = []; + function proxy(variable: string): string | undefined { + const value = values.get(variable); + if (value === undefined) return undefined; + if (!value.trim()) return undefined; + try { + const url = new URL(value.includes("://") ? value : `http://${value}`); + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new Error("Unsupported proxy protocol"); + decodeURIComponent(url.username); + decodeURIComponent(url.password); + return url.href; + } catch { + ignored.push(variable); + return undefined; + } + } + const httpProxy = proxy("http_proxy") ?? proxy("HTTP_PROXY") ?? ""; + return { + environment: { + httpProxy, + httpsProxy: proxy("https_proxy") ?? proxy("HTTPS_PROXY") ?? httpProxy, + noProxy: values.get("no_proxy") ?? values.get("NO_PROXY") ?? "", + }, + ignored, + }; +} + +function bypassList(noProxy: string): string { + return noProxy.split(/[,\s]+/).includes("*") ? "*" : `${noProxy},localhost,127.0.0.1,[::1],::1`; +} + +function proxyClient(origin: URL, options: object) { + const pool = new Pool(origin, options); + if (!origin.username && !origin.password) return pool; + const credentials = `${decodeURIComponent(origin.username)}:${decodeURIComponent(origin.password)}`; + const authorization = `Basic ${Buffer.from(credentials).toString("base64")}`; + // This pool only sends CONNECT requests to this proxy, never requests to the origin. + // undici's URL auth requires both fields; HTTP Basic also permits either field to be empty. + return pool.compose( + (dispatch) => (request, handler) => + dispatch( + { + ...request, + headers: { ...request.headers, "proxy-authorization": authorization }, + }, + handler, + ), + ); +} + +/** Install once at startup; return ignored variable names for logging without exposing credentials. */ +export function initializeEnvironmentProxy( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string[] { + const resolved = readEnvironment(env, platform); + environment = resolved.environment; + setGlobalDispatcher( + new EnvHttpProxyAgent({ + httpProxy: environment.httpProxy, + httpsProxy: environment.httpsProxy, + noProxy: bypassList(environment.noProxy), + clientFactory: proxyClient, + }), + ); + return resolved.ignored; +} + +/** ws uses Node HTTP agents rather than the fetch dispatcher. */ +export function webSocketProxyAgent(address: string): Agent | undefined { + const url = new URL(address); + const hostname = url.hostname.toLowerCase(); + const port = url.port || (url.protocol === "wss:" ? "443" : "80"); + const entries = bypassList(environment.noProxy) + .toLowerCase() + .split(/[,\s]+/); + if ( + entries.some((entry) => { + if (entry === "*") return true; + const match = entry.match(/^(.+):(\d+)$/); + if (match && Number(match[2]) !== Number(port)) return false; + const host = (match ? match[1]! : entry).replace(/^\*?\./, ""); + return host !== "" && (hostname === host || hostname.endsWith(`.${host}`)); + }) + ) + return undefined; + const proxy = url.protocol === "wss:" ? environment.httpsProxy : environment.httpProxy; + return proxy ? new HttpsProxyAgent(proxy) : undefined; +} diff --git a/packages/server/src/server/network/network.test.ts b/packages/server/src/server/network/network.test.ts new file mode 100644 index 0000000000..88e7d61aa6 --- /dev/null +++ b/packages/server/src/server/network/network.test.ts @@ -0,0 +1,341 @@ +import { fork } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer, type Server } from "node:http"; +import { once } from "node:events"; +import { connect } from "node:net"; +import { afterEach, expect, test } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; +import { getGlobalDispatcher, setGlobalDispatcher } from "undici"; +import { initializeEnvironmentProxy, webSocketProxyAgent } from "./index.js"; +import { ConnectedHosts } from "../connected-hosts/index.js"; +const original = getGlobalDispatcher(); +const servers: Server[] = []; +afterEach(async () => { + const installed = getGlobalDispatcher(); + setGlobalDispatcher(original); + if (installed !== original) await installed.close(); + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); +}); +async function listen(server: Server) { + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + return `127.0.0.1:${address.port}`; +} +async function proxy() { + const tunnels: string[] = []; + const server = createServer(); + server.on("connect", (request, socket) => { + tunnels.push(request.url!); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + return { url: `http://${await listen(server)}`, tunnels }; +} +test("loopback fetch and WebSockets bypass HTTP_PROXY with empty NO_PROXY", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, NO_PROXY: "" }); + const server = createServer((_, response) => response.end("direct")); + const sockets = new WebSocketServer({ server }); + sockets.on("connection", (socket) => socket.send("direct")); + const address = await listen(server); + expect(await (await fetch(`http://${address}`)).text()).toBe("direct"); + const socket = new WebSocket(`ws://${address}`, { + agent: webSocketProxyAgent(`ws://${address}`), + }); + expect((await once(socket, "message"))[0].toString()).toBe("direct"); + socket.close(); + await once(socket, "close"); + await new Promise((resolve) => sockets.close(() => resolve())); + for (const host of ["localhost", "127.0.0.1", "[::1]"]) + expect(webSocketProxyAgent(`wss://${host}:1234`)).toBeUndefined(); + expect(recorder.tunnels).toEqual([]); +}); +test("fetch and WebSockets send non-loopback traffic through CONNECT", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ http_proxy: recorder.url }); + await expect( + fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect( + recorder.tunnels.filter((host) => host === "vendor.invalid:443").length, + ).toBeGreaterThanOrEqual(2); +}); +test("NO_PROXY and absent variables keep matching WebSockets direct", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ + HTTPS_PROXY: recorder.url, + NO_PROXY: "vendor.invalid,.example.invalid:443", + }); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined(); + await expect( + fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(webSocketProxyAgent("wss://sub.example.invalid")).toBeUndefined(); + expect(webSocketProxyAgent("wss://example.invalid:444")).toBeDefined(); + initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, no_proxy: "*" }); + expect(webSocketProxyAgent("ws://vendor.invalid")).toBeUndefined(); + initializeEnvironmentProxy({}); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined(); + const address = await listen(createServer((_, response) => response.end("direct"))); + expect(await (await fetch(`http://${address}`)).text()).toBe("direct"); + expect(recorder.tunnels).toEqual([]); +}); + +test.each(["127.0.0.1", "localhost"])( + "scheme-less %s proxies work for fetch and WebSockets", + async (host) => { + const recorder = await proxy(); + const address = new URL(recorder.url); + initializeEnvironmentProxy({ https_proxy: `${host}:${address.port}` }); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect(recorder.tunnels).toContain("vendor.invalid:443"); + }, +); + +test.each([ + "http://[", + "http://user%zz:secret@proxy.corp", + "socks5://127.0.0.1:7890", + "ftp://proxy.corp:8080", +])("ignores invalid or unsupported proxy %s without stopping startup", async (value) => { + const recorder = await proxy(); + expect(initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, https_proxy: value })).toEqual([ + "https_proxy", + ]); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]); +}); + +test("daemon starts with an ignored proxy and writes one redacted warning to daemon.log", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "daemon-invalid-proxy-")); + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !["http_proxy", "https_proxy"].includes(key.toLowerCase()), + ), + ); + const worker = fork(fileURLToPath(new URL("../daemon-worker.ts", import.meta.url)), [], { + execArgv: ["--import", "tsx"], + silent: true, + env: { + ...env, + PASEO_HOME: home, + HOME: home, + USERPROFILE: home, + PASEO_LISTEN: "127.0.0.1:0", + PASEO_RELAY_ENABLED: "0", + https_proxy: "socks5://private:secret@127.0.0.1:7890", + }, + }); + worker.stdout?.resume(); + let stderr = ""; + worker.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + try { + await new Promise((resolve, reject) => { + worker.on("message", (message) => { + if ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === "paseo:ready" + ) + resolve(); + }); + worker.on("exit", (code) => reject(new Error(`Worker exited ${code}: ${stderr}`))); + }); + const log = await readFile(path.join(home, "daemon.log"), "utf8"); + const warnings = log + .split("\n") + .filter((line) => line.includes("Ignoring invalid or unsupported HTTP proxy variable")); + expect(warnings).toHaveLength(1); + expect(JSON.parse(warnings[0]!).variable).toBe("https_proxy"); + expect(warnings[0]).not.toContain("secret"); + } finally { + if (worker.exitCode === null && worker.signalCode === null) { + const exited = once(worker, "exit"); + worker.kill("SIGTERM"); + await exited; + } + await rm(home, { recursive: true, force: true }); + } +}, 60_000); + +test("accepts a scheme-less corporate proxy hostname", () => { + expect(initializeEnvironmentProxy({ https_proxy: "proxy.corp:8080" })).toEqual([]); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); +}); + +test("an ignored lowercase variable falls back to the uppercase variable", async () => { + const recorder = await proxy(); + expect( + initializeEnvironmentProxy( + { + https_proxy: "socks5://proxy.invalid", + HTTPS_PROXY: recorder.url, + }, + "linux", + ), + ).toEqual(["https_proxy"]); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]); +}); + +test("case-insensitive environment access reports a variable only once", () => { + const env = new Proxy( + { https_proxy: "socks5://127.0.0.1:7890" }, + { + get(target, key) { + return Reflect.get(target, typeof key === "string" ? key.toLowerCase() : key); + }, + }, + ); + expect(initializeEnvironmentProxy(env)).toEqual(["https_proxy"]); +}); + +test("connected-host links send remote WebSockets through the environment proxy", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url }); + const home = await mkdtemp(path.join(os.tmpdir(), "connected-host-proxy-")); + const hosts = new ConnectedHosts(home, { serverId: "self", label: "Self" }); + try { + await hosts.add( + { connection: "wss://remote.invalid/ws" }, + { sourceId: "test", manageSources: true }, + ); + await expect.poll(() => recorder.tunnels).toContain("remote.invalid:443"); + } finally { + await hosts.close(); + await rm(home, { recursive: true, force: true }); + } +}); + +test.each(["corp.internal,*", "vendor.invalid:0443"])( + "fetch and WebSockets share NO_PROXY=%s", + async (noProxy) => { + const recorder = await proxy(); + initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url, NO_PROXY: noProxy }); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(recorder.tunnels).toEqual([]); + }, +); + +test("Windows mixed-case environment names route and bypass both transports", async () => { + const recorder = await proxy(); + // Windows preserves spelling but treats environment names without regard to case. + initializeEnvironmentProxy({ Https_Proxy: recorder.url, No_Proxy: "other.invalid" }, "win32"); + expect(webSocketProxyAgent("wss://other.invalid")).toBeUndefined(); + await expect( + fetch("https://other.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]); +}); + +test("POSIX mixed-case names do not override standard proxy names", async () => { + const recorder = await proxy(); + initializeEnvironmentProxy( + { Https_Proxy: recorder.url, HTTPS_PROXY: recorder.url, No_Proxy: "*" }, + "linux", + ); + expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined(); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + expect(recorder.tunnels).toEqual(["vendor.invalid:443"]); +}); + +test("proxy credentials stay on CONNECT and do not reach the HTTP origin", async () => { + let originAuthorization: string | undefined; + const origin = createServer((request, response) => { + originAuthorization = request.headers["proxy-authorization"]; + response.end("through proxy"); + }); + const originAddress = new URL(`http://${await listen(origin)}`); + const authorizations: (string | undefined)[] = []; + const server = createServer(); + server.on("connect", (request, socket, head) => { + authorizations.push(request.headers["proxy-authorization"]); + const upstream = connect(Number(originAddress.port), "127.0.0.1", () => { + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length) upstream.write(head); + socket.pipe(upstream).pipe(socket); + }); + socket.on("error", () => upstream.destroy()); + upstream.on("error", () => socket.destroy()); + socket.on("close", () => upstream.destroy()); + }); + initializeEnvironmentProxy({ HTTP_PROXY: `http://user:@${await listen(server)}` }); + const response = await fetch("http://vendor.invalid", { signal: AbortSignal.timeout(3000) }); + expect(await response.text()).toBe("through proxy"); + expect(authorizations).toEqual([`Basic ${Buffer.from("user:").toString("base64")}`]); + expect(originAuthorization).toBeUndefined(); +}); + +test.each(["user:@", ":secret@", "user:secret@"])( + "fetch and WebSockets authenticate proxy credentials %s", + async (credentials) => { + const authorizations: (string | undefined)[] = []; + const server = createServer(); + server.on("connect", (request, socket) => { + authorizations.push(request.headers["proxy-authorization"]); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + initializeEnvironmentProxy({ HTTPS_PROXY: `http://${credentials}${await listen(server)}` }); + await expect( + fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }), + ).rejects.toThrow(); + const socket = new WebSocket("wss://vendor.invalid", { + agent: webSocketProxyAgent("wss://vendor.invalid"), + }); + await once(socket, "error"); + const expected = `Basic ${Buffer.from(credentials.slice(0, -1)).toString("base64")}`; + expect(authorizations).toEqual([expected, expected]); + }, +); diff --git a/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts b/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts index 5a42fd0dac..50fb071fef 100644 --- a/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts +++ b/packages/server/src/server/orchestration-skills/internal/selection-store.test.ts @@ -57,7 +57,7 @@ describe("daemon agent skill selection", () => { it("does not expose selection through the generic config patch path", async () => { const { config, root, store } = await createStore(); - config.patch({ + await config.patch({ skills: { selection: { mode: "custom", skills: ["paseo"] } }, } as MutableDaemonConfigPatch); diff --git a/packages/server/src/server/orchestration-skills/internal/selection-store.ts b/packages/server/src/server/orchestration-skills/internal/selection-store.ts index 6fa00af6e6..a3f7f95569 100644 --- a/packages/server/src/server/orchestration-skills/internal/selection-store.ts +++ b/packages/server/src/server/orchestration-skills/internal/selection-store.ts @@ -43,7 +43,7 @@ export function createSkillSelectionStore( }, async set(selection) { const parsed = coerceSkillSelection(selection); - configStore.setAgentSkillSelection(parsed); + await configStore.setAgentSkillSelection(parsed); return parsed; }, async isSet() { diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts index 68437daf70..fc73e44db4 100644 --- a/packages/server/src/server/owned-subscriptions.e2e.test.ts +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -1,7 +1,8 @@ import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; import { createPaseoApi } from "@getpaseo/client"; +import type { AgentManager } from "./agent/agent-manager.js"; import { execFileSync } from "node:child_process"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; import type { z } from "zod"; @@ -71,15 +72,18 @@ class SubscriptionPeer { 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, - ), + .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, + ), + // Diagnostics invoke external tools with their own multi-second deadlines. + { timeout: 10_000 }, ) .toBe(true); } @@ -119,6 +123,16 @@ class SubscriptionPeer { } } +function removedAgentIds(peer: SubscriptionPeer): string[] { + return peer.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "agent_update" && + frame.message.payload.kind === "remove" + ? [frame.message.payload.agentId] + : [], + ); +} + 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[] = []; @@ -225,6 +239,96 @@ test("combined filtered agent lists create independent server-owned observations } }); +test.each([ + { + state: "live", + prepare: async (_manager: AgentManager, _agentId: string) => {}, + }, + { + state: "stored-only", + prepare: (manager: AgentManager, agentId: string) => manager.closeAgent(agentId), + }, +])( + "$state agent deletion reaches directory observers across logical sessions without broadcasting its reply", + async ({ prepare }) => { + 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 removed = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + const survivor = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + const sentinel = path.join(daemon.staticDir, "keep-project-file.txt"); + await writeFile(sentinel, "keep this project file\n"); + await prepare(daemon.daemon.agentManager, removed.id); + const requester = await SubscriptionPeer.connect(daemon.port, "deletion-origin"); + const sibling = await SubscriptionPeer.connect(daemon.port, "deletion-origin"); + const observer = await SubscriptionPeer.connect(daemon.port, "deletion-observer"); + const legacy = await SubscriptionPeer.connect(daemon.port, "deletion-legacy", { + owned_subscriptions: false, + }); + peers.push(requester, sibling, observer, legacy); + for (const [index, peer] of peers.entries()) { + await peer.request({ + type: "fetch_agents_request", + requestId: `directory-${index}`, + subscribe: {}, + }); + const entries = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fetch_agents_response" + ? frame.message.payload.entries.map((entry) => entry.agent.id) + : [], + ); + expect(entries.sort()).toEqual([removed.id, survivor.id].sort()); + peer.frames.length = 0; + } + await requester.request({ + type: "delete_agent_request", + requestId: "delete-observed-agent", + agentId: removed.id, + }); + await expect + .poll(() => peers.map(removedAgentIds)) + .toEqual([[removed.id], [removed.id], [removed.id], [removed.id]]); + const replies = peers.map((peer) => + peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_deleted" + ? [frame.message.payload] + : [], + ), + ); + expect(replies).toEqual([ + [{ agentId: removed.id, requestId: "delete-observed-agent" }], + [], + [], + [], + ]); + expect(await daemon.daemon.agentStorage.get(removed.id)).toBeNull(); + expect((await daemon.daemon.agentStorage.get(survivor.id))?.id).toBe(survivor.id); + expect(await readFile(sentinel, "utf8")).toBe("keep this project file\n"); + } finally { + for (const peer of peers) 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" }); @@ -679,7 +783,8 @@ test("same-workspace terminal directory observers do not share a teardown slot", peer.frames.length = 0; const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, { workspaceId: workspace.id, - command: "/bin/cat", + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], }); expect(result.error).toBe(null); await expect @@ -717,85 +822,100 @@ test("same-workspace terminal directory observers do not share a teardown slot", } }); -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", +test.each([false, true])( + "terminal output has separate server IDs and binary slots, including in a shared session (chunked=%s)", + async (chunked) => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", }); - 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 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: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], }); - } - 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( - () => + expect(result.error).toBe(null); + const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", { + terminal_chunked_replay: chunked, + }); + peers.push(idle); + const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session", { + terminal_chunked_replay: chunked, + }); + 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.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(); - } -}); + .filter( + (frame) => + frame?.opcode === + (chunked ? TerminalStreamOpcode.ReplayEnd : TerminalStreamOpcode.Restore), + ) + .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 }); @@ -1423,7 +1543,7 @@ test("request outcomes for import and attention reach only their requesting sock }); 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` }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); try { await admin.connect(); await requester.request({ @@ -1462,7 +1582,7 @@ test("request outcomes for import and attention reach only their requesting sock }), ); const agent = await admin.createAgent({ - provider: "codex", + provider: "mock", cwd: daemon.staticDir, title: "Attention", }); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 66740d2ae2..1579f92d5f 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -548,7 +548,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex ); expect(created.worktree.branchName).toBe("dazzling-yak"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "pending", @@ -575,7 +575,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex branchName: "renamed-from-agent-context", }); expect(branchAfterFirst).toBe("renamed-from-agent-context"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", @@ -762,7 +762,7 @@ test("leaves the branch alone when generated branch text is invalid", async () = .toString() .trim(), ).toBe("dazzling-yak"); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", @@ -794,7 +794,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename // baseRefName must be the repo's default branch, not the checked-out branch // itself — otherwise the diff panel compares the branch against itself and // always shows no changes. - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, baseRefName: "main", }); @@ -831,7 +831,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren createDeps(), ); - expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, baseRefName: "main", }); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 61e00723d0..1205554d4a 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,3 +1,4 @@ +import type { PluginLifecycle } from "./plugins/lifecycle/index.js"; import { stat } from "node:fs/promises"; import { resolve } from "node:path"; @@ -59,6 +60,7 @@ export interface AttemptFirstAgentBranchAutoNameResult { } export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { + lifecycle?: Pick; workspaceGitService: WorkspaceGitService; workspaceProvisioning: Pick; } @@ -77,7 +79,16 @@ async function createPaseoWorktreeWithPriority( const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); const createdWorktree = await createWorktreeCore(input, deps); try { - maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); + if (createdWorktree.created) { + await deps.lifecycle?.wait("worktree.create.after", { + worktree: { + path: createdWorktree.worktree.worktreePath, + branch: createdWorktree.worktree.branchName, + sourceCheckout: workspaceCwdPlan.sourceCheckout, + }, + }); + } + await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({ relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, targetWorktreePath: createdWorktree.worktree.worktreePath, @@ -155,7 +166,7 @@ async function isDirectory(targetPath: string): Promise { async function planWorkspaceCwdForWorktree( inputCwd: string, workspaceGitService: Pick, -): Promise<{ inputCwd: string; relativeWorkspaceCwd: string }> { +): Promise<{ inputCwd: string; relativeWorkspaceCwd: string; sourceCheckout: string }> { const normalizedInputCwd = resolve(inputCwd); const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd); const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd; @@ -163,7 +174,7 @@ async function planWorkspaceCwdForWorktree( if (relativeWorkspaceCwd === null) { throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`); } - return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd }; + return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd, sourceCheckout: sourceWorktreePath }; } export async function attemptFirstAgentBranchAutoName(options: { @@ -182,9 +193,9 @@ export async function attemptFirstAgentBranchAutoName(options: { return { attempted: false, renamed: false, branchName: null }; } - let metadata: ReturnType; + let metadata: Awaited>; try { - metadata = readPaseoWorktreeMetadata(options.cwd); + metadata = await readPaseoWorktreeMetadata(options.cwd); } catch { return { attempted: false, renamed: false, branchName: null }; } @@ -199,11 +210,11 @@ export async function attemptFirstAgentBranchAutoName(options: { const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch; const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName; if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) { - markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); + await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); return { attempted: true, renamed: false, branchName: null }; } - markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); + await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd); const branchName = await options.generateBranchNameFromContext({ cwd: options.cwd, @@ -264,15 +275,15 @@ async function findAvailableBranchName(options: { return null; } -function maybeMarkFirstAgentBranchAutoNameEligible(options: { +async function maybeMarkFirstAgentBranchAutoNameEligible(options: { createdWorktree: Awaited>; -}): void { +}): Promise { const { createdWorktree } = options; if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") { return; } - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, { placeholderBranchName: createdWorktree.worktree.branchName, }); } diff --git a/packages/server/src/server/persisted-config-edit.test.ts b/packages/server/src/server/persisted-config-edit.test.ts index 4b4bf4c0db..c838601255 100644 --- a/packages/server/src/server/persisted-config-edit.test.ts +++ b/packages/server/src/server/persisted-config-edit.test.ts @@ -43,3 +43,19 @@ test("configuration edits validate before writing and preserve unrelated setting await rm(root, { recursive: true, force: true }); } }); + +test("configuration paths edit fields inside speech provider variants", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-speech-edit-")); + try { + editPersistedConfig(home, "features.voiceMode.stt", { value: { provider: "openai" } }); + editPersistedConfig(home, "features.voiceMode.stt.apiKey", { value: "site-secret" }); + const config = readPersistedConfig(home); + expect(getPersistedConfigValue(config, "features.voiceMode.stt.apiKey")).toBe("site-secret"); + editPersistedConfig(home, "features.voiceMode.stt.apiKey", { unset: true }); + expect( + getPersistedConfigValue(readPersistedConfig(home), "features.voiceMode.stt.apiKey"), + ).toBeUndefined(); + } finally { + await rm(home, { 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 6b23a6f365..fb38e41591 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -1,3 +1,4 @@ +import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; import { chmodSync, lstatSync, @@ -15,9 +16,11 @@ import { describe, expect, test } from "vitest"; import { loadPersistedConfig, + loadPersistedConfigAsync, PersistedConfigSchema, readPersistedConfig, savePersistedConfig, + savePersistedConfigAsync, } from "./persisted-config.js"; import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js"; @@ -149,26 +152,33 @@ describe("PersistedConfigSchema worktrees config", () => { }); describe("PersistedConfigSchema provider credentials", () => { - test("accepts separate OpenAI STT and TTS credentials", () => { + test("accepts separate inline STT and TTS credentials", () => { const parsed = PersistedConfigSchema.parse({ - providers: { - openai: { + features: { + voiceMode: { stt: { + provider: "openai", apiKey: " stt-secret ", baseUrl: " https://stt.example.com/v1 ", }, tts: { + provider: "openai", apiKey: " tts-secret ", baseUrl: " https://tts.example.com/v1 ", }, }, }, }); - - expect(parsed.providers?.openai?.stt?.apiKey).toBe("stt-secret"); - expect(parsed.providers?.openai?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(parsed.providers?.openai?.tts?.apiKey).toBe("tts-secret"); - expect(parsed.providers?.openai?.tts?.baseUrl).toBe("https://tts.example.com/v1"); + expect(parsed.features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "stt-secret", + baseUrl: "https://stt.example.com/v1", + }); + expect(parsed.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "tts-secret", + baseUrl: "https://tts.example.com/v1", + }); }); }); @@ -660,19 +670,21 @@ describe("PersistedConfigSchema voice mode config", () => { features: { dictation: { stt: { + provider: "openai", language: " fr ", }, }, voiceMode: { stt: { + provider: "openai", language: " de ", }, }, }, }); - expect(parsed.features?.dictation?.stt?.language).toBe("fr"); - expect(parsed.features?.voiceMode?.stt?.language).toBe("de"); + expect(parsed.features?.dictation?.stt).toMatchObject({ language: "fr" }); + expect(parsed.features?.voiceMode?.stt).toMatchObject({ language: "de" }); }); test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => { @@ -685,7 +697,7 @@ describe("PersistedConfigSchema voice mode config", () => { }); expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0"); - expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden"); + expect(parsed.features?.voiceMode?.tts).toMatchObject({ voice: "aiden" }); }); }); @@ -754,10 +766,7 @@ describe("loadPersistedConfig", () => { const config = loadPersistedConfig(home); - expect(config.providers?.openai?.apiKey).toBe("global-key"); - expect((config.providers?.openai as Record)?.voice).toBeUndefined(); - expect(config.providers?.openai?.stt).toBeUndefined(); - expect(config.providers?.openai?.tts).toBeUndefined(); + expect(config.providers).not.toHaveProperty("openai"); } finally { rmSync(home, { recursive: true, force: true }); } @@ -820,11 +829,14 @@ describe("readPersistedConfig with an unreadable config.json", () => { }); }); -describe.skipIf(process.platform === "win32")("persisted config file permissions", () => { - test("initializes config.json with private permissions", () => { +describe.skipIf(process.platform === "win32").each([ + { name: "startup", load: loadPersistedConfig, save: savePersistedConfig }, + { name: "runtime", load: loadPersistedConfigAsync, save: savePersistedConfigAsync }, +])("persisted config file permissions ($name)", ({ load, save }) => { + test("initializes config.json with private permissions", async () => { const home = createTempHome(); try { - loadPersistedConfig(home); + await load(home); expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE); } finally { @@ -832,14 +844,14 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions } }); - test("repairs permissive config.json permissions when loading", () => { + test("repairs permissive config.json permissions when loading", async () => { const home = createTempHome(); const configPath = path.join(home, "config.json"); try { writeFileSync(configPath, "{}\n", { mode: PERMISSIVE_FILE_MODE }); chmodSync(configPath, PERMISSIVE_FILE_MODE); - loadPersistedConfig(home); + await load(home); expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE); } finally { @@ -847,16 +859,25 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions } }); - test("saves config.json with private permissions", () => { + test("repairs unreadable owner permissions before reading config.json", async () => { + const home = createTempHome(); + const configPath = path.join(home, "config.json"); + try { + writeFileSync(configPath, "{}\n"); + chmodSync(configPath, 0); + expect(await load(home)).toEqual({}); + expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("saves config.json with private permissions", async () => { const parent = createTempHome(); const home = path.join(parent, "home"); try { - savePersistedConfig(home, { - providers: { - openai: { - apiKey: "secret", - }, - }, + await save(home, { + features: { dictation: { stt: { provider: "openai", apiKey: "secret" } } }, }); expect(modeOf(home)).toBe(PRIVATE_DIRECTORY_MODE); @@ -867,34 +888,204 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }); }); -describe.skipIf(process.platform === "win32")( - "config.json symlinked from another directory", - () => { - test("saving writes through the symlink and leaves it in place", () => { - const parent = createTempHome(); - const home = path.join(parent, "home"); - const dotfiles = path.join(parent, "dotfiles"); - const linkedConfigPath = path.join(dotfiles, "config.json"); - const configPath = path.join(home, "config.json"); +describe.skipIf(process.platform === "win32").each([ + { name: "startup", save: savePersistedConfig }, + { name: "runtime", save: savePersistedConfigAsync }, +])("config.json symlinked from another directory ($name)", ({ save }) => { + test("saving writes through the symlink and leaves it in place", async () => { + const parent = createTempHome(); + const home = path.join(parent, "home"); + const dotfiles = path.join(parent, "dotfiles"); + const linkedConfigPath = path.join(dotfiles, "config.json"); + const configPath = path.join(home, "config.json"); + try { + mkdirSync(home); + mkdirSync(dotfiles, { mode: 0o755 }); + chmodSync(dotfiles, 0o755); + writeFileSync(linkedConfigPath, '{ "version": 1 }\n'); + symlinkSync(linkedConfigPath, configPath); + + await save(home, { version: 1, daemon: { relay: { enabled: false } } }); + + expect(lstatSync(configPath).isSymbolicLink()).toBe(true); + expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({ + version: 1, + daemon: { relay: { enabled: false } }, + }); + expect(modeOf(dotfiles)).toBe(0o755); + expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); +}); + +describe("speech provider config normalization", () => { + test.each([undefined, "local"])( + "preserves local STT language on load and save (provider: %s)", + (provider) => { + const home = createTempHome(); try { - mkdirSync(home); - mkdirSync(dotfiles, { mode: 0o755 }); - chmodSync(dotfiles, 0o755); - writeFileSync(linkedConfigPath, '{ "version": 1 }\n'); - symlinkSync(linkedConfigPath, configPath); - - savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } }); - - expect(lstatSync(configPath).isSymbolicLink()).toBe(true); - expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({ - version: 1, - daemon: { relay: { enabled: false } }, + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + features: { dictation: { stt: { provider, language: "fr" } } }, + }), + ); + const loaded = loadPersistedConfig(home); + expect(loaded.features?.dictation?.stt).toEqual({ provider: "local", language: "fr" }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: loaded }).speech.sttLanguages + .dictation, + ).toBe("fr"); + savePersistedConfig(home, loaded); + expect(loadPersistedConfig(home).features?.dictation?.stt).toEqual({ + provider: "local", + language: "fr", }); - expect(modeOf(dotfiles)).toBe(0o755); - expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE); + expect( + JSON.parse(readFileSync(path.join(home, "config.json"), "utf8")).features.dictation.stt, + ).toEqual({ provider: "local", language: "fr" }); } finally { - rmSync(parent, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); } - }); - }, -); + }, + ); + + test("loads and resolves legacy fields ignored by each site's provider", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-unused-options-")); + try { + const legacy = { + features: { + dictation: { stt: { provider: "local", language: "fr", confidenceThreshold: -1 } }, + voiceMode: { + enabled: true, + tts: { provider: "local", voice: "alloy", speakerId: 3, speed: 1.2 }, + }, + }, + }; + writeFileSync(path.join(home, "config.json"), JSON.stringify(legacy)); + const local = loadPersistedConfig(home); + expect(local.features?.dictation?.stt).toEqual({ provider: "local", language: "fr" }); + expect(local.features?.voiceMode?.tts).toEqual({ + provider: "local", + speakerId: 3, + speed: 1.2, + }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: local }).speech.local?.models, + ).toMatchObject({ voiceTtsSpeakerId: 3, voiceTtsSpeed: 1.2 }); + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + features: { + voiceMode: { + enabled: true, + tts: { provider: "openai", apiKey: "key", voice: "alloy", speakerId: 3, speed: 1.2 }, + }, + }, + }), + ); + const openai = loadPersistedConfig(home); + expect(openai.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "key", + voice: "alloy", + }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: openai }).openai?.tts, + ).toMatchObject({ apiKey: "key", voice: "alloy" }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("warns with the path, never the discarded legacy key", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-warning-")); + try { + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + providers: { openai: { apiKey: "discarded-secret" } }, + features: { voiceMode: { enabled: true, tts: { provider: "local" } } }, + }), + ); + const warnings: unknown[][] = []; + const logger = { + child: () => logger, + info: () => {}, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const config = loadPersistedConfig(home, logger); + expect(config.providers).not.toHaveProperty("openai"); + expect(warnings).toHaveLength(1); + expect(JSON.stringify(warnings)).toContain("providers.openai.apiKey"); + expect(JSON.stringify(warnings)).toContain("no use site in config.json selects OpenAI"); + const resolved = resolveSpeechConfig({ + paseoHome: home, + persisted: config, + env: { PASEO_VOICE_TTS_PROVIDER: "openai" }, + }); + expect(resolved.speech.providers.voiceTts.provider).toBe("openai"); + expect(resolved.openai).toBeUndefined(); + expect(JSON.stringify(warnings)).not.toContain("discarded-secret"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("folds legacy endpoint credentials into OpenAI sites on load and saves inline", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-normalize-")); + try { + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + providers: { + openai: { + apiKey: "global-key", + baseUrl: "https://global/v1", + stt: { apiKey: "stt-key" }, + }, + }, + features: { + dictation: { stt: { model: "whisper-base", language: "en" } }, + voiceMode: { + enabled: true, + stt: { provider: "OPENAI", baseUrl: "https://site/v1" }, + tts: { provider: "openai" }, + }, + }, + }), + ); + const config = loadPersistedConfig(home); + expect(config.features?.dictation?.stt).toEqual({ + provider: "local", + model: "whisper-base", + language: "en", + }); + expect(config.features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "stt-key", + baseUrl: "https://site/v1", + }); + expect(config.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "global-key", + baseUrl: "https://global/v1", + }); + expect(config.providers).not.toHaveProperty("openai"); + const resolved = resolveSpeechConfig({ paseoHome: home, env: {}, persisted: config }); + expect(resolved.openai?.stt?.apiKey).toBe("stt-key"); + expect(resolved.speech.providers.voiceStt).toMatchObject({ + provider: "openai", + enabled: true, + }); + savePersistedConfig(home, config); + expect(JSON.parse(readFileSync(path.join(home, "config.json"), "utf8"))).toEqual(config); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index cccc0228d8..3138ec9132 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; @@ -9,7 +10,12 @@ import { ProviderOverridesSchema, } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; -import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; +import { + ensurePrivateFile, + ensurePrivateFileAsync, + writePrivateFileAtomic, + writePrivateFileAtomicSync, +} from "./private-files.js"; import { AgentProfileSchema, AgentSkillSelectionSchema } from "@getpaseo/protocol/agent-profile"; import { PluginIdSchema, PluginSourceSchema } from "@getpaseo/protocol/plugin-config"; import { TerminalProfileSchema } from "@getpaseo/protocol/terminal-profile"; @@ -49,22 +55,6 @@ const LogConfigSchema = z }) .strict(); -const OpenAiSpeechEndpointSchema = z - .object({ - apiKey: z.string().trim().min(1).optional(), - baseUrl: z.string().trim().min(1).optional(), - }) - .strict(); - -const OpenAiProviderSchema = z - .object({ - apiKey: z.string().min(1).optional(), - baseUrl: z.string().trim().min(1).optional(), - stt: OpenAiSpeechEndpointSchema.optional(), - tts: OpenAiSpeechEndpointSchema.optional(), - }) - .strict(); - const LocalSpeechProviderSchema = z .object({ modelsDir: z.string().min(1).optional(), @@ -73,7 +63,6 @@ const LocalSpeechProviderSchema = z const ProvidersSchema = z .object({ - openai: OpenAiProviderSchema.optional(), local: LocalSpeechProviderSchema.optional(), }) .strict(); @@ -101,18 +90,54 @@ const SpeechProviderIdSchema = z .toLowerCase() .pipe(z.enum(["openai", "local"])); +const SpeechModelSchema = z.string().min(1).optional(); +const OpenAiConnectionFields = { + apiKey: z.string().trim().min(1).optional(), + baseUrl: z.string().trim().min(1).optional(), +}; + +export const SpeechSttConfigSchema = z.discriminatedUnion("provider", [ + z + .object({ + provider: z.literal("local"), + model: SpeechModelSchema, + language: z.string().trim().min(1).optional(), + }) + .strict(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiConnectionFields, + model: SpeechModelSchema, + language: z.string().trim().min(1).optional(), + confidenceThreshold: z.number().optional(), + }) + .strict(), +]); + +export const SpeechTtsConfigSchema = z.discriminatedUnion("provider", [ + z + .object({ + provider: z.literal("local"), + model: SpeechModelSchema, + speakerId: z.number().int().optional(), + speed: z.number().optional(), + }) + .strict(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiConnectionFields, + model: SpeechModelSchema, + voice: z.string().trim().min(1).optional(), + }) + .strict(), +]); + const FeatureDictationSchema = z .object({ enabled: z.boolean().optional(), - stt: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - language: z.string().trim().min(1).optional(), - confidenceThreshold: z.number().optional(), - }) - .strict() - .optional(), + stt: SpeechSttConfigSchema.optional(), }) .strict(); @@ -126,31 +151,14 @@ const FeatureVoiceModeSchema = z }) .strict() .optional(), - stt: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - language: z.string().trim().min(1).optional(), - }) - .strict() - .optional(), + stt: SpeechSttConfigSchema.optional(), turnDetection: z .object({ provider: SpeechProviderIdSchema.optional(), }) .strict() .optional(), - tts: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - // Free-form: self-hosted OpenAI-compatible servers expose their own voice names. - voice: z.string().trim().min(1).optional(), - speakerId: z.number().int().optional(), - speed: z.number().optional(), - }) - .strict() - .optional(), + tts: SpeechTtsConfigSchema.optional(), }) .strict(); @@ -251,6 +259,7 @@ export const PersistedConfigSchema = z }) .passthrough() .optional(), + usage: z.object({ enabled: z.boolean().optional() }).optional(), browserTools: z .object({ enabled: z.boolean().optional(), @@ -339,6 +348,9 @@ export const PersistedConfigSchema = z agents: z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), + idleRuntimeCleanup: z.boolean().optional(), + resumeOnStartup: z.boolean().optional(), + resumePrompt: z.string().optional(), catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(), metadataGeneration: AgentMetadataGenerationSchema.optional(), skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(), @@ -386,6 +398,7 @@ const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({ interface LoggerLike { child(bindings: Record): LoggerLike; info(...args: unknown[]): void; + warn?(...args: unknown[]): void; } function getConfigPath(paseoHome: string): string { @@ -396,45 +409,96 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { return logger?.child({ module: "config" }); } -// Removed config fields are stripped before parsing so the strict schema does not -// reject a config written by an older release. The stripped values are discarded, -// not migrated — there is no back-compat for the removed `providers.openai.voice` -// block (use `providers.openai.stt` / `providers.openai.tts`). -function stripRemovedConfigFields(parsed: unknown): unknown { - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return parsed; +function isConfigRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function configRecord(value: unknown): Record | undefined { + return isConfigRecord(value) ? value : undefined; +} + +// COMPAT(speechProviderConfig): added in v0.12.0, remove after 2027-04-26. +// Fold shared credentials once at the file boundary; consumers see only the strict +// per-site shape. Never include credential values in diagnostics. +function normalizeSpeechProviderConfig(parsed: unknown, logger?: LoggerLike): unknown { + if (!isConfigRecord(parsed)) return parsed; + const root = structuredClone(parsed); + const providers = configRecord(root.providers); + const legacy = configRecord(providers?.openai); + const features = configRecord(root.features); + const foldedKeys = new Set(); + for (const [feature, kind] of [ + ["dictation", "stt"], + ["voiceMode", "stt"], + ["voiceMode", "tts"], + ] as const) { + const site = configRecord(configRecord(features?.[feature])?.[kind]); + if (site) normalizeSpeechSite(site, kind, legacy, foldedKeys); } - - const root = { ...(parsed as Record) }; - const providers = root.providers; - if (!providers || typeof providers !== "object" || Array.isArray(providers)) { - return root; + if (legacy) warnDiscardedSpeechKeys(legacy, foldedKeys, logger); + if (providers) { + delete providers.openai; + const local = configRecord(providers.local); + if (local) delete local.autoDownload; } - - const providersRecord = { ...(providers as Record) }; - - const local = providersRecord.local; - if (local && typeof local === "object" && !Array.isArray(local)) { - const localRecord = { ...(local as Record) }; - delete localRecord.autoDownload; - providersRecord.local = localRecord; - } - - const openai = providersRecord.openai; - if (openai && typeof openai === "object" && !Array.isArray(openai)) { - const openaiRecord = { ...(openai as Record) }; - // COMPAT(openaiVoiceConfig): added 2026-06-30, remove after 2026-12-30. - // Drop a `providers.openai.voice` block left by an older release so the strict - // schema doesn't reject it. The value is discarded, not migrated — there is no - // back-compat; configure `providers.openai.stt` / `providers.openai.tts` instead. - delete openaiRecord.voice; - providersRecord.openai = openaiRecord; - } - - root.providers = providersRecord; return root; } +function normalizeSpeechSite( + site: Record, + kind: "stt" | "tts", + legacy: Record | undefined, + foldedKeys: Set, +) { + site.provider = + typeof site.provider === "string" + ? site.provider.trim().toLowerCase() + : (site.provider ?? "local"); + if (kind === "stt" && site.provider === "local") { + delete site.confidenceThreshold; + } + if (kind === "tts") { + if (site.provider === "local") delete site.voice; + if (site.provider === "openai") { + delete site.speakerId; + delete site.speed; + } + } + if (site.provider !== "openai" || !legacy) return; + const endpoint = configRecord(legacy[kind]); + for (const field of ["apiKey", "baseUrl"] as const) { + if (site[field] !== undefined) continue; + if (endpoint?.[field] !== undefined) { + site[field] = endpoint[field]; + foldedKeys.add(`providers.openai.${kind}.${field}`); + } else if (legacy[field] !== undefined) { + site[field] = legacy[field]; + foldedKeys.add(`providers.openai.${field}`); + } + } +} + +function warnDiscardedSpeechKeys( + legacy: Record, + foldedKeys: Set, + logger?: LoggerLike, +) { + const keys = [ + ["providers.openai.apiKey", legacy.apiKey], + ...["stt", "tts"].map((kind) => [ + `providers.openai.${kind}.apiKey`, + configRecord(legacy[kind])?.apiKey, + ]), + ] as const; + for (const [keyPath, key] of keys) { + if (key && typeof keyPath === "string" && !foldedKeys.has(keyPath)) { + const message = `Dropped ${keyPath}: no use site in config.json selects OpenAI; re-enter it when selecting OpenAI.`; + if (logger?.warn) logger.warn(message); + else console.warn(message); + } + } +} + export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): PersistedConfig { const log = getLogger(logger); const configPath = getConfigPath(paseoHome); @@ -463,7 +527,41 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } - const config = parseConfigFile(configPath, raw); + const config = parseConfigFile(configPath, raw, log); + log?.info(`Loaded from ${configPath}`); + return config; +} + +/** Runtime config IO. Startup and offline CLI consumers retain their synchronous entry points. */ +export async function loadPersistedConfigAsync( + paseoHome: string, + logger?: LoggerLike, +): Promise { + const log = getLogger(logger); + const configPath = getConfigPath(paseoHome); + let raw: string; + await ensurePrivateFileAsync(configPath); + try { + raw = await readFile(configPath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new Error( + `[Config] Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + raw = JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n"; + await writePrivateFileAtomic(configPath, raw); + log?.info(`Initialized config file at ${configPath}`); + } catch (initializationError) { + throw new Error( + `[Config] Failed to initialize ${configPath}: ${initializationError instanceof Error ? initializationError.message : String(initializationError)}`, + { cause: initializationError }, + ); + } + } + const config = parseConfigFile(configPath, raw, log); log?.info(`Loaded from ${configPath}`); return config; } @@ -485,7 +583,7 @@ export function readPersistedConfig( return parseConfigFile(configPath, raw); } -function parseConfigFile(configPath: string, raw: string): PersistedConfig { +function parseConfigFile(configPath: string, raw: string, logger?: LoggerLike): PersistedConfig { let parsed: unknown; try { parsed = parseConfigText(raw); @@ -496,7 +594,7 @@ function parseConfigFile(configPath: string, raw: string): PersistedConfig { }); } - const result = PersistedConfigSchema.safeParse(stripRemovedConfigFields(parsed)); + const result = PersistedConfigSchema.safeParse(normalizeSpeechProviderConfig(parsed, logger)); if (!result.success) { const issues = result.error.issues .map((i) => ` - ${i.path.join(".")}: ${i.message}`) @@ -524,6 +622,12 @@ function configPathParts(field: string): string[] { schema instanceof z.ZodPipe ) schema = (schema instanceof z.ZodPipe ? schema.in : schema.unwrap()) as z.ZodType; + if (schema instanceof z.ZodUnion) { + const variant = schema.options.find( + (option) => option instanceof z.ZodObject && Object.hasOwn(option.shape, part), + ); + if (variant instanceof z.ZodObject) schema = variant; + } 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.`, @@ -581,16 +685,10 @@ export function savePersistedConfig( const log = getLogger(logger); const configPath = getConfigPath(paseoHome); - const result = PersistedConfigSchema.safeParse(config); - if (!result.success) { - const issues = result.error.issues - .map((i) => ` - ${i.path.join(".")}: ${i.message}`) - .join("\n"); - throw new Error(`[Config] Invalid config to save:\n${issues}`); - } + const data = serializePersistedConfig(config); try { - writePrivateFileAtomicSync(configPath, JSON.stringify(result.data, null, 2) + "\n"); + writePrivateFileAtomicSync(configPath, data); log?.info(`Saved to ${configPath}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -599,3 +697,33 @@ export function savePersistedConfig( }); } } + +function serializePersistedConfig(config: PersistedConfig): string { + const result = PersistedConfigSchema.safeParse(config); + if (!result.success) { + const issues = result.error.issues + .map((i) => ` - ${i.path.join(".")}: ${i.message}`) + .join("\n"); + throw new Error(`[Config] Invalid config to save:\n${issues}`); + } + + return JSON.stringify(result.data, null, 2) + "\n"; +} + +export async function savePersistedConfigAsync( + paseoHome: string, + config: PersistedConfig, + logger?: LoggerLike, +): Promise { + const configPath = getConfigPath(paseoHome); + const data = serializePersistedConfig(config); + try { + await writePrivateFileAtomic(configPath, data); + getLogger(logger)?.info(`Saved to ${configPath}`); + } catch (error) { + throw new Error( + `[Config] Failed to write ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 314b44ea47..6f3e7135d9 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -30,13 +30,16 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException { return err instanceof Error && "code" in err; } +// The field is assigned explicitly rather than declared as a constructor parameter property: +// the terminal worker loads this module under --experimental-strip-types, which cannot emit +// the assignment a parameter property implies. export class PidLockError extends Error { - constructor( - message: string, - public readonly existingLock?: PidLockInfo, - ) { + readonly existingLock?: PidLockInfo; + + constructor(message: string, existingLock?: PidLockInfo) { super(message); this.name = "PidLockError"; + this.existingLock = existingLock; } } @@ -69,7 +72,7 @@ let cachedBootId: string | null | undefined; // Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while // its host sleeps keeps its uptime, so after it resumes the instant lands after locks its // running supervisor wrote. -function currentBootId(): string | null { +export function currentBootId(): string | null { if (cachedBootId === undefined) { try { cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null; @@ -80,10 +83,17 @@ function currentBootId(): string | null { return cachedBootId; } -function writtenDuringThisBoot(lock: PidLockInfo): boolean { +/** + * Whether a record stamped with `startedAt` (and, on Linux, `bootId`) was written during the + * current boot. Daemon locks and terminal worker records share this rule. + */ +export function writtenDuringThisBoot(stamp: { + startedAt: string; + bootId?: string | null; +}): boolean { const thisBoot = currentBootId(); - if (lock.bootId && thisBoot) return lock.bootId === thisBoot; - return !precedesThisBoot(lock.startedAt); + if (stamp.bootId && thisBoot) return stamp.bootId === thisBoot; + return !precedesThisBoot(stamp.startedAt); } /** 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 3228306297..a3e4e273a4 100644 --- a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -1,20 +1,32 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; +import type { AgentClient, AgentSessionConfig } from "../agent/agent-sdk-types.js"; import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -test("the configuration example adds MCP servers and overrides Codex options while preserving other configuration", async () => { +test("the configuration example saves Codex options and injects launch-only MCP servers while preserving other configuration", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-")); + const launches: AgentSessionConfig[] = []; const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.8.0", - agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) }, + daemonVersion: "0.14.0-beta.1", + agentClients: { + codex: createTestAgentClient("codex", { + supportsMcpServers: true, + beforeCreateSession: async (config) => { + launches.push(config); + }, + }), + }, mcpEnabled: false, }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.1", + }); try { await client.connect(); await client.fetchAgents({ subscribe: {} }); @@ -44,9 +56,15 @@ test("the configuration example adds MCP servers and overrides Codex options whi }, mcpServers: { existing: { type: "http", url: "https://existing.example.com/mcp" }, - company: { type: "http", url: "https://tools.example.com/mcp" }, }, }); + expect(daemon.daemon.agentManager.getAgent(agent.id)!.config.mcpServers).toEqual({ + existing: { type: "http", url: "https://existing.example.com/mcp" }, + }); + expect(launches[0].mcpServers).toEqual({ + existing: { type: "http", url: "https://existing.example.com/mcp" }, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }); await client.archiveAgent(agent.id); } finally { await client.close(); @@ -54,3 +72,71 @@ test("the configuration example adds MCP servers and overrides Codex options whi await rm(directory, { recursive: true, force: true }); } }, 60_000); + +test("session-open entrypoint contributions are optional and rederived for existing agents", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-entrypoint-hook-")); + const launches: { provider: string; env?: Record }[] = []; + function recordingProvider(provider: "claude" | "codex"): AgentClient { + const inner = createTestAgentClient(provider); + return { + provider: inner.provider, + capabilities: inner.capabilities, + isAvailable: () => inner.isAvailable(), + fetchCatalog: (options) => inner.fetchCatalog(options), + createSession: async (...args: Parameters) => { + launches.push({ provider, env: args[1]?.env }); + return inner.createSession(...args); + }, + resumeSession: async (...args: Parameters) => { + launches.push({ provider, env: args[2]?.env }); + return inner.resumeSession(...args); + }, + }; + } + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.14.0-beta.2", + agentClients: { claude: recordingProvider("claude"), codex: recordingProvider("codex") }, + mcpEnabled: false, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.2", + }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "entrypoint-test", requirements: { paseo: ">=0.14.0-beta.2" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + return server.before("agent.session_open", ({ request }) => { + if (request.provider !== "claude") return; + return { ...request, env: { ...request.env, CLAUDE_CODE_ENTRYPOINT: "custom-client" } }; + }); +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const existing = await client.createAgent({ provider: "claude", cwd: directory }); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + await daemon.daemon.agentManager.reloadAgentSession(existing.id); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client"); + await client.createAgent({ provider: "claude", cwd: directory }); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client"); + await client.createAgent({ provider: "codex", cwd: directory }); + expect(launches.at(-1)).toMatchObject({ provider: "codex" }); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + await client.disablePlugin("entrypoint-test"); + await daemon.daemon.agentManager.reloadAgentSession(existing.id); + expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/agent-tools.e2e.test.ts b/packages/server/src/server/plugins/agent-tools.e2e.test.ts new file mode 100644 index 0000000000..b652c569fe --- /dev/null +++ b/packages/server/src/server/plugins/agent-tools.e2e.test.ts @@ -0,0 +1,1042 @@ +import { fileURLToPath } from "node:url"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { + createTestAgentClient, + type TestAgentClientOptions, +} from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { importSessionFromPersistence } from "../agent/provider-session-import.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; +import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js"; +import type { + AgentClient, + AgentLaunchContext, + AgentSessionConfig, + AgentPromptInput, +} from "../agent/agent-sdk-types.js"; + +function recordingProvider(native: boolean, callbacks: TestAgentClientOptions = {}) { + const launches: { config: AgentSessionConfig; context?: AgentLaunchContext }[] = []; + const turns: { prompt: AgentPromptInput; launchCount: number }[] = []; + const steers: AgentPromptInput[] = []; + const events = { interrupts: 0 }; + const inner = createTestAgentClient("codex", { + ...callbacks, + supportsMcpServers: callbacks.supportsMcpServers ?? true, + onStartTurn: (prompt, config) => { + turns.push({ prompt, launchCount: launches.length }); + callbacks.onStartTurn?.(prompt, config); + }, + steerActiveTurn: async (prompt) => { + steers.push(prompt); + return { status: "accepted" }; + }, + onInterrupt: () => { + events.interrupts++; + callbacks.onInterrupt?.(); + }, + }); + const provider: AgentClient = { + provider: inner.provider, + capabilities: { ...inner.capabilities, supportsNativePaseoTools: native }, + createSession: async (config, context, options) => { + launches.push({ config, context }); + return inner.createSession(config, context, options); + }, + resumeSession: async (handle, config, context, options) => { + launches.push({ config: config as AgentSessionConfig, context }); + return inner.resumeSession(handle, config, context, options); + }, + importSession: (input, context) => + importSessionFromPersistence({ + provider: "codex", + request: input, + context, + resumeSession: provider.resumeSession.bind(provider), + }), + isAvailable: () => inner.isAvailable(), + fetchCatalog: (options) => inner.fetchCatalog(options), + }; + return { provider, launches, turns, steers, events }; +} + +const pluginSource = ` +import { z } from "zod"; +import { defineAgentTool } from "@getpaseo/plugin/server"; +let calls = 0; +const tool = defineAgentTool({ + name: "plugin_echo", description: "Echo for the chosen agent", + input: z.object({ text: z.string().min(1).refine(value => value !== "forbidden", "Forbidden by plugin").describe("Message") }), + async execute(input, { agent, paseo, signal }) { + calls++; + if (input.text === "throw") throw new Error("handler failed"); + return { text: JSON.stringify({ agent, input, calls, sdk: typeof paseo.agents.ref, aborted: signal.aborted }) }; + }, +}); +export default function contribute(server) { + server.addHook("agent.open.before", async ({ request }) => { + if (request.labels.x !== "y") return; + return { ...request, env: { ...request.env, OPEN_INFO: JSON.stringify({ title: request.title, labels: request.labels, reason: request.reason }) }, tools: [...request.tools, tool] }; + }); + return () => {}; +} +`; + +async function setup( + native = false, + source = pluginSource, + policy?: { enabled?: boolean; disabledTools?: string[] }, + providerOptions?: TestAgentClientOptions, +) { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-tools-")); + const recorder = recordingProvider(native, providerOptions); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.14.0-beta.1", + agentClients: { codex: recorder.provider }, + mcpEnabled: true, + providerOverrides: { codex: { paseoTools: policy } }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.1", + }); + const mcps: Client[] = []; + async function install(id: string, code: string) { + const pluginDirectory = path.join(directory, id); + await mkdir(pluginDirectory); + await writeFile( + path.join(pluginDirectory, "paseo-plugin.json"), + JSON.stringify({ id, requirements: { paseo: ">=0.14.0-beta.1" } }), + ); + await writeFile(path.join(pluginDirectory, "index.server.ts"), code); + await client.installDirectoryPlugin(pluginDirectory); + } + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + try { + await install("tools-test", source); + } catch (error) { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + throw error; + } + return { + ...recorder, + daemon, + client, + directory, + install, + async create(labels: Record = {}) { + return daemon.daemon.agentManager.createAgent( + { provider: "codex", cwd: directory }, + undefined, + { labels, initialTitle: "Chosen title" }, + ); + }, + async mcp(agentId?: string) { + const server = recorder.launches.at(-1)!.config.mcpServers?.paseo; + let url: string; + if (agentId) url = `http://127.0.0.1:${daemon.port}/mcp/agents?callerAgentId=${agentId}`; + else { + if (server?.type !== "http") throw new Error("Expected HTTP server"); + url = server.url; + } + const mcp = new Client({ name: "plugin-tools-test", version: "1" }); + mcps.push(mcp); + await mcp.connect(new StreamableHTTPClientTransport(new URL(url))); + return mcp; + }, + async close() { + await Promise.all(mcps.map((mcp) => mcp.close())); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + }, + }; +} + +test("session hooks attach tools only to matching agents over MCP, with validated input and caller context", async () => { + const h = await setup(); + try { + const a = await h.create({ x: "y" }); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO)).toEqual({ + title: "Chosen title", + labels: { x: "y" }, + reason: "create", + }); + const mcpA = await h.mcp(); + const listed = (await mcpA.listTools()).tools.find((tool) => tool.name === "plugin_echo"); + expect(listed).toMatchObject({ + name: "plugin_echo", + inputSchema: { + type: "object", + properties: { text: { type: "string", minLength: 1, description: "Message" } }, + required: ["text"], + }, + }); + const b = await h.create(); + const mcpB = await h.mcp(); + expect((await mcpB.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + const invalid = await mcpA.callTool({ name: "plugin_echo", arguments: { text: "" } }); + expect(invalid.isError).toBe(true); + expect(JSON.stringify(invalid)).toContain("text"); + expect( + await mcpA.callTool({ name: "plugin_echo", arguments: { text: "forbidden" } }), + ).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("Forbidden by plugin") }], + }); + const result = await mcpA.callTool({ name: "plugin_echo", arguments: { text: "hello" } }); + expect(result.isError).not.toBe(true); + expect(JSON.parse((result.content as { text: string }[])[0].text)).toMatchObject({ + agent: { id: a.id, cwd: h.directory, title: "Chosen title" }, + input: { text: "hello" }, + calls: 1, + sdk: "function", + aborted: false, + }); + expect(a.id).not.toBe(b.id); + expect( + await mcpA.callTool({ name: "plugin_echo", arguments: { text: "throw" } }), + ).toMatchObject({ isError: true, content: [{ type: "text", text: "handler failed" }] }); + expect( + (await mcpA.callTool({ name: "plugin_echo", arguments: { text: "still alive" } })).isError, + ).not.toBe(true); + } finally { + await h.close(); + } +}, 60_000); + +test("native providers get the same attached schema and execution", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const catalog = h.launches.at(-1)!.context!.paseoTools!; + expect(catalog.getTool("plugin_echo")?.description).toBe("Echo for the chosen agent"); + const mcp = await h.mcp(a.id); + const advertised = (await mcp.listTools()).tools.find((tool) => tool.name === "plugin_echo")!; + expect(serializePaseoToolInputParameters(catalog.getTool("plugin_echo")!)).toEqual( + advertised.inputSchema, + ); + const result = await catalog.executeTool("plugin_echo", { text: "native" }); + expect(JSON.parse(result.content[0].text!)).toMatchObject({ + agent: { id: a.id }, + input: { text: "native" }, + }); + } finally { + await h.close(); + } +}, 60_000); + +test("resume uses the stored title and labels, and close removes the attachment", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const catalog = h.launches.at(-1)!.context!.paseoTools!; + const handle = a.persistence!; + await h.daemon.daemon.agentManager.setTitle(a.id, "Stored title"); + await h.daemon.daemon.agentManager.closeAgent(a.id); + expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]); + await expect(catalog.executeTool("plugin_echo", { text: "closed" })).rejects.toThrow( + "Agent is not live", + ); + await h.daemon.daemon.agentManager.resumeAgentFromPersistence(handle, a.config, a.id); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO)).toEqual({ + title: "Stored title", + labels: { x: "y" }, + reason: "resume", + }); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe( + "plugin_echo", + ); + } finally { + await h.close(); + } +}, 60_000); + +test.each([ + { + name: "Paseo", + source: pluginSource.replace('name: "plugin_echo"', 'name: "list_agents"'), + other: false, + message: /plugin tools-test.*Paseo tool list_agents/, + }, + { + name: "another plugin", + source: pluginSource, + other: true, + message: /plugin tools-test.*plugin other-tools/, + }, + { + name: "the same plugin", + source: pluginSource.replace("tools: [...request.tools, tool]", "tools: [tool, tool]"), + other: false, + message: /Duplicate agent tool: plugin_echo/, + }, +])( + "collisions with $name fail only targeted session opens", + async ({ source, other, message }) => { + const h = await setup(false, source); + try { + if (other) await h.install("other-tools", pluginSource); + await expect(h.create({ x: "y" })).rejects.toThrow(message); + expect(h.launches).toHaveLength(0); + await h.create(); + expect(h.launches).toHaveLength(1); + expect((await (await h.mcp()).listTools()).tools.map((tool) => tool.name)).not.toContain( + "plugin_echo", + ); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("each plugin owns its tool array and callbacks can remove earlier attachments", async () => { + const source = pluginSource.replace( + " return () => {};", + ` + server.addHook("agent.open.before", async ({ request }) => { + if (request.labels.x === "y" && request.tools[0]?.name !== "plugin_echo") throw new Error("Lost plugin-local definitions"); + return { ...request, tools: [] }; + }); + return () => {};`, + ); + const h = await setup(false, source); + try { + await h.install( + "other-tools", + pluginSource + .replace('name: "plugin_echo"', 'name: "other_echo"') + .replace( + ' if (request.labels.x !== "y") return;', + ' if (request.tools.length !== 0) throw new Error("Saw another plugin tools");\n if (request.labels.x !== "y") return;', + ), + ); + await h.create({ x: "y" }); + const names = (await (await h.mcp()).listTools()).tools.map((tool) => tool.name); + expect(names).toContain("other_echo"); + expect(names).not.toContain("plugin_echo"); + } finally { + await h.close(); + } +}, 60_000); + +test("disabled plugins remove tools from existing and new MCP catalogs", async () => { + const h = await setup(); + try { + await h.create({ x: "y" }); + const mcp = await h.mcp(); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo"); + await h.client.patchDaemonConfig({ pluginsEnabled: false }); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + await h.create({ x: "y" }); + expect((await (await h.mcp()).listTools()).tools.map((tool) => tool.name)).not.toContain( + "plugin_echo", + ); + } finally { + await h.close(); + } +}, 60_000); + +test.each([false, true])( + "provider tools disabled gates plugin tools (native=%s)", + async (native) => { + const h = await setup(native, pluginSource, { enabled: false }); + try { + await h.create({ x: "y" }); + expect(h.launches.at(-1)!.context!.paseoTools).toBeUndefined(); + expect(h.launches.at(-1)!.config.mcpServers?.paseo).toBeUndefined(); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("disabledTools filters plugin tool names", async () => { + const h = await setup(true, pluginSource, { disabledTools: ["plugin_echo"] }); + try { + await h.create({ x: "y" }); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +const waitingSource = pluginSource + .replace("import { z }", 'import { writeFile } from "node:fs/promises";\nimport { z }') + .replace( + " calls++;", + ` + calls++; + if (input.text === "wait") { + await writeFile(agent.cwd + "/started", agent.id); + await new Promise(resolve => signal.addEventListener("abort", resolve, { once: true })); + await writeFile(agent.cwd + "/aborted", agent.id); + return { text: "discard this result" }; + } +`, + ); + +test("caller cancellation aborts the plugin handler and discards its result", async () => { + const h = await setup(true, waitingSource); + try { + const a = await h.create({ x: "y" }); + const catalog = h.launches.at(-1)!.context!.paseoTools!; + const controller = new AbortController(); + const call = catalog.executeTool( + "plugin_echo", + { text: "wait" }, + { signal: controller.signal }, + ); + const rejected = expect(call).rejects.toThrow("Canceled by caller"); + await expect.poll(() => readFile(path.join(h.directory, "started"), "utf8")).toBe(a.id); + controller.abort(new Error("Canceled by caller")); + await rejected; + await expect.poll(() => readFile(path.join(h.directory, "aborted"), "utf8")).toBe(a.id); + } finally { + await h.close(); + } +}, 60_000); + +test("a plugin stopping mid-call returns a tool error", async () => { + const h = await setup(true, waitingSource); + try { + const a = await h.create({ x: "y" }); + const call = h.launches + .at(-1)! + .context!.paseoTools!.executeTool("plugin_echo", { text: "wait" }); + await expect.poll(() => readFile(path.join(h.directory, "started"), "utf8")).toBe(a.id); + await h.client.patchDaemonConfig({ pluginsEnabled: false }); + expect(await call).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("Plugin stopped") }], + }); + } finally { + await h.close(); + } +}, 60_000); + +test("history sessions ignore attached tools", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const handle = a.persistence!; + await h.client.archiveAgent(a.id); + await h.daemon.daemon.agentManager.resumeAgentFromPersistence(handle, a.config, a.id); + expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]); + expect(h.launches.at(-1)!.context!.paseoTools?.getTool("plugin_echo")).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("disabled plugins attach no tools to a new native session", async () => { + const h = await setup(true); + try { + await h.client.patchDaemonConfig({ pluginsEnabled: false }); + await h.create({ x: "y" }); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("structured tool results get model-visible text when text is omitted", async () => { + const h = await setup( + true, + pluginSource.replace( + " calls++;", + ' return { structuredContent: { status: "created" } };', + ), + ); + try { + await h.create({ x: "y" }); + expect( + await h.launches + .at(-1)! + .context!.paseoTools!.executeTool("plugin_echo", { text: "structured" }), + ).toEqual({ + content: [{ type: "text", text: JSON.stringify({ status: "created" }, null, 2) }], + structuredContent: { status: "created" }, + isError: undefined, + }); + } finally { + await h.close(); + } +}, 60_000); + +test("reloading a plugin retires its old attachments before an agent reopens", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + const mcp = await h.mcp(a.id); + const oldCatalog = h.launches.at(-1)!.context!.paseoTools!; + expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo"); + await h.client.reloadPlugin("tools-test"); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]); + expect(await oldCatalog.executeTool("plugin_echo", { text: "retired" })).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("not available") }], + }); + } finally { + await h.close(); + } +}, 60_000); + +test("built-in plugins attach and execute tools while configured plugins are disabled", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-builtin-tools-")); + const directory = path.join(root, "builtin-tools"); + await mkdir(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.14.0-beta.1" } }), + ); + await writeFile(path.join(directory, "index.server.ts"), pluginSource); + const recorder = recordingProvider(true); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.14.0-beta.1", + agentClients: { codex: recorder.provider }, + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["builtin-tools"]), + }); + try { + const a = await daemon.daemon.agentManager.createAgent( + { provider: "codex", cwd: root }, + undefined, + { labels: { x: "y" } }, + ); + const catalog = recorder.launches.at(-1)!.context!.paseoTools!; + expect(catalog.getTool("plugin_echo")?.name).toBe("plugin_echo"); + const result = await catalog.executeTool("plugin_echo", { text: "builtin" }); + expect(result.isError).not.toBe(true); + expect(JSON.parse(result.content[0].text!)).toMatchObject({ + agent: { id: a.id }, + input: { text: "builtin" }, + }); + } finally { + await daemon.close(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); + +test("a session hook missing tools reports a schema issue", async () => { + const h = await setup( + false, + pluginSource.replace("tools: [...request.tools, tool]", "tools: undefined"), + ); + try { + await expect(h.create({ x: "y" })).rejects.toThrow('"tools"'); + } finally { + await h.close(); + } +}, 60_000); + +async function sendAndFinish(h: Awaited>, agentId: string, text: string) { + const previous = h.turns.length; + await h.client.sendMessage(agentId, text, { activeTurnBehavior: "steer" }); + await expect.poll(() => h.turns.length).toBe(previous + 1); + await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(agentId)).toBe(false); +} + +test("an idle agent reopens once before its next prompt after a session-hook plugin reload", async () => { + const h = await setup(); + try { + const b = await h.create(); + const mcp = await h.mcp(); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo"); + await writeFile( + path.join(h.directory, "tools-test", "index.server.ts"), + pluginSource.replace(' if (request.labels.x !== "y") return;', ""), + ); + await h.client.reloadPlugin("tools-test"); + expect(h.launches).toHaveLength(1); + await sendAndFinish(h, b.id, "first prompt"); + expect(h.launches).toHaveLength(2); + expect(h.turns.map((turn) => turn.launchCount)).toEqual([2]); + expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo"); + await sendAndFinish(h, b.id, "second prompt"); + expect(h.launches).toHaveLength(2); + expect(h.turns.map((turn) => turn.launchCount)).toEqual([2, 2]); + expect(h.events.interrupts).toBe(0); + } finally { + await h.close(); + } +}, 60_000); + +test("a busy agent is steered after plugin reload and reopens only at its next idle prompt", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const h = await setup(true, pluginSource, undefined, { beforeTurnComplete: async () => blocked }); + try { + const a = await h.create({ x: "y" }); + await h.client.sendMessage(a.id, "busy prompt", { activeTurnBehavior: "steer" }); + await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(a.id)).toBe(true); + await expect + .poll(() => h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId) + .not.toBeNull(); + const turnId = h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId; + await h.client.reloadPlugin("tools-test"); + await h.client.sendMessage(a.id, "steering prompt", { activeTurnBehavior: "steer" }); + expect(h.steers).toHaveLength(1); + expect(h.launches).toHaveLength(1); + expect(h.events.interrupts).toBe(0); + expect(h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId).toBe(turnId); + release(); + await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(a.id)).toBe(false); + await sendAndFinish(h, a.id, "idle prompt"); + expect(h.launches).toHaveLength(2); + expect(h.events.interrupts).toBe(0); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe( + "plugin_echo", + ); + await sendAndFinish(h, a.id, "stable prompt"); + expect(h.launches).toHaveLength(2); + } finally { + release(); + await h.close(); + } +}, 60_000); + +const reloadRpcSource = pluginSource + .replace("import { z }", 'import { defineRpc } from "@getpaseo/plugin";\nimport { z }') + .replace( + " return () => {};", + ` + server.handle(defineRpc({ name: "reload_agent", input: z.object({ id: z.string() }), output: z.object({ id: z.string() }) }), async ({ id }, { paseo }) => { + const agent = paseo.agents.ref(id); + await agent.refresh(); + await agent.reload(); + return { id: agent.current().id }; + }); + return () => {}; +`, + ); + +test("a plugin can reload an agent through the SDK and rerun session hooks with reason refresh", async () => { + const h = await setup(true, reloadRpcSource); + try { + const a = await h.create({ x: "y" }); + expect(h.launches).toHaveLength(1); + expect(await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id })).toEqual({ + id: a.id, + }); + expect(h.launches).toHaveLength(2); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh"); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe( + "plugin_echo", + ); + } finally { + await h.close(); + } +}, 60_000); + +test("starting and stopping a session-hook plugin each causes one idle reopen", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + await h.install( + "added-tools", + pluginSource.replace('name: "plugin_echo"', 'name: "added_echo"'), + ); + await sendAndFinish(h, a.id, "after start"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("added_echo")?.name).toBe("added_echo"); + await h.client.disablePlugin("added-tools"); + await sendAndFinish(h, a.id, "after stop"); + expect(h.launches).toHaveLength(3); + expect(h.launches.at(-1)!.context!.paseoTools!.getTool("added_echo")).toBeUndefined(); + await sendAndFinish(h, a.id, "stable after stop"); + expect(h.launches).toHaveLength(3); + expect(h.events.interrupts).toBe(0); + } finally { + await h.close(); + } +}, 60_000); + +test("plugins without a session-open hook do not make agent sessions stale", async () => { + const h = await setup(true); + try { + const a = await h.create({ x: "y" }); + await h.install( + "no-session-hook", + "export default function contribute(server) { server.on('agent.created', () => {}); return () => {}; }", + ); + await sendAndFinish(h, a.id, "after unrelated start"); + expect(h.launches).toHaveLength(1); + await h.client.reloadPlugin("no-session-hook"); + await sendAndFinish(h, a.id, "after unrelated reload"); + expect(h.launches).toHaveLength(1); + await h.client.disablePlugin("no-session-hook"); + await sendAndFinish(h, a.id, "after unrelated stop"); + expect(h.launches).toHaveLength(1); + } finally { + await h.close(); + } +}, 60_000); + +test("explicit SDK reload interrupts a running turn before reopening its session", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const h = await setup(true, reloadRpcSource, undefined, { + beforeTurnComplete: async () => blocked, + onInterrupt: () => release(), + }); + try { + const a = await h.create({ x: "y" }); + await h.client.sendMessage(a.id, "running before explicit reload", { + activeTurnBehavior: "steer", + }); + await expect + .poll(() => h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId) + .not.toBeNull(); + expect(await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id })).toEqual({ + id: a.id, + }); + expect(h.events.interrupts).toBe(1); + expect(h.launches).toHaveLength(2); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh"); + } finally { + release(); + await h.close(); + } +}, 60_000); + +test("the agent-tools example appends notes only for labeled agents", async () => { + const h = await setup(); + try { + await h.client.installDirectoryPlugin( + fileURLToPath(new URL("../../../../../plugin-examples/agent-tools", import.meta.url)), + ); + const a = await h.create({ "notes/enabled": "true" }); + const mcpA = await h.mcp(a.id); + expect((await mcpA.listTools()).tools.map((tool) => tool.name)).toContain( + "append_workspace_note", + ); + expect( + await mcpA.callTool({ + name: "append_workspace_note", + arguments: { note: "Run the targeted tests before committing." }, + }), + ).toMatchObject({ + content: [ + { type: "text", text: `Saved note to ${path.join(h.directory, "AGENT_NOTES.md")}` }, + ], + structuredContent: { path: path.join(h.directory, "AGENT_NOTES.md") }, + }); + expect(await readFile(path.join(h.directory, "AGENT_NOTES.md"), "utf8")).toBe( + "Run the targeted tests before committing.\n", + ); + await mcpA.callTool({ + name: "append_workspace_note", + arguments: { note: "Keep existing notes." }, + }); + expect(await readFile(path.join(h.directory, "AGENT_NOTES.md"), "utf8")).toBe( + "Run the targeted tests before committing.\nKeep existing notes.\n", + ); + await h.create(); + const mcpB = await h.mcp(); + expect((await mcpB.listTools()).tools.map((tool) => tool.name)).not.toContain( + "append_workspace_note", + ); + } finally { + await h.close(); + } +}, 60_000); + +const companyServer = { type: "http", url: "https://tools.example.com/mcp" } as const; +const mcpSource = ` +export default function contribute(server) { + server.addHook("agent.open.before", async ({ request }) => { + if (request.provider !== "codex") return; + return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, + env: { ...request.env, OPEN_REASON: request.reason } }; + }); + return () => {}; +} +`; + +test.each([ + { native: false, serverNames: ["paseo", "company"] }, + { native: true, serverNames: ["company"] }, +])( + "session-open MCP servers reach launch but never saved configuration (native=$native)", + async ({ native, serverNames }) => { + const h = await setup(native, mcpSource); + try { + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(a.config.mcpServers?.company).toBeUndefined(); + const snapshot = (await h.client.fetchAgent(a.id))!.agent; + expect(snapshot.id).toBe(a.id); + expect(JSON.stringify(snapshot)).not.toContain("tools.example.com"); + expect(h.launches.at(-1)!.context!.env!.OPEN_REASON).toBe("create"); + expect(Object.keys(h.launches.at(-1)!.config.mcpServers!)).toEqual(serverNames); + await h.daemon.daemon.agentManager.closeAgent(a.id); + const agentFiles = await readdir(path.join(h.daemon.paseoHome, "agents"), { + recursive: true, + }); + const recordFile = agentFiles.find((file) => file.endsWith(a.id + ".json"))!; + const record = await readFile(path.join(h.daemon.paseoHome, "agents", recordFile), "utf8"); + expect(record).not.toContain("tools.example.com"); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("session-open MCP servers are rederived on resume, SDK reload, and provider import", async () => { + const source = reloadRpcSource + .replace(' if (request.labels.x !== "y") return;', "") + .replace( + "return { ...request, env:", + `return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, env:`, + ); + const h = await setup(true, source); + try { + const a = await h.create({ x: "y" }); + const manager = h.daemon.daemon.agentManager; + await manager.closeAgent(a.id); + await manager.resumeAgentFromPersistence(a.persistence!, a.config, a.id); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("resume"); + await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id }); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh"); + const imported = await manager.importProviderSession({ + provider: "codex", + providerHandleId: "imported-session", + cwd: h.directory, + workspaceId: "import-workspace", + labels: { x: "y" }, + }); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("import"); + expect(imported.config.mcpServers?.company).toBeUndefined(); + expect(JSON.stringify(imported.persistence)).not.toContain("tools.example.com"); + expect(manager.getAgent(a.id)!.config.mcpServers?.company).toBeUndefined(); + expect(JSON.stringify(manager.getAgent(a.id)!.persistence)).not.toContain("tools.example.com"); + } finally { + await h.close(); + } +}, 60_000); + +test("installing and disabling an MCP plugin reopens idle sessions before their next prompt", async () => { + const h = await setup(true, "export default function contribute() { return () => {}; }"); + try { + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + await h.install("company-mcp", mcpSource); + await sendAndFinish(h, a.id, "after install"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer); + await h.client.disablePlugin("company-mcp"); + await sendAndFinish(h, a.id, "after disable"); + expect(h.launches).toHaveLength(3); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + await sendAndFinish(h, a.id, "stable"); + expect(h.launches).toHaveLength(3); + expect(h.events.interrupts).toBe(0); + } finally { + await h.close(); + } +}, 60_000); + +test("installing and disabling an MCP plugin never interrupts a running turn", async () => { + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const h = await setup( + true, + "export default function contribute() { return () => {}; }", + undefined, + { beforeTurnComplete: async () => blocked }, + ); + try { + const a = await h.create(); + await h.client.sendMessage(a.id, "busy", { activeTurnBehavior: "steer" }); + const manager = h.daemon.daemon.agentManager; + await expect.poll(() => manager.getAgent(a.id)!.activeTurnId).not.toBeNull(); + const turn = manager.getAgent(a.id)!.activeTurnId; + await h.install("company-mcp", mcpSource); + expect(h.launches).toHaveLength(1); + expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn); + await h.client.disablePlugin("company-mcp"); + expect(h.launches).toHaveLength(1); + expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn); + expect(h.events.interrupts).toBe(0); + release(); + await expect.poll(() => manager.hasInFlightRun(a.id)).toBe(false); + await sendAndFinish(h, a.id, "idle"); + expect(h.launches).toHaveLength(2); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + } finally { + release(); + await h.close(); + } +}, 60_000); + +test.each([ + { + target: "saved configuration", + saved: true, + name: "company", + other: false, + error: /company.*plugin tools-test.*agent saved configuration/, + }, + { + target: "saved Paseo name", + saved: true, + name: "paseo", + other: false, + error: /paseo.*plugin tools-test.*agent saved configuration/, + }, + { + target: "Paseo internal server", + saved: false, + name: "paseo", + other: false, + error: /paseo.*plugin tools-test.*Paseo internal MCP server/, + }, + { + target: "another plugin with identical config", + saved: false, + name: "company", + other: true, + error: /Plugin tools-test.*company.*plugin other-mcp/, + }, +])( + "session-open MCP collisions with $target name both owners", + async ({ saved, name, other, error }) => { + const h = await setup(true, mcpSource.replace("company:", name + ":")); + try { + if (other) await h.install("other-mcp", mcpSource); + await expect( + h.daemon.daemon.agentManager.createAgent( + { + provider: "codex", + cwd: h.directory, + ...(saved + ? { mcpServers: { [name]: { type: "http", url: "https://saved.example.com" } } } + : {}), + }, + undefined, + {}, + ), + ).rejects.toThrow(error); + expect(h.launches).toHaveLength(0); + } finally { + await h.close(); + } + }, + 60_000, +); + +test("session-open MCP servers fail on unsupported providers and leave saved config untouched", async () => { + const h = await setup(false, mcpSource, undefined, { supportsMcpServers: false }); + try { + await h.client.disablePlugin("tools-test"); + const a = await h.create(); + await h.client.enablePlugin("tools-test"); + await expect(h.daemon.daemon.agentManager.reloadAgentSession(a.id)).rejects.toThrow( + /does not support MCP servers.*plugin tools-test/, + ); + expect(a.config.mcpServers?.company).toBeUndefined(); + await expect(h.create()).rejects.toThrow(/does not support MCP servers.*plugin tools-test/); + const imported = h.daemon.daemon.agentManager.importProviderSession({ + provider: "codex", + providerHandleId: "unsupported-import", + cwd: h.directory, + workspaceId: "import-workspace", + }); + await expect(imported).rejects.toThrow(/does not support MCP servers.*plugin tools-test/); + } finally { + await h.close(); + } +}, 60_000); + +test("history opens ignore session-open MCP servers", async () => { + const h = await setup(true, mcpSource); + try { + const a = await h.create(); + await h.client.archiveAgent(a.id); + await h.install("other-mcp", mcpSource); + await h.daemon.daemon.agentManager.resumeAgentFromPersistence(a.persistence!, a.config, a.id); + expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined(); + expect(h.launches.at(-1)!.context!.mcpServers).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("session-open MCP maps are plugin-local and accumulate only across their own callbacks", async () => { + const h = await setup(true, mcpSource); + try { + await h.install( + "z-extend", + ` +export default function contribute(server) { + server.addHook("agent.open.before", async ({ request }) => { + if ("mcpServerOwners" in request) throw new Error("Daemon provenance leaked"); + if (Object.keys(request.mcpServers).length !== 0) throw new Error("Saw another plugin servers"); + return { ...request, mcpServers: { ...request.mcpServers, extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } } } }; + }); + server.addHook("agent.open.before", async ({ request }) => { + if (request.mcpServers.extra.command !== "company-tools") throw new Error("Lost own earlier server"); + return { ...request, mcpServers: { ...request.mcpServers, additional: { type: "http", url: "https://additional.example.com/mcp" } } }; + }); + server.addHook("agent.open.before", async () => undefined); + return () => {}; +}`, + ); + const a = await h.create(); + expect(h.launches.at(-1)!.config.mcpServers).toEqual({ + company: companyServer, + extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } }, + additional: { type: "http", url: "https://additional.example.com/mcp" }, + }); + const contributedServers = h.launches.at(-1)!.config.mcpServers; + await h.install( + "zz-remove", + `export default function contribute(server) { + server.addHook("agent.open.before", async ({ request }) => ({ ...request, mcpServers: {} })); + return () => {}; +}`, + ); + await sendAndFinish(h, a.id, "after removal"); + expect(h.launches.at(-1)!.config.mcpServers).toEqual(contributedServers); + expect(a.config.mcpServers).toBeUndefined(); + } finally { + await h.close(); + } +}, 60_000); + +test("session-open MCP configs are validated before a provider launches", async () => { + const h = await setup( + true, + mcpSource.replace(JSON.stringify(companyServer), '{ type: "http", url: 42 }'), + ); + try { + await expect(h.create()).rejects.toThrow(/Plugin tools-test.*agent.open.before/s); + expect(h.launches).toHaveLength(0); + } finally { + await h.close(); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/agent-tools.test.ts b/packages/server/src/server/plugins/agent-tools.test.ts new file mode 100644 index 0000000000..f3bf7b886f --- /dev/null +++ b/packages/server/src/server/plugins/agent-tools.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vitest"; +import { z } from "zod"; +import { defineAgentTool } from "@getpaseo/plugin/server"; +import { serializeAgentTool } from "./agent-tools.js"; +import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js"; + +test("agent tool schemas survive the process boundary and catalog round trip", () => { + const input = z + .object({ + title: z.string().min(1).describe("Issue title"), + state: z.enum(["open", "closed"]), + tags: z.array(z.string()).optional(), + nested: z.object({ enabled: z.boolean() }), + }) + .describe("Create an issue"); + const tool = defineAgentTool({ + name: "create_issue", + description: "Create", + input, + execute: () => ({ text: "done" }), + }); + const metadata = JSON.parse(JSON.stringify(serializeAgentTool(tool))); + const restored = z.fromJSONSchema(metadata.inputSchema); + expect( + serializePaseoToolInputParameters({ + ...metadata, + inputSchema: restored, + handler: async () => ({ content: [] }), + }), + ).toEqual({ + ...metadata.inputSchema, + additionalProperties: {}, + properties: { + ...metadata.inputSchema.properties, + nested: { ...metadata.inputSchema.properties.nested, additionalProperties: {} }, + }, + }); + expect(restored.parse({ title: "Bug", state: "open", nested: { enabled: true } })).toEqual({ + title: "Bug", + state: "open", + nested: { enabled: true }, + }); + expect( + restored.safeParse({ title: "", state: "unknown", nested: { enabled: true } }).success, + ).toBe(false); +}); diff --git a/packages/server/src/server/plugins/agent-tools.ts b/packages/server/src/server/plugins/agent-tools.ts new file mode 100644 index 0000000000..266234b446 --- /dev/null +++ b/packages/server/src/server/plugins/agent-tools.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import type { + AgentToolDefinition, + AgentToolResult, + PluginHookContext, + PluginSessionOpenRequest, + PluginHookAgent, +} from "@getpaseo/plugin/server"; +import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"; + +export const agentToolMetadataSchema = z + .object({ + name: z + .string() + .regex(/^[a-z][a-z0-9_]*$/) + .max(64), + title: z.string().optional(), + description: z.string(), + inputSchema: z.record(z.string(), z.unknown()), + pluginId: z.string().optional(), + pluginInstanceId: z.string().optional(), + }) + .strict(); +export type AgentToolMetadata = z.infer; +export type AttachedAgentTool = AgentToolMetadata & { pluginId: string; pluginInstanceId: string }; +export type SessionOpenWireRequest = Omit & { + tools: AgentToolMetadata[]; +}; + +export interface PluginAgentToolsRuntime { + isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean; + detachTools(agentId: string): void; + callTool( + pluginId: string, + pluginInstanceId: string, + agent: PluginHookAgent, + name: string, + input: unknown, + signal?: AbortSignal, + ): Promise; +} + +export function serializeAgentTool(tool: AgentToolDefinition): AgentToolMetadata { + return agentToolMetadataSchema.parse({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: toJsonSchemaCompat(tool.input, { strictUnions: true, pipeStrategy: "input" }), + }); +} + +// This store lives only in the plugin process. Definitions and parsed values never cross IPC. +export class PluginAgentTools { + private readonly agents = new Map(); + private readonly calls = new Map(); + + attach(agentId: string, tools: AgentToolDefinition[]): AgentToolMetadata[] { + const names = new Set(); + const metadata = tools.map((tool) => { + const serialized = serializeAgentTool(tool); + if (names.has(tool.name)) throw new Error(`Duplicate agent tool: ${tool.name}`); + names.add(tool.name); + return serialized; + }); + this.agents.set(agentId, tools); + return metadata; + } + + detach(agentId: string): void { + this.agents.delete(agentId); + for (const call of this.calls.values()) { + if (call.agentId === agentId) call.controller.abort(); + } + } + + cancel(requestId: string): void { + this.calls.get(requestId)?.controller.abort(); + } + + close(): void { + for (const call of this.calls.values()) call.controller.abort(); + this.agents.clear(); + } + + async call( + requestId: string, + agent: PluginHookAgent, + name: string, + input: unknown, + paseo: PluginHookContext["paseo"], + ): Promise { + const controller = new AbortController(); + this.calls.set(requestId, { agentId: agent.id, controller }); + try { + const tool = this.agents.get(agent.id)?.find((definition) => definition.name === name); + if (!tool) throw new Error(`Agent tool not attached: ${name}`); + const parsed = await tool.input.parseAsync(input); + controller.signal.throwIfAborted(); + const result = await tool.execute(parsed, { agent, paseo, signal: controller.signal }); + controller.signal.throwIfAborted(); + return result; + } catch (error) { + return { isError: true, text: error instanceof Error ? error.message : String(error) }; + } finally { + this.calls.delete(requestId); + } + } +} diff --git a/packages/server/src/server/plugins/builtin/index.test.ts b/packages/server/src/server/plugins/builtin/index.test.ts index 6160c8f855..5f4728ecfa 100644 --- a/packages/server/src/server/plugins/builtin/index.test.ts +++ b/packages/server/src/server/plugins/builtin/index.test.ts @@ -78,6 +78,45 @@ test("listed client bundle is published while plugins are disabled; unlisted dir } }, 60_000); +test("a repeated catalog fetch leaves out the client bundle the client already holds", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-catalog-cache-")); + roots.push(root); + const directory = await fixture(root, "large", true); + // About the size of a real plugin client bundle. + await writeFile( + path.join(directory, "index.client.tsx"), + `const payload = ${JSON.stringify("x".repeat(1_000_000))}; +export default function contribute() { void payload; return () => {}; }`, + ); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["large"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + const responseBytes: number[] = []; + const unsubscribe = client.subscribeRawMessages((message) => { + if (message.type === "plugin.catalog.get.response") { + responseBytes.push(JSON.stringify(message).length); + } + }); + try { + await client.connect(); + const first = await client.getPluginCatalog(); + const second = await client.getPluginCatalog(); + + expect(second).toEqual(first); + expect(second[0]?.clientBundle).toContain("x".repeat(1_000_000)); + expect(responseBytes).toHaveLength(2); + expect(responseBytes[0]).toBeGreaterThan(1_000_000); + expect(responseBytes[1]).toBeLessThan(500); + } finally { + unsubscribe(); + await client.close(); + await daemon.close(); + } +}, 60_000); + test("directory, Git, and npm installs reject a built-in ID", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-install-")); roots.push(root); diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts index cf6ffe9eee..463ee8407d 100644 --- a/packages/server/src/server/plugins/builtin/index.ts +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -14,6 +14,7 @@ export const builtinPlugins = [ "minimax-usage-source", "muse-provider", "opencode-go-usage-source", + "worktree-include", "zai-usage-source", ] as const; diff --git a/packages/server/src/server/plugins/compiler-build.ts b/packages/server/src/server/plugins/compiler-build.ts new file mode 100644 index 0000000000..51eb2d2549 --- /dev/null +++ b/packages/server/src/server/plugins/compiler-build.ts @@ -0,0 +1,450 @@ +import { stat } from "node:fs/promises"; +import { readPluginManifest } from "./manifest.js"; +import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { isBuiltin } from "node:module"; +import path from "node:path"; +import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js"; +import type { Metafile, OnResolveResult, Plugin } from "esbuild"; +import { + isPluginClientOnlySdkSpecifier, + isPluginServerOnlySdkSpecifier, + PLUGIN_SDK_SPECIFIERS, +} from "./plugin-sdk-specifiers.js"; + +import { loadEsbuild, SERVER_HOST_MODULES } from "./compiler-esbuild.js"; + +type PluginBuildTarget = "client" | "server"; + +type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid"; + +function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null { + const relative = path.relative(pluginDirectory, filePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid"; + const segments = relative.split(path.sep); + if (segments.includes("node_modules")) return null; + if (segments[0] === "client") return "client"; + if (segments[0] === "server") return "server"; + if (segments[0] === "shared") return "shared"; + if (/^index\.client\.tsx?$/.test(relative)) return "client"; + if (/^index\.server\.tsx?$/.test(relative)) return "server"; + return "invalid"; +} + +function containsPath(directory: string, filePath: string): boolean { + const relative = path.relative(directory, filePath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function dependencyName(specifier: string): string { + const segments = specifier.split("/"); + return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; +} + +function findDependencyRoot( + resolvedPath: string, + specifier: string, + pluginDirectory: string, +): string | null { + const expectedName = dependencyName(specifier); + const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`; + let directory = path.dirname(resolvedPath); + for (;;) { + const manifestPath = path.join(directory, "package.json"); + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown }; + if (manifest.name === expectedName || manifest.name === ambientName) { + if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) { + return null; + } + return directory; + } + } + const parent = path.dirname(directory); + if (parent === directory) return null; + directory = parent; + } +} + +function moduleBoundaryError( + moduleLocation: PluginModuleLocation | null, + target: PluginBuildTarget | "shared", + filePath: string, +): OnResolveResult | null { + if (moduleLocation === "invalid") { + return { + errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }], + }; + } + if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) { + return null; + } + return { + errors: [ + { + text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`, + }, + ], + }; +} + +function lexicalBoundaryError( + specifier: string, + resolveDirectory: string, + pluginDirectory: string, + target: PluginBuildTarget | "shared", +): OnResolveResult | null { + if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null; + const lexicalPath = path.resolve(resolveDirectory, specifier); + if (containsPath(pluginDirectory, lexicalPath)) { + return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); + } + // Normalize only a containing root alias. Resolving the whole import would erase + // an authored server/ or client/ location when the final file is a symlink. + for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) { + if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) { + const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath)); + return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath); + } + if (path.dirname(ancestor) === ancestor) return null; + } +} + +function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { + const boundaryResolution = {}; + const linkedDependencyRoots = new Set(); + return { + name: `paseo-plugin-${target}-runtime-boundary`, + setup(buildContext) { + const checked = new Set(); + const imports = createPluginImportReader(pluginDirectory); + function resolvedBoundaryError( + resolvedFile: string, + specifier: string, + importer: string, + owner: PluginBuildTarget | "shared", + ) { + const file = realpathSync.native(resolvedFile); + const location = directoryTarget(file, pluginDirectory); + if (location === "invalid") { + if ( + [...linkedDependencyRoots].some( + (root) => containsPath(root, importer) && containsPath(root, file), + ) + ) + return null; + if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) { + const root = findDependencyRoot(file, specifier, pluginDirectory); + if (root) { + linkedDependencyRoots.add(root); + return null; + } + } + } + return moduleBoundaryError(location, owner, file); + } + async function resolveImportFiles( + file: string, + specifier: string, + kind: PluginImportKind, + typeOnly: boolean, + ): Promise> { + const declaration = imports.resolve(specifier, file, kind); + const dependencyFiles = new Set(); + if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration))) + dependencyFiles.add(declaration); + if (typeOnly && !declaration) { + throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`); + } + if (!typeOnly && kind !== "type-reference") { + const resolution = await buildContext.resolve(specifier, { + importer: file, + resolveDir: path.dirname(file), + kind, + }); + // esbuild owns missing-runtime errors: erased imports and guarded optional + // requires are legal. Inspect every dependency that actually resolves. + if (!resolution.errors.length && !resolution.external && resolution.namespace === "file") + dependencyFiles.add(realpathSync.native(resolution.path)); + } + return dependencyFiles; + } + async function checkSourceImports( + sourcePath: string, + inheritedOwner: PluginBuildTarget | "shared", + ): Promise { + const file = realpathSync.native(sourcePath); + const owner = + directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner; + const key = `${owner}:${file}`; + if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null; + checked.add(key); + for (const { specifier, kind, typeOnly } of imports.read(file)) { + const packageSpecifier = + kind === "type-reference" + ? `@types/${specifier.replace(/^@/, "").replace("/", "__")}` + : specifier; + const error = + runtimeSpecifierError(specifier, owner, file) ?? + runtimeSpecifierError(packageSpecifier, owner, file) ?? + lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); + if (error) return error; + // Protocol types are shared host contracts, also exposed by the SDK's peer + // dependency during development. Erased imports need no plugin-local install; + // runtime imports still go through normal dependency resolution below. + if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue; + // Host modules have separately enforced SDK boundaries and need no local installation. + if ( + (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || + /^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) || + isBuiltin(specifier) || + packageSpecifier === "@types/node" + ) + continue; + const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly); + for (const dependencyFile of dependencyFiles) { + const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner); + if (boundaryError) return boundaryError; + const dependencyError = await checkSourceImports(dependencyFile, owner); + if (dependencyError) return dependencyError; + } + } + return null; + } + buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) => + checkSourceImports(args.path, target), + ); + buildContext.onResolve({ filter: /.*/ }, async (args) => { + if (args.kind === "entry-point") return null; + if (args.pluginData === boundaryResolution) return null; + const importer = + args.namespace === "file" ? realpathSync.native(args.importer) : args.importer; + const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir; + const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target; + const specifierError = runtimeSpecifierError(args.path, owner, importer); + if (specifierError) return specifierError; + const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner); + if (lexicalError) return lexicalError; + const resolution = await buildContext.resolve(args.path, { + importer, + namespace: args.namespace, + resolveDir, + kind: args.kind, + pluginData: boundaryResolution, + with: args.with, + }); + if ( + resolution.errors.length > 0 || + resolution.external || + resolution.namespace !== "file" + ) { + return null; + } + return resolvedBoundaryError(resolution.path, args.path, importer, owner); + }); + }, + }; +} + +function wrapCommonJsBundle(code: string): string { + return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`; +} + +function makeHermesInteropEager(code: string): string { + // Hermes evaluates esbuild's lazy CommonJS interop getters from a string with + // the final loop binding, so every named import can resolve to the last export. + // Plugin bundles execute once and do not need live bindings from host modules. + return code.replaceAll("get: () => from[key]", "value: from[key]"); +} + +async function minifyClientBundle(code: string): Promise { + const { transform } = loadEsbuild(); + // Minify after the eager replace. Whitespace minify rewrites the getter this + // replace matches, and identifier minify can delete the helper entirely. + const result = await transform(code, { + minifyWhitespace: true, + minifySyntax: true, + minifyIdentifiers: false, + target: "es2020", + supported: { "async-await": false }, + charset: "ascii", + legalComments: "inline", + }); + return result.code; +} + +function runtimeSpecifierError( + specifier: string, + target: PluginBuildTarget | "shared", + importer: string, +): OnResolveResult | null { + let kind: string | null = null; + if (specifier === "@getpaseo/plugin/client/host") kind = "host-private"; + else if ( + (specifier === "@getpaseo/plugin" || + specifier.startsWith("@getpaseo/plugin/") || + specifier === "@paseo/plugin" || + specifier.startsWith("@paseo/plugin/")) && + !(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) + ) + kind = "Unknown SDK"; + else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node")) + kind = "Node"; + else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only"; + else if ( + target !== "client" && + (isPluginClientOnlySdkSpecifier(specifier) || + /^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test( + specifier, + )) + ) + kind = "client-only"; + return kind + ? { + errors: [ + { + text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`, + }, + ], + } + : null; +} + +function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void { + function inputLocation(file: string): PluginModuleLocation | null { + const absolutePath = path.resolve(file); + // Metafile keys must stay unchanged for graph traversal. Only filesystem + // inputs have canonical paths; data URLs and external specifiers do not. + return directoryTarget( + existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath, + pluginDirectory, + ); + } + const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared"); + const visited = new Set(); + while (pending.length) { + const file = pending.pop()!; + if (visited.has(file)) continue; + visited.add(file); + for (const dependency of inputs[file]?.imports ?? []) { + const location = dependency.external ? null : inputLocation(dependency.path); + const error = + runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ?? + (dependency.external + ? null + : moduleBoundaryError( + location === "invalid" ? null : location, + "shared", + dependency.path, + )); + if (error?.errors?.length) throw new Error(error.errors[0].text); + if (!dependency.external) pending.push(dependency.path); + } + } +} + +async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { + const { build } = loadEsbuild(); + // Use native canonical paths throughout: TypeScript expands Windows short names + // when resolving type references, while the JS realpath implementation retains them. + const pluginDirectory = realpathSync.native(path.dirname(entryPath)); + const result = await build({ + entryPoints: [realpathSync.native(entryPath)], + bundle: true, + format: "cjs", + jsx: "automatic", + // `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon. + loader: { ".svg": "text" }, + platform: target === "server" ? "node" : "neutral", + target: target === "server" ? "node20" : "es2020", + // The neutral platform reads no package.json entry fields, so packages without + // `exports` would not resolve. Read the same fields as esbuild's browser platform + // minus `browser`, since the bundle also runs in React Native. + mainFields: target === "client" ? ["module", "main"] : undefined, + // Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro, + // so apply the same compatibility transform before the app evaluates them from source. + supported: target === "client" ? { "async-await": false } : undefined, + external: + target === "client" + ? [ + ...PLUGIN_SDK_SPECIFIERS, + "@tanstack/react-query", + "react", + "react/jsx-runtime", + "react-native", + "zod", + ] + : SERVER_HOST_MODULES, + plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], + metafile: true, + logLevel: "silent", + treeShaking: true, + write: false, + }); + checkSharedDependencies(result.metafile.inputs, pluginDirectory); + const output = result.outputFiles[0]?.text; + if (!output) throw new Error(`Plugin ${target} compilation produced no output`); + const eager = makeHermesInteropEager(output); + let prepared = eager; + if (target === "client") prepared = await minifyClientBundle(eager); + return wrapCommonJsBundle(prepared); +} + +export async function compilePlugin(entryPaths: { + client: string | null; + server: string | null; +}): Promise<{ + clientBundle: string | null; + serverBundle: string | null; +}> { + const [clientBundle, serverBundle] = await Promise.all([ + entryPaths.client ? compileTarget(entryPaths.client, "client") : null, + entryPaths.server ? compileTarget(entryPaths.server, "server") : null, + ]); + return { clientBundle, serverBundle }; +} + +const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; +const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; +async function findEntry(directory: string, filenames: readonly string[]): Promise { + for (const filename of filenames) { + const filePath = path.join(directory, filename); + const info = await stat(filePath).catch(() => null); + if (info?.isFile()) return filePath; + } + return null; +} + +async function resolveEntryPaths(directory: string): Promise<{ + client: string | null; + server: string | null; +}> { + const [client, server] = await Promise.all([ + findEntry(directory, CLIENT_ENTRY_FILENAMES), + findEntry(directory, SERVER_ENTRY_FILENAMES), + ]); + if (client || server) return { client, server }; + const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]); + if (legacyEntry) { + throw new Error( + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration", + ); + } + throw new Error( + `Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`, + ); +} + +export async function compileDirectoryPlugin( + directory: string, + version: string, + builtinId?: string, +) { + const manifest = await readPluginManifest(directory); + if (builtinId !== undefined && manifest.id !== builtinId) { + throw new Error(`Built-in plugin ${builtinId} has manifest ID ${manifest.id}`); + } + assertPluginCompatibility({ ...manifest, version, runtime: "daemon" }); + const bundles = await compilePlugin(await resolveEntryPaths(directory)); + return { ...bundles, manifest }; +} diff --git a/packages/server/src/server/plugins/compiler-esbuild.ts b/packages/server/src/server/plugins/compiler-esbuild.ts new file mode 100644 index 0000000000..c36c9c22f9 --- /dev/null +++ b/packages/server/src/server/plugins/compiler-esbuild.ts @@ -0,0 +1,64 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { PLUGIN_SDK_SPECIFIERS } from "./plugin-sdk-specifiers.js"; +const nodeRequire = createRequire(import.meta.url); +export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"]; +const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH"; + +// esbuild resolves its own platform binary via require.resolve() the first time its +// module is evaluated. Inside the packaged desktop app that resolves to a path under +// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked. +// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path +// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads. +export function unpackedEsbuildBinaryFromPackageDir( + esbuildPackageDir: string, + platform: NodeJS.Platform, + arch: string, +): string | null { + const asarSegment = `${path.sep}app.asar${path.sep}`; + const asarIndex = esbuildPackageDir.indexOf(asarSegment); + if (asarIndex === -1) return null; + return path.join( + esbuildPackageDir.slice(0, asarIndex), + "app.asar.unpacked", + "node_modules", + `@esbuild/${platform}-${arch}`, + ...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]), + ); +} + +export function resolveExistingAsarUnpackedEsbuildBinary( + esbuildPackageDir: string, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, + exists: (file: string) => boolean = existsSync, +): string | null { + const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch); + return binaryPath && exists(binaryPath) ? binaryPath : null; +} + +function resolveAsarUnpackedEsbuildBinary(): string | null { + let esbuildDir: string; + try { + esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json")); + } catch { + return null; + } + return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir); +} + +export function loadEsbuild(): typeof import("esbuild") { + const previousBinaryPath = process.env[ESBUILD_BINARY_PATH]; + const unpackedBinary = resolveAsarUnpackedEsbuildBinary(); + if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary; + + try { + // esbuild reads this variable while its CommonJS module is evaluated. Keep + // the compatibility bridge local so it cannot become an agent's environment. + return nodeRequire("esbuild") as typeof import("esbuild"); + } finally { + if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH]; + else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath; + } +} diff --git a/packages/server/src/server/plugins/compiler-worker.ts b/packages/server/src/server/plugins/compiler-worker.ts new file mode 100644 index 0000000000..ddf2544b8b --- /dev/null +++ b/packages/server/src/server/plugins/compiler-worker.ts @@ -0,0 +1,62 @@ +import { parentPort } from "node:worker_threads"; +import type { ZodIssue } from "zod"; +import { compilePlugin, compileDirectoryPlugin } from "./compiler-build.js"; +import { runPluginBuild } from "./preparation-build.js"; +import type { CompilerTask } from "./compiler.js"; + +export type CompilerReply = + | { kind: "log"; data: Record; message: string } + | { kind: "result"; value: unknown } + | { + kind: "error"; + error: Error; + properties: Record; + issues?: ZodIssue[]; + errors?: unknown[]; + warnings?: unknown[]; + }; + +const port = parentPort!; +// The compiler owner serializes requests; the port queues the first request +// until this entrypoint has loaded, including in source/tsx development. +port.on("message", (task: CompilerTask) => { + void run(task); +}); + +async function run(task: CompilerTask): Promise { + try { + let value: unknown; + switch (task.kind) { + case "entries": + value = await compilePlugin(task.entries); + break; + case "directory": + value = await compileDirectoryPlugin(task.directory, task.version, task.builtinId); + break; + case "prepare": + await runPluginBuild( + task.directory, + task.commands, + { + info: (data, message) => + port.postMessage({ kind: "log", data, message } satisfies CompilerReply), + }, + task.environment, + ); + break; + } + port.postMessage({ kind: "result", value } satisfies CompilerReply); + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + port.postMessage({ + kind: "error", + error, + // Error cloning drops own metadata such as filesystem code/path. Zod's + // readonly issues are reconstructed separately by the compiler owner. + properties: Object.fromEntries(Object.entries(error).filter(([key]) => key !== "issues")), + ...("issues" in error ? { issues: error.issues as ZodIssue[] } : {}), + ...("errors" in error ? { errors: error.errors as unknown[] } : {}), + ...("warnings" in error ? { warnings: error.warnings as unknown[] } : {}), + } satisfies CompilerReply); + } +} diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index 6f694fbaa3..69510cdfb0 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -3,8 +3,11 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import * as zod from "zod"; +import { ZodError } from "zod"; import { compilePlugin, + compileDirectoryPlugin, resolveExistingAsarUnpackedEsbuildBinary, unpackedEsbuildBinaryFromPackageDir, } from "./compiler.js"; @@ -567,6 +570,101 @@ export default function contribute(client) { expect(clientBundle).toContain("__async"); }); + it("reduces client bytes while preserving registration and leaving server output readable", async () => { + const entries = await createSplitPlugin(); + const source = `import { z } from "zod"; +const commandSchema = z.object({ id: z.string(), title: z.string() }); +export default function contribute(client) { + const command = commandSchema.parse({ id: "open", title: "Open – plugin" }); + client.addCommandCenterItem({ + ...command, + icon: "Scan", + context: "global", + onSelect() { return Promise.resolve(command.title); }, + }); + return () => undefined; +}`; + await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]); + const { clientBundle, serverBundle } = await compilePlugin(entries); + if (clientBundle === null || serverBundle === null) { + throw new Error("compilePlugin returned no bundle"); + } + + for (const bundle of [clientBundle, serverBundle]) { + const registered: { id: string; title: string; onSelect: () => Promise }[] = []; + const exports = new Function(`return ${bundle}`)()((specifier: string) => { + expect(specifier).toBe("zod"); + return zod; + }); + const cleanup = exports.default({ + addCommandCenterItem: (command: (typeof registered)[number]) => registered.push(command), + }); + expect(registered).toMatchObject([{ id: "open", title: "Open – plugin" }]); + await expect(registered[0]!.onSelect()).resolves.toBe("Open – plugin"); + expect(cleanup()).toBeUndefined(); + } + expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9); + expect(clientBundle.split("").every((char) => char.charCodeAt(0) <= 0x7f)).toBe(true); + expect(serverBundle).toContain('title: "Open'); + }); + + it("retains dependency license notices while minifying executable client code", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/licensed-dependency"); + const notice = "Copyright Example Authors. SPDX-License-Identifier: MIT"; + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "licensed-dependency", main: "index.js" }), + ); + await writeFile( + path.join(dependency, "index.js"), + `/*! ${notice} */ +exports.title = function title(input) { + const trimmed = input.trim(); + return "Licensed " + trimmed; +};`, + ); + const source = `import { title } from "licensed-dependency"; +export default function contribute() { return title(" plugin "); }`; + await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]); + const { clientBundle, serverBundle } = await compilePlugin(entries); + if (clientBundle === null || serverBundle === null) { + throw new Error("compilePlugin returned no bundle"); + } + + for (const bundle of [clientBundle, serverBundle]) { + expect(bundle).toContain(notice); + const exports = new Function(`return ${bundle}`)()(() => { + throw new Error("The dependency must be bundled"); + }); + expect(exports.default()).toBe("Licensed plugin"); + } + expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9); + }); + + it("keeps distinct host exports eager when minifying in the worker", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.client, + `import * as host from "zod"; +export default function contribute() { return [host.first, host.second]; }`, + ); + const { clientBundle } = await compilePlugin({ client: entries.client, server: null }); + const host = { first: "first export", second: "second export" }; + const factory = new Function(`return ${clientBundle}`)() as ( + require: (specifier: string) => typeof host, + ) => { default: () => unknown }; + function requireHost(specifier: string): typeof host { + expect(specifier).toBe("zod"); + return host; + } + const exports = factory(requireHost); + host.first = "changed first export"; + host.second = "changed second export"; + expect(exports.default()).toEqual(["first export", "second export"]); + }); + it("rejects node imports from the client entry", async () => { const entries = await createSplitPlugin(); await writeFile( @@ -975,3 +1073,52 @@ export default function contribute() { void value; void secret; return () => und expect(serverBundle).toBeNull(); }); }); + +it("preserves esbuild diagnostics across the compiler boundary", async () => { + const { server } = await createSplitPlugin(); + await writeFile(server, "export default function contribute( {"); + const error = await compilePlugin({ client: null, server }).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + name: "Error", + message: expect.stringContaining("Expected identifier but found end of file"), + errors: [ + expect.objectContaining({ + text: "Expected identifier but found end of file", + location: expect.objectContaining({ + line: 1, + lineText: "export default function contribute( {", + }), + }), + ], + warnings: [], + }); + // A failed task does not poison the next build. + await writeFile(server, 'export default () => "recovered";'); + const bundles = await compilePlugin({ client: null, server }); + expect(bundles.serverBundle).toContain("recovered"); +}); + +it("preserves manifest validation errors across the compiler boundary", async () => { + const { directory } = await createSplitPlugin(); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: 42 })); + const error = await compileDirectoryPlugin(directory, "0.13.2").catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(ZodError); + expect(error).toMatchObject({ + name: "ZodError", + issues: [expect.objectContaining({ path: ["id"], code: "invalid_type" })], + }); + await writeFile(path.join(directory, "paseo-plugin.json"), "{"); + await expect(compileDirectoryPlugin(directory, "0.13.2")).rejects.toBeInstanceOf(SyntaxError); +}); + +it("preserves filesystem error metadata for a missing entry", async () => { + const { server } = await createSplitPlugin(); + await rm(server); + await expect(compilePlugin({ client: null, server })).rejects.toMatchObject({ + code: "ENOENT", + errno: expect.any(Number), + syscall: "realpath", + path: server, + }); +}); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 4cf89a902c..b96c8ecd4e 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -1,442 +1,152 @@ -import { existsSync, readFileSync, realpathSync } from "node:fs"; -import { createRequire, isBuiltin } from "node:module"; -import path from "node:path"; -import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js"; -import type { Metafile, OnResolveResult, Plugin } from "esbuild"; -import { - isPluginClientOnlySdkSpecifier, - isPluginServerOnlySdkSpecifier, - PLUGIN_SDK_SPECIFIERS, -} from "./plugin-sdk-specifiers.js"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { Worker } from "node:worker_threads"; +import pLimit from "p-limit"; +import { ZodError } from "zod"; +import type * as build from "./compiler-build.js"; +import type { PluginManifest } from "./manifest.js"; +import type { CompilerReply } from "./compiler-worker.js"; -const nodeRequire = createRequire(import.meta.url); -export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"]; -const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH"; +export { + SERVER_HOST_MODULES, + resolveExistingAsarUnpackedEsbuildBinary, + unpackedEsbuildBinaryFromPackageDir, +} from "./compiler-esbuild.js"; -// esbuild resolves its own platform binary via require.resolve() the first time its -// module is evaluated. Inside the packaged desktop app that resolves to a path under -// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked. -// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path -// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads. -export function unpackedEsbuildBinaryFromPackageDir( - esbuildPackageDir: string, - platform: NodeJS.Platform, - arch: string, -): string | null { - const asarSegment = `${path.sep}app.asar${path.sep}`; - const asarIndex = esbuildPackageDir.indexOf(asarSegment); - if (asarIndex === -1) return null; - return path.join( - esbuildPackageDir.slice(0, asarIndex), - "app.asar.unpacked", - "node_modules", - `@esbuild/${platform}-${arch}`, - ...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]), - ); -} - -export function resolveExistingAsarUnpackedEsbuildBinary( - esbuildPackageDir: string, - platform: NodeJS.Platform = process.platform, - arch: string = process.arch, - exists: (file: string) => boolean = existsSync, -): string | null { - const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch); - return binaryPath && exists(binaryPath) ? binaryPath : null; -} - -function resolveAsarUnpackedEsbuildBinary(): string | null { - let esbuildDir: string; - try { - esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json")); - } catch { - return null; - } - return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir); -} - -function loadEsbuild(): typeof import("esbuild") { - const previousBinaryPath = process.env[ESBUILD_BINARY_PATH]; - const unpackedBinary = resolveAsarUnpackedEsbuildBinary(); - if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary; - - try { - // esbuild reads this variable while its CommonJS module is evaluated. Keep - // the compatibility bridge local so it cannot become an agent's environment. - return nodeRequire("esbuild") as typeof import("esbuild"); - } finally { - if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH]; - else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath; - } -} - -type PluginBuildTarget = "client" | "server"; - -type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid"; - -function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null { - const relative = path.relative(pluginDirectory, filePath); - if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid"; - const segments = relative.split(path.sep); - if (segments.includes("node_modules")) return null; - if (segments[0] === "client") return "client"; - if (segments[0] === "server") return "server"; - if (segments[0] === "shared") return "shared"; - if (/^index\.client\.tsx?$/.test(relative)) return "client"; - if (/^index\.server\.tsx?$/.test(relative)) return "server"; - return "invalid"; -} - -function containsPath(directory: string, filePath: string): boolean { - const relative = path.relative(directory, filePath); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function dependencyName(specifier: string): string { - const segments = specifier.split("/"); - return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]; -} - -function findDependencyRoot( - resolvedPath: string, - specifier: string, - pluginDirectory: string, -): string | null { - const expectedName = dependencyName(specifier); - const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`; - let directory = path.dirname(resolvedPath); - for (;;) { - const manifestPath = path.join(directory, "package.json"); - if (existsSync(manifestPath)) { - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown }; - if (manifest.name === expectedName || manifest.name === ambientName) { - if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) { - return null; - } - return directory; - } - } - const parent = path.dirname(directory); - if (parent === directory) return null; - directory = parent; - } -} - -function moduleBoundaryError( - moduleLocation: PluginModuleLocation | null, - target: PluginBuildTarget | "shared", - filePath: string, -): OnResolveResult | null { - if (moduleLocation === "invalid") { - return { - errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }], +export type CompilerTask = + | { kind: "entries"; entries: Parameters[0] } + | { kind: "directory"; directory: string; version: string; builtinId?: string } + | { + kind: "prepare"; + directory: string; + commands: PluginManifest["build"]; + environment: NodeJS.ProcessEnv; }; - } - if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) { - return null; - } - return { - errors: [ - { - text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`, - }, - ], - }; + +interface TaskResult { + entries: Awaited>; + directory: Awaited>; + prepare: void; } -function lexicalBoundaryError( - specifier: string, - resolveDirectory: string, - pluginDirectory: string, - target: PluginBuildTarget | "shared", -): OnResolveResult | null { - if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null; - const lexicalPath = path.resolve(resolveDirectory, specifier); - if (containsPath(pluginDirectory, lexicalPath)) { - return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); - } - // Normalize only a containing root alias. Resolving the whole import would erase - // an authored server/ or client/ location when the final file is a symlink. - for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) { - if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) { - const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath)); - return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath); - } - if (path.dirname(ancestor) === ancestor) return null; - } -} +const IDLE_TIMEOUT_MS = 1_000; +const source = import.meta.url.endsWith(".ts"); +const workerUrl = new URL( + source ? "./compiler-worker.ts" : "./compiler-worker.js", + import.meta.url, +); +// Resolve the development loader once at module initialization, never during a +// compile request. Packaged workers execute ordinary JavaScript without tsx. +const workerEntrypoint = source + ? `import(${JSON.stringify(pathToFileURL(createRequire(import.meta.url).resolve("tsx/esm/api")).href)}).then(({ register }) => { register(); return import(${JSON.stringify(workerUrl.href)}); });` + : workerUrl; -function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { - const boundaryResolution = {}; - const linkedDependencyRoots = new Set(); - return { - name: `paseo-plugin-${target}-runtime-boundary`, - setup(buildContext) { - const checked = new Set(); - const imports = createPluginImportReader(pluginDirectory); - function resolvedBoundaryError( - resolvedFile: string, - specifier: string, - importer: string, - owner: PluginBuildTarget | "shared", - ) { - const file = realpathSync.native(resolvedFile); - const location = directoryTarget(file, pluginDirectory); - if (location === "invalid") { - if ( - [...linkedDependencyRoots].some( - (root) => containsPath(root, importer) && containsPath(root, file), - ) - ) - return null; - if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) { - const root = findDependencyRoot(file, specifier, pluginDirectory); - if (root) { - linkedDependencyRoots.add(root); - return null; - } +// Reuse the isolate during a build batch (notably daemon bootstrap), then release +// TypeScript's heap when idle. Only this owner admits work or stops the worker. +class PluginCompiler { + private readonly queue = pLimit(1); + private worker: Worker | null = null; + private idleTimer: ReturnType | null = null; + private stopping: Promise = Promise.resolve(); + + run( + task: T, + onLog?: (data: Record, message: string) => void, + ): Promise { + return this.queue(async () => { + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = null; + await this.stopping; + const worker = this.worker ?? this.startWorker(); + worker.ref(); + try { + return await this.request(worker, task, onLog); + } finally { + if (this.worker === worker) { + worker.unref(); + this.idleTimer = setTimeout(() => this.stopWorker(worker), IDLE_TIMEOUT_MS); + this.idleTimer.unref(); + } else { + await this.stopping; + } + } + }); + } + + private startWorker(): Worker { + const worker = new Worker(workerEntrypoint, { eval: source, execArgv: [] }); + this.worker = worker; + // Keep error/exit ownership while idle too. The next caller gets a fresh + // isolate after a crash; author build commands are never retried. + worker.on("error", () => this.stopWorker(worker)); + worker.on("exit", () => { + if (this.worker === worker) this.stopWorker(worker); + }); + return worker; + } + + private stopWorker(worker: Worker): void { + if (this.worker !== worker) return; + this.worker = null; + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = null; + this.stopping = worker.terminate(); + } + + private async request( + worker: Worker, + task: T, + onLog?: (data: Record, message: string) => void, + ): Promise { + let onMessage: (reply: CompilerReply) => void; + let onError: (error: Error) => void; + let onExit: (code: number) => void; + try { + return await new Promise((resolve, reject) => { + onError = reject; + onExit = (code) => reject(new Error(`Plugin compiler worker exited (code ${code})`)); + onMessage = (reply) => { + if (reply.kind === "log") { + onLog?.(reply.data, reply.message); + } else if (reply.kind === "result") { + resolve(reply.value as TaskResult[T["kind"]]); + } else { + const error = reply.issues ? new ZodError(reply.issues) : reply.error; + Object.assign(error, reply.properties); + if (reply.errors) Object.assign(error, { errors: reply.errors }); + if (reply.warnings) Object.assign(error, { warnings: reply.warnings }); + // Promise.all can reject while the other target is still compiling. + // Retire the isolate before another request can enter it. + this.stopWorker(worker); + reject(error); } - } - return moduleBoundaryError(location, owner, file); - } - async function resolveImportFiles( - file: string, - specifier: string, - kind: PluginImportKind, - typeOnly: boolean, - ): Promise> { - const declaration = imports.resolve(specifier, file, kind); - const dependencyFiles = new Set(); - if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration))) - dependencyFiles.add(declaration); - if (typeOnly && !declaration) { - throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`); - } - if (!typeOnly && kind !== "type-reference") { - const resolution = await buildContext.resolve(specifier, { - importer: file, - resolveDir: path.dirname(file), - kind, - }); - // esbuild owns missing-runtime errors: erased imports and guarded optional - // requires are legal. Inspect every dependency that actually resolves. - if (!resolution.errors.length && !resolution.external && resolution.namespace === "file") - dependencyFiles.add(realpathSync.native(resolution.path)); - } - return dependencyFiles; - } - async function checkSourceImports( - sourcePath: string, - inheritedOwner: PluginBuildTarget | "shared", - ): Promise { - const file = realpathSync.native(sourcePath); - const owner = - directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner; - const key = `${owner}:${file}`; - if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null; - checked.add(key); - for (const { specifier, kind, typeOnly } of imports.read(file)) { - const packageSpecifier = - kind === "type-reference" - ? `@types/${specifier.replace(/^@/, "").replace("/", "__")}` - : specifier; - const error = - runtimeSpecifierError(specifier, owner, file) ?? - runtimeSpecifierError(packageSpecifier, owner, file) ?? - lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); - if (error) return error; - // Protocol types are shared host contracts, also exposed by the SDK's peer - // dependency during development. Erased imports need no plugin-local install; - // runtime imports still go through normal dependency resolution below. - if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue; - // Host modules have separately enforced SDK boundaries and need no local installation. - if ( - (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || - /^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) || - isBuiltin(specifier) || - packageSpecifier === "@types/node" - ) - continue; - const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly); - for (const dependencyFile of dependencyFiles) { - const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner); - if (boundaryError) return boundaryError; - const dependencyError = await checkSourceImports(dependencyFile, owner); - if (dependencyError) return dependencyError; - } - } - return null; - } - buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) => - checkSourceImports(args.path, target), - ); - buildContext.onResolve({ filter: /.*/ }, async (args) => { - if (args.kind === "entry-point") return null; - if (args.pluginData === boundaryResolution) return null; - const importer = - args.namespace === "file" ? realpathSync.native(args.importer) : args.importer; - const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir; - const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target; - const specifierError = runtimeSpecifierError(args.path, owner, importer); - if (specifierError) return specifierError; - const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner); - if (lexicalError) return lexicalError; - const resolution = await buildContext.resolve(args.path, { - importer, - namespace: args.namespace, - resolveDir, - kind: args.kind, - pluginData: boundaryResolution, - with: args.with, - }); - if ( - resolution.errors.length > 0 || - resolution.external || - resolution.namespace !== "file" - ) { - return null; - } - return resolvedBoundaryError(resolution.path, args.path, importer, owner); + }; + worker.on("error", onError); + worker.on("exit", onExit); + worker.on("message", onMessage); + worker.postMessage(task, []); }); - }, - }; -} - -function wrapCommonJsBundle(code: string): string { - return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`; -} - -function makeHermesInteropEager(code: string): string { - // Hermes evaluates esbuild's lazy CommonJS interop getters from a string with - // the final loop binding, so every named import can resolve to the last export. - // Plugin bundles execute once and do not need live bindings from host modules. - return code.replaceAll("get: () => from[key]", "value: from[key]"); -} - -function runtimeSpecifierError( - specifier: string, - target: PluginBuildTarget | "shared", - importer: string, -): OnResolveResult | null { - let kind: string | null = null; - if (specifier === "@getpaseo/plugin/client/host") kind = "host-private"; - else if ( - (specifier === "@getpaseo/plugin" || - specifier.startsWith("@getpaseo/plugin/") || - specifier === "@paseo/plugin" || - specifier.startsWith("@paseo/plugin/")) && - !(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) - ) - kind = "Unknown SDK"; - else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node")) - kind = "Node"; - else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only"; - else if ( - target !== "client" && - (isPluginClientOnlySdkSpecifier(specifier) || - /^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test( - specifier, - )) - ) - kind = "client-only"; - return kind - ? { - errors: [ - { - text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`, - }, - ], - } - : null; -} - -function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void { - function inputLocation(file: string): PluginModuleLocation | null { - const absolutePath = path.resolve(file); - // Metafile keys must stay unchanged for graph traversal. Only filesystem - // inputs have canonical paths; data URLs and external specifiers do not. - return directoryTarget( - existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath, - pluginDirectory, - ); - } - const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared"); - const visited = new Set(); - while (pending.length) { - const file = pending.pop()!; - if (visited.has(file)) continue; - visited.add(file); - for (const dependency of inputs[file]?.imports ?? []) { - const location = dependency.external ? null : inputLocation(dependency.path); - const error = - runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ?? - (dependency.external - ? null - : moduleBoundaryError( - location === "invalid" ? null : location, - "shared", - dependency.path, - )); - if (error?.errors?.length) throw new Error(error.errors[0].text); - if (!dependency.external) pending.push(dependency.path); + } finally { + worker.off("message", onMessage!); + worker.off("error", onError!); + worker.off("exit", onExit!); } } } -async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { - const { build } = loadEsbuild(); - // Use native canonical paths throughout: TypeScript expands Windows short names - // when resolving type references, while the JS realpath implementation retains them. - const pluginDirectory = realpathSync.native(path.dirname(entryPath)); - const result = await build({ - entryPoints: [realpathSync.native(entryPath)], - bundle: true, - format: "cjs", - jsx: "automatic", - // `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon. - loader: { ".svg": "text" }, - platform: target === "server" ? "node" : "neutral", - target: target === "server" ? "node20" : "es2020", - // The neutral platform reads no package.json entry fields, so packages without - // `exports` would not resolve. Read the same fields as esbuild's browser platform - // minus `browser`, since the bundle also runs in React Native. - mainFields: target === "client" ? ["module", "main"] : undefined, - // Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro, - // so apply the same compatibility transform before the app evaluates them from source. - supported: target === "client" ? { "async-await": false } : undefined, - external: - target === "client" - ? [ - ...PLUGIN_SDK_SPECIFIERS, - "@tanstack/react-query", - "react", - "react/jsx-runtime", - "react-native", - "zod", - ] - : SERVER_HOST_MODULES, - plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], - metafile: true, - logLevel: "silent", - treeShaking: true, - write: false, - }); - checkSharedDependencies(result.metafile.inputs, pluginDirectory); - const output = result.outputFiles[0]?.text; - if (!output) throw new Error(`Plugin ${target} compilation produced no output`); - return wrapCommonJsBundle(makeHermesInteropEager(output)); +const compiler = new PluginCompiler(); + +export function runCompilerTask( + task: T, + onLog?: (data: Record, message: string) => void, +): Promise { + return compiler.run(task, onLog); } -export async function compilePlugin(entryPaths: { - client: string | null; - server: string | null; -}): Promise<{ - clientBundle: string | null; - serverBundle: string | null; -}> { - const [clientBundle, serverBundle] = await Promise.all([ - entryPaths.client ? compileTarget(entryPaths.client, "client") : null, - entryPaths.server ? compileTarget(entryPaths.server, "server") : null, - ]); - return { clientBundle, serverBundle }; +export function compilePlugin(entries: Parameters[0]) { + return runCompilerTask({ kind: "entries", entries }); +} + +export function compileDirectoryPlugin(directory: string, version: string, builtinId?: string) { + return runCompilerTask({ kind: "directory", directory, version, builtinId }); } diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 71fe2b96e9..60216ff0fa 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -15,11 +15,12 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import pino from "pino"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { DaemonConfigStore } from "../daemon-config-store.js"; import { PluginService } from "./index.js"; import { BuiltinPluginLoader } from "./builtin/index.js"; import { ManagedPluginSources } from "./managed-source.js"; +import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; import { startNpmRegistry, npmPluginPackages, @@ -179,6 +180,55 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) { return { runtime, started, releaseStart, starts }; } +function createGatedRuntime(providers: Record = {}) { + const gates = new Map>(); + const gate = (pluginId: string) => { + const existing = gates.get(pluginId); + if (existing) return existing; + const created = Promise.withResolvers(); + gates.set(pluginId, created); + return created; + }; + const begun: string[] = []; + const running = new Set(); + const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, + catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), + invoke: async () => undefined, + getLogs: () => [], + clearLogs: () => undefined, + getProviderRegistrations: (pluginId) => + running.has(pluginId) ? (providers[pluginId] ?? []) : [], + startPlugin: async (pluginId) => { + begun.push(pluginId); + await gate(pluginId).promise; + running.add(pluginId); + }, + stopPluginById: async (pluginId) => running.delete(pluginId), + stopAll: async () => { + running.clear(); + }, + subscribe: () => () => undefined, + bindPaseoSessionHost: () => undefined, + }; + return { + runtime, + begun, + finish: (pluginId: string) => gate(pluginId).resolve(), + fail: (pluginId: string, error: Error) => gate(pluginId).reject(error), + }; +} + +function nextNotification(service: PluginService, pluginId: string): Promise { + return new Promise((resolve) => { + const unsubscribe = service.subscribe((notified) => { + if (notified !== pluginId) return; + unsubscribe(); + resolve(); + }); + }); +} + describe("PluginService", () => { it.each([ { shadow: false, runtime: "subprocess" }, @@ -372,15 +422,127 @@ describe("PluginService", () => { "startup-first": { source: "directory", path: first }, "startup-second": { source: "directory", path: second }, }); - const snapshots: string[][] = []; - service.subscribe(() => snapshots.push(catalogIds(service))); + const notifications: { pluginId: string; running: boolean }[] = []; + service.subscribe((pluginId) => + notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }), + ); await service.start(); - expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]); + expect(notifications).toEqual([ + { pluginId: "startup-first", running: true }, + { pluginId: "startup-second", running: true }, + ]); + expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]); await service.stopAllPlugins(); }, 20_000); + it("starts configured plugins concurrently and publishes them in config order", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const gated = createGatedRuntime(); + const service = createService( + home, + { + alpha: { source: "directory", path: "/plugins/alpha" }, + beta: { source: "directory", path: "/plugins/beta" }, + gamma: { source: "directory", path: "/plugins/gamma" }, + }, + { runtime: gated.runtime }, + ); + const notified: string[] = []; + service.subscribe((pluginId) => notified.push(pluginId)); + let started = false; + const starting = (async () => { + await service.start(); + started = true; + })(); + + await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]); + + gated.finish("gamma"); + const alphaPublished = nextNotification(service, "alpha"); + gated.finish("alpha"); + await alphaPublished; + expect(notified).toEqual(["alpha"]); + expect(started).toBe(false); + + gated.finish("beta"); + await starting; + expect(notified).toEqual(["alpha", "beta", "gamma"]); + expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]); + await service.stopAllPlugins(); + }); + + it("starts the other configured plugins when one fails while they are still starting", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const gated = createGatedRuntime(); + const service = createService( + home, + { + broken: { source: "directory", path: "/plugins/broken" }, + healthy: { source: "directory", path: "/plugins/healthy" }, + }, + { runtime: gated.runtime }, + ); + const notified: string[] = []; + service.subscribe((pluginId) => notified.push(pluginId)); + const starting = service.start(); + + await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]); + const brokenPublished = nextNotification(service, "broken"); + gated.fail("broken", new Error("startup exploded")); + await brokenPublished; + gated.finish("healthy"); + await starting; + + expect(notified).toEqual(["broken", "healthy"]); + expect(catalogIds(service)).toEqual(["healthy"]); + expect(await service.listPlugins()).toMatchObject([ + { id: "broken", status: "failed", error: "startup exploded" }, + { id: "healthy", status: "running" }, + ]); + await service.stopAllPlugins(); + }); + + it("keeps a provider ID with the plugin listed first when both start at once", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const gated = createGatedRuntime({ + first: [{ id: "shared-agent", label: "First agent" }], + second: [{ id: "shared-agent", label: "Second agent" }], + }); + const service = createService( + home, + { + first: { source: "directory", path: "/plugins/first" }, + second: { source: "directory", path: "/plugins/second" }, + }, + { runtime: gated.runtime }, + ); + const starting = service.start(); + + await expect.poll(() => gated.begun).toEqual(["first", "second"]); + gated.finish("second"); + gated.finish("first"); + await starting; + + expect(service.getProviderRegistrations()).toMatchObject([ + { id: "shared-agent", label: "First agent" }, + ]); + expect(catalogIds(service)).toEqual(["first"]); + expect(await service.listPlugins()).toMatchObject([ + { id: "first", status: "running" }, + { + id: "second", + status: "failed", + error: 'Plugin second cannot register provider ID "shared-agent" twice', + }, + ]); + await service.stopAllPlugins(); + }); + it("uses an explicit config key, exposes reload failure, and retries from disk", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); @@ -526,7 +688,7 @@ describe("PluginService", () => { if (!address || typeof address === "string") throw new Error("Missing registry address"); const url = `http://127.0.0.1:${address.port}`; const store = createStore(home); - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); const service = bindTestSessionHost( new PluginService(pino({ level: "silent" }), store, "0.4.0", { managedSources: new ManagedPluginSources(home, { defaultUrl: url }), @@ -730,7 +892,7 @@ export default function contribute(server: PluginServerContext) { await service.stopAllPlugins(); } }, - 30_000, + 60_000, ); it("keeps the running commit when a Git update build command fails", async () => { @@ -1071,27 +1233,65 @@ export default function contribute(plugin: unknown) { "second", `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, ); + // Hold cleanup open so synchronous detach and queued recovery are tested + // independently of compiler/process startup speed on the runner. + let finishCleanup = () => undefined; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + const running = new Set(); + const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, + catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), + invoke: async (pluginId) => { + if (!running.has(pluginId)) throw new Error(`Plugin is not available: ${pluginId}`); + return pluginId; + }, + getLogs: () => [], + clearLogs: () => undefined, + startPlugin: async (pluginId, _path, canPublish) => { + if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`); + running.add(pluginId); + }, + stopPluginById: async (pluginId) => { + const existed = running.delete(pluginId); + await cleanup; + return existed; + }, + stopAll: async () => running.clear(), + subscribe: () => () => undefined, + bindPaseoSessionHost: () => undefined, + }; const store = createStore(home); const service = bindTestSessionHost( - new PluginService(pino({ level: "silent" }), store, "0.4.0"), + new PluginService(pino({ level: "silent" }), store, "0.4.0", { runtime }), ); - await service.start(); - await service.installDirectory({ path: first }); - await service.installDirectory({ path: second }); + try { + await service.start(); + await service.installDirectory({ path: first }); + await service.installDirectory({ path: second }); + expect(catalogIds(service)).toEqual(["first", "second"]); + await store.patch({ pluginsEnabled: false }); - store.patch({ pluginsEnabled: false }); - - expect(service.catalog()).toEqual([]); - await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow( - "Plugin is not available", - ); - store.patch({ pluginsEnabled: true }); - await service.reloadPlugin("first"); - expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([ - { id: "first", status: "running" }, - { id: "second", status: "running" }, - ]); - await service.stopAllPlugins(); + expect(service.catalog()).toEqual([]); + await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow( + "Plugin is not available", + ); + await store.patch({ pluginsEnabled: true }); + expect(service.catalog()).toEqual([]); + finishCleanup(); + // Observe recovery itself. Reloading here restarts a healthy plugin and + // would also conceal a failure to restart it after the global enable. + await expect + .poll(() => service.listPlugins(), { timeout: 10_000 }) + .toMatchObject([ + { id: "first", status: "running" }, + { id: "second", status: "running" }, + ]); + } finally { + finishCleanup(); + await service.stopAllPlugins(); + } }, 20_000); it("does not publish an in-flight start after a later global disable", async () => { @@ -1100,16 +1300,16 @@ export default function contribute(plugin: unknown) { const store = createStore(home, { slow: { source: "directory", path: "/plugins/slow", enabled: true }, }); - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); const paused = createPausedRuntime(); const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", { runtime: paused.runtime, }); await service.start(); - store.patch({ pluginsEnabled: true }); + await store.patch({ pluginsEnabled: true }); await paused.started; - store.patch({ pluginsEnabled: false }); + await store.patch({ pluginsEnabled: false }); paused.releaseStart(); await service.stopAllPlugins(); @@ -1119,6 +1319,47 @@ export default function contribute(plugin: unknown) { ]); }); + it("persists overlapping enable and disable without losing either source mutation", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + a: { source: "directory", path: "/plugins/a", enabled: false }, + b: { source: "directory", path: "/plugins/b", enabled: true }, + }); + const paused = createPluginSelectivePausedRuntime("unused"); + const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", { + runtime: paused.runtime, + }); + await service.start(); + await Promise.all([service.enablePlugin("a"), service.disablePlugin("b")]); + expect(store.get().plugins).toEqual({ + a: { source: "directory", path: "/plugins/a", enabled: true }, + b: { source: "directory", path: "/plugins/b", enabled: false }, + }); + await service.stopAllPlugins(); + }); + + it("clears a failed disable intent so the previous enabled state can publish", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const store = createStore(home, { + slow: { source: "directory", path: "/plugins/slow", enabled: false }, + }); + const paused = createPausedRuntime(); + const service = new PluginService(pino({ level: "silent" }), store, "0.4.0", { + runtime: paused.runtime, + }); + await service.start(); + const enabling = service.enablePlugin("slow"); + await paused.started; + await writeFile(path.join(home, "config.json"), "invalid JSON"); + await expect(service.disablePlugin("slow")).rejects.toThrow("Invalid JSON"); + paused.releaseStart(); + await expect(enabling).resolves.toMatchObject({ enabled: true, status: "running" }); + expect(service.catalog()).toEqual([{ id: "slow", clientBundle: "bundle" }]); + await service.stopAllPlugins(); + }); + it("does not publish an in-flight enable after a later plugin disable", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); @@ -1280,7 +1521,11 @@ export default function contribute(plugin: unknown) { }); describe("npm plugin installation", () => { - it("preserves dependencies and metadata, and reloads without npm", async () => { + let registry: Awaited>; + + // Give real npm fixture construction its own setup budget so it does not consume + // the installation journey on Windows. + beforeAll(async () => { const packages = npmPluginPackages(); const fixture = packages[1]; packages.push({ ...fixture, version: "2.0.0", tags: ["future"] }); @@ -1315,7 +1560,7 @@ describe("npm plugin installation", () => { for (const [name, overrides] of failures) { failedPackages.push({ ...fixture, name, files: { ...fixture.files, ...overrides } }); } - const registry = await startNpmRegistry([ + registry = await startNpmRegistry([ ...packages, { name: "paseo-prepared-plugin", @@ -1345,6 +1590,9 @@ export default function contribute() { }, ...failedPackages, ]); + }, 120_000); + + it("preserves dependencies and metadata, and reloads without npm", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-npm-service-")); roots.push(home); const previousConfig = process.env.npm_config_userconfig; diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index a5ecea8097..65faa39f75 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -1,3 +1,4 @@ +import type { PluginAgentToolsRuntime } from "./agent-tools.js"; import type { PluginLifecycle } from "./lifecycle/index.js"; import path from "node:path"; import { stat, rm } from "node:fs/promises"; @@ -37,9 +38,14 @@ import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js"; const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { + subscribeSessionOpenChanges?: PluginRuntime["subscribeSessionOpenChanges"]; + isToolPluginAvailable?: PluginRuntime["isToolPluginAvailable"]; + callTool?: PluginRuntime["callTool"]; + detachTools?: PluginRuntime["detachTools"]; emit?: PluginLifecycle["emit"]; drainEvents?: PluginRuntime["drainEvents"]; before?: PluginLifecycle["before"]; + wait?: PluginLifecycle["wait"]; catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise; getLogs(pluginId: string): PluginLogEntry[]; @@ -92,6 +98,10 @@ export class PluginService { private readonly providerListeners = new Set<() => void>(); private lifecycle = Promise.resolve(); private globalStartsBlocked = true; + private readonly pendingOperations = new Map< + string, + { kind: "disable" | "remove"; completion: Promise } + >(); private started = false; private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>(); @@ -131,6 +141,38 @@ export class PluginService { this.runtime.emit?.(name, event); }; + subscribeSessionOpenChanges(listener: () => void): () => void { + return this.runtime.subscribeSessionOpenChanges?.(listener) ?? (() => {}); + } + + readonly isToolPluginAvailable: PluginAgentToolsRuntime["isToolPluginAvailable"] = ( + pluginId, + pluginInstanceId, + ) => { + return this.runtime.isToolPluginAvailable?.(pluginId, pluginInstanceId) ?? false; + }; + + readonly detachTools: PluginAgentToolsRuntime["detachTools"] = (agentId) => { + this.runtime.detachTools?.(agentId); + }; + + readonly callTool: PluginAgentToolsRuntime["callTool"] = async ( + pluginId, + pluginInstanceId, + agent, + name, + input, + signal, + ) => { + if (!this.isToolPluginAvailable(pluginId, pluginInstanceId) || !this.runtime.callTool) + return { isError: true, text: `Plugin is not available: ${pluginId}` }; + return this.runtime.callTool(pluginId, pluginInstanceId, agent, name, input, signal); + }; + + readonly wait: PluginLifecycle["wait"] = async (name, payload) => { + await this.runtime.wait?.(name, payload); + }; + async drainEvents(): Promise { await this.runtime.drainEvents?.(); } @@ -188,11 +230,18 @@ export class PluginService { } }); if (config.pluginsEnabled === true) { - for (const [pluginId, source] of Object.entries(config.plugins ?? {})) { - if (source.enabled === false) continue; - await this.startConfigured(pluginId); - this.notify(pluginId); - } + let earlierPublished: Promise = Promise.resolve(); + await Promise.all( + Object.entries(config.plugins ?? {}) + .filter(([, source]) => source.enabled !== false) + .map(async ([pluginId]) => { + const publishTurn = earlierPublished; + const starting = this.startConfigured(pluginId, publishTurn); + earlierPublished = Promise.all([publishTurn, starting]); + await starting; + this.notify(pluginId); + }), + ); } this.configStore.onFieldChange("pluginsEnabled", (value) => { this.handleGlobalSwitch(value === true); @@ -265,11 +314,7 @@ export class PluginService { `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, ); } - const sources = { - ...this.configStore.get().plugins, - [pluginId]: { source: "directory" as const, path: directory, enabled: true }, - }; - this.configStore.patch({ plugins: sources }); + await this.patchSource(pluginId, { source: "directory", path: directory, enabled: true }); if (this.canPublish(pluginId)) await this.startConfigured(pluginId); this.notify(pluginId); const installed = await this.requireItem(pluginId); @@ -328,15 +373,17 @@ export class PluginService { await managedSources.discard(candidate); throw error; } - this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true }); + await this.patchSource(pluginId, { + source: "directory", + path: candidate.directory, + enabled: true, + }); try { if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory); managedSources.commit(pluginId, candidate.record); } catch (error) { await this.stopPlugin(pluginId); - const sources = { ...this.configStore.get().plugins }; - delete sources[pluginId]; - this.configStore.patch({ plugins: sources }); + await this.removeSource(pluginId); await managedSources.discard(candidate); this.errors.delete(pluginId); this.notify(pluginId); @@ -428,10 +475,11 @@ export class PluginService { } async enablePlugin(pluginId: string): Promise { - const source = this.requireSource(pluginId); - this.patchSource(pluginId, { ...source, enabled: true }); + await this.patchSource(pluginId, (source) => ({ ...source, enabled: true })); this.errors.delete(pluginId); return this.enqueue(async () => { + // Cancellation persistence uses the store queue, so it never waits behind a blocked start. + await this.pendingOperations.get(pluginId)?.completion.catch(() => {}); const current = this.requireSource(pluginId); if (current.enabled !== false && this.configStore.get().pluginsEnabled === true) { await this.startExplicit(pluginId, current.path); @@ -442,8 +490,17 @@ export class PluginService { } async disablePlugin(pluginId: string): Promise { - const source = this.requireSource(pluginId); - this.patchSource(pluginId, { ...source, enabled: false }); + const operation = { + kind: "disable", + completion: this.patchSource(pluginId, (source) => ({ ...source, enabled: false })), + } as const; + this.pendingOperations.set(pluginId, operation); + try { + await operation.completion; + } finally { + if (this.pendingOperations.get(pluginId) === operation) + this.pendingOperations.delete(pluginId); + } const stopping = this.stopPlugin(pluginId); return this.enqueue(async () => { await stopping; @@ -455,10 +512,15 @@ export class PluginService { async removePlugin(pluginId: string): Promise { this.requireSource(pluginId); + const operation = { kind: "remove", completion: this.removeSource(pluginId) } as const; + this.pendingOperations.set(pluginId, operation); + try { + await operation.completion; + } finally { + if (this.pendingOperations.get(pluginId) === operation) + this.pendingOperations.delete(pluginId); + } const stopping = this.stopPlugin(pluginId); - const sources = { ...this.configStore.get().plugins }; - delete sources[pluginId]; - this.configStore.patch({ plugins: sources }); await this.enqueue(async () => { await stopping; this.runtime.clearLogs(pluginId); @@ -505,12 +567,12 @@ export class PluginService { }).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins")); } - private async startConfigured(pluginId: string): Promise { + private async startConfigured(pluginId: string, publishTurn?: Promise): Promise { const source = this.configStore.get().plugins?.[pluginId]; if (!source || source.enabled === false || !this.canPublish(pluginId)) return; this.errors.delete(pluginId); try { - await this.startPlugin(pluginId, source.path); + await this.startPlugin(pluginId, source.path, publishTurn); } catch (error) { if (this.canPublish(pluginId)) this.recordFailure(pluginId, error); } @@ -531,6 +593,7 @@ export class PluginService { private canPublish(pluginId: string): boolean { const config = this.configStore.get(); return ( + !this.pendingOperations.has(pluginId) && !this.globalStartsBlocked && config.pluginsEnabled === true && config.plugins?.[pluginId]?.enabled !== false && @@ -538,8 +601,13 @@ export class PluginService { ); } - private async startPlugin(pluginId: string, sourcePath: string): Promise { + private async startPlugin( + pluginId: string, + sourcePath: string, + publishTurn?: Promise, + ): Promise { await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); + await publishTurn; try { await this.publishProviderRegistrations(pluginId, sourcePath); this.publishUsageSources(pluginId); @@ -744,7 +812,10 @@ export class PluginService { await managedSources.discard(candidate); throw new Error(`Plugin is no longer configured: ${pluginId}`); } - this.patchSource(pluginId, { ...activatedSource, path: candidate.directory }); + await this.patchSource(pluginId, (currentSource) => ({ + ...currentSource, + path: candidate.directory, + })); this.errors.delete(pluginId); this.notify(pluginId); let warning: string | undefined; @@ -786,9 +857,28 @@ export class PluginService { return source; } - private patchSource(pluginId: string, source: PluginSource): void { - this.configStore.patch({ - plugins: { ...this.configStore.get().plugins, [pluginId]: source }, + private async patchSource( + pluginId: string, + update: PluginSource | ((source: PluginSource) => PluginSource), + ): Promise { + await this.configStore.patch((current) => { + const source = current.plugins?.[pluginId]; + if (typeof update === "function" && !source) + throw new Error(`Plugin is not configured: ${pluginId}`); + return { + plugins: { + ...current.plugins, + [pluginId]: typeof update === "function" ? update(source!) : update, + }, + }; + }); + } + + private async removeSource(pluginId: string): Promise { + await this.configStore.patch((current) => { + const sources = { ...current.plugins }; + delete sources[pluginId]; + return { plugins: sources }; }); } 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 6f3d8661e7..1b05b56b53 100644 --- a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -8,8 +8,11 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; test("closing and archiving agents publish their distinct lifecycle hooks once", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-archive-hooks-")); - const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.14.0-beta.1" }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.1", + }); try { await client.connect(); await client.fetchAgents({ subscribe: {} }); @@ -39,7 +42,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", .poll(async () => { const logs = await client.getPluginLogs("lifecycle-logger"); return logs - .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .filter((entry) => entry.message.startsWith('{"hook":"agent.close.after"')) .map((entry) => JSON.parse(entry.message).data.agent.id); }) .toEqual([closed.id]); @@ -49,7 +52,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", const logs = await client.getPluginLogs("lifecycle-logger"); return logs .filter((entry) => { - return entry.message.startsWith('{"hook":"agent.archived"'); + return entry.message.startsWith('{"hook":"agent.archive.after"'); }) .map((entry) => { return JSON.parse(entry.message).data.agent.id; @@ -61,7 +64,7 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", .poll(async () => { const logs = await client.getPluginLogs("lifecycle-logger"); return logs - .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .filter((entry) => entry.message.startsWith('{"hook":"agent.close.after"')) .map((entry) => JSON.parse(entry.message).data.agent.id) .sort(); }) @@ -75,20 +78,23 @@ test("closing and archiving agents publish their distinct lifecycle hooks once", test("daemon shutdown lets agent close hooks finish before stopping plugins", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-close-hook-shutdown-")); - const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.14.0-beta.1" }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0-beta.1", + }); const completedFile = path.join(directory, "closed.txt"); try { await writeFile( path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.8.0" } }), + JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.14.0-beta.1" } }), ); await writeFile( path.join(directory, "index.server.ts"), `import { writeFile } from "node:fs/promises"; import { setTimeout } from "node:timers/promises"; export default function contribute(server) { - server.on("agent.closed", async (event, context) => { + server.addHook("agent.close.after", async (event, context) => { await setTimeout(200, undefined, { signal: context.signal }); await writeFile(${JSON.stringify(completedFile)}, event.agent.id); }); diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts index 9c75f63746..bd1dfa8859 100644 --- a/packages/server/src/server/plugins/lifecycle.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -1,4 +1,5 @@ -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -6,6 +7,7 @@ import { expect, onTestFinished, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { resolveDaemonVersion } from "../daemon-version.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-")); @@ -74,6 +76,76 @@ export default function contribute(server) { } }, 60_000); +test("addHook transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "lifecycle", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.addHook("workspace.create.before", async ({ request }) => { + console.log(JSON.stringify({ hook: "workspace.before" })); + return { ...request, title: "Created through a hook" }; + }); + server.addHook("workspace.create.after", async (event) => { + console.log(JSON.stringify({ hook: "workspace.create.after", event })); + }); + server.addHook("workspace.archive.after", async (event) => { + console.log(JSON.stringify({ hook: "workspace.archive.after", event })); + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const created = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(created.workspace?.name).toBe("Created through a hook"); + const workspaceId = created.workspace!.id; + const replay = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); + await client.archiveWorkspace(workspaceId); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle"); + return logs + .filter((entry) => { + return entry.message.startsWith('{"hook":'); + }) + .map((entry) => { + return JSON.parse(entry.message); + }); + }) + .toMatchObject([ + { hook: "workspace.before" }, + { + hook: "workspace.create.after", + event: { workspace: { id: workspaceId, cwd: directory } }, + }, + { hook: "workspace.archive.after", event: { workspace: { id: workspaceId } } }, + ]); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + test("plugins observe turns, answer permissions, and observe archive without blocking the agent", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-turn-hooks-")); const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); @@ -87,24 +159,24 @@ test("plugins observe turns, answer permissions, and observe archive without blo path.join(directory, "index.server.ts"), ` export default function contribute(server) { - server.on("agent.turn_started", (event) => { - console.log(JSON.stringify({ hook: "agent.turn_started", event })); + server.addHook("agent.turn.before", async (event) => { + console.log(JSON.stringify({ hook: "agent.turn.before", event })); }); - server.on("agent.turn_ended", (event) => { - console.log(JSON.stringify({ hook: "agent.turn_ended", event })); + server.addHook("agent.turn.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.turn.after", event })); }); - server.on("agent.permission_requested", async (event, context) => { - console.log(JSON.stringify({ hook: "agent.permission_requested", event })); + server.addHook("agent.permission.before", async (event, context) => { + console.log(JSON.stringify({ hook: "agent.permission.before", event })); await context.paseo.agents.ref(event.agent.id).respondToPermission({ requestId: event.request.id, response: { behavior: "deny", message: "Declined by plugin" }, }); }); - server.on("agent.permission_resolved", (event) => { - console.log(JSON.stringify({ hook: "agent.permission_resolved", event })); + server.addHook("agent.permission.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.permission.after", event })); }); - server.on("agent.archived", (event) => { - console.log(JSON.stringify({ hook: "agent.archived", event })); + server.addHook("agent.archive.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.archive.after", event })); }); return () => {}; } @@ -139,16 +211,16 @@ export default function contribute(server) { { timeout: 10_000 }, ) .toMatchObject([ + { hook: "agent.permission.after", event: { resolution: { behavior: "deny" } } }, { - hook: "agent.permission_requested", + hook: "agent.permission.before", event: { request: { input: { command: "rm -f permission.txt" } } }, }, - { hook: "agent.permission_resolved", event: { resolution: { behavior: "deny" } } }, { - hook: "agent.turn_ended", + hook: "agent.turn.after", event: { outcome: { kind: "completed" }, timeline: [] }, }, - { hook: "agent.turn_started", event: { agent: { id: agent.id } } }, + { hook: "agent.turn.before", event: { agent: { id: agent.id } } }, ]); const page = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 20 }); expect(page.entries.length).toBeGreaterThan(0); @@ -157,7 +229,7 @@ export default function contribute(server) { .poll(async () => { const logs = await client.getPluginLogs("turn-hooks"); return logs.filter((entry) => { - return entry.message.startsWith('{"hook":"agent.archived"'); + return entry.message.startsWith('{"hook":"agent.archive.after"'); }).length; }) .toBe(1); @@ -170,44 +242,41 @@ export default function contribute(server) { test("agent creation hooks change the provider and environment before the session opens", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-")); - const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); - const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); - try { - await writeFile( - path.join(directory, "paseo-plugin.json"), - JSON.stringify({ id: "agent-hooks", requirements: { paseo: ">=0.8.0" } }), - ); - await writeFile( - path.join(directory, "index.server.ts"), - ` -export default function contribute(server) { - server.before("agent.create", ({ request }) => { - return { ...request, config: { ...request.config, provider: "codex" }, env: { ...request.env, HOOK_CREATE: "created" } }; + const pluginDirectory = await createProviderPlugin(directory, { + acknowledgeClose: true, + hooks: ` + server.addHook("agent.create.before", async ({ request }) => { + return { ...request, config: { ...request.config, provider: "${PROVIDER_ID}", model: "shutdown-model" }, env: { ...request.env, HOOK_CREATE: "created" } }; }); - server.before("agent.session_open", ({ request }) => { - console.log(JSON.stringify({ hook: "agent.session_open", request })); + server.addHook("agent.open.before", async ({ request }) => { + console.log(JSON.stringify({ hook: "agent.open.before", request })); return { ...request, env: { ...request.env, HOOK_OPEN: "opened" } }; }); - server.on("agent.created", (event) => { - console.log(JSON.stringify({ hook: "agent.created", event })); + server.addHook("agent.create.after", async (event) => { + console.log(JSON.stringify({ hook: "agent.create.after", event })); + });`, + reportOpen: true, }); - return () => {}; -} -`, - ); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { await client.connect(); await client.fetchAgents({ subscribe: {} }); await client.patchDaemonConfig({ pluginsEnabled: true }); - await client.installDirectoryPlugin(directory); + await client.installDirectoryPlugin(pluginDirectory); const agent = await client.createAgent({ provider: "claude", cwd: directory, title: "Hook test", }); - expect(agent.provider).toBe("codex"); + expect(agent.provider).toBe(PROVIDER_ID); + expect(await daemon.daemon.agentStorage.get(agent.id)).toMatchObject({ + provider: PROVIDER_ID, + config: { model: "shutdown-model" }, + }); await expect .poll(async () => { - const logs = await client.getPluginLogs("agent-hooks"); + const logs = await client.getPluginLogs("shutdown-provider-plugin"); return logs .filter((entry) => { return entry.message.startsWith('{"hook":'); @@ -218,10 +287,17 @@ export default function contribute(server) { }) .toMatchObject([ { - hook: "agent.session_open", + hook: "agent.open.before", request: { agentId: agent.id, env: { HOOK_CREATE: "created" } }, }, - { hook: "agent.created", event: { agent: { id: agent.id, provider: "codex" } } }, + { + hook: "provider.open", + config: { env: { HOOK_CREATE: "created", HOOK_OPEN: "opened" } }, + }, + { + hook: "agent.create.after", + event: { agent: { id: agent.id, provider: PROVIDER_ID } }, + }, ]); await client.archiveAgent(agent.id); } finally { @@ -244,10 +320,10 @@ test("invalid output from an untyped plugin rejects creation before later callba path.join(directory, "index.server.ts"), ` export default function contribute(server) { - server.before("workspace.create", () => { + server.addHook("workspace.create.before", async () => { return { source: { kind: "worktree", branchName: 42 } }; }); - server.before("workspace.create", ({ request }) => { + server.addHook("workspace.create.before", async ({ request }) => { console.log("Unexpected later callback"); return request; }); @@ -260,7 +336,7 @@ export default function contribute(server) { await client.installDirectoryPlugin(directory); const result = await client.createWorkspace({ source: { kind: "directory", path: directory } }); expect(result.workspace).toBeNull(); - expect(result.error).toContain("Plugin invalid-hook before workspace.create failed"); + expect(result.error).toContain("Plugin invalid-hook workspace.create.before failed"); const logs = await client.getPluginLogs("invalid-hook"); expect( logs.some((entry) => { @@ -278,7 +354,9 @@ const PROVIDER_ID = "shutdown-provider"; async function createProviderPlugin( root: string, - options: { acknowledgeClose: boolean } = { acknowledgeClose: true }, + options: { acknowledgeClose: boolean; hooks?: string; reportOpen?: boolean } = { + acknowledgeClose: true, + }, ): Promise { const directory = path.join(root, "plugin"); await mkdir(directory, { recursive: true }); @@ -324,6 +402,7 @@ const provider: ProviderRegistration = { return; } if (input.type === "session.open") { + if (${JSON.stringify(options.reportOpen ?? false)}) console.log(JSON.stringify({ hook: "provider.open", config: input.config })); emit({ type: "session.opened", requestId: input.requestId, @@ -375,6 +454,7 @@ const provider: ProviderRegistration = { export default function contribute(server: { registerProvider(p: ProviderRegistration): void }) { server.registerProvider(provider); +${options.hooks ?? ""} return () => undefined; }`, ); @@ -383,7 +463,7 @@ export default function contribute(server: { registerProvider(p: ProviderRegistr test("a clean daemon shutdown leaves a completed plugin-provider agent without an error", async () => { const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-shutdown-")); - onTestFinished(() => rm(root, { recursive: true, force: true })); + onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })); const pluginDirectory = await createProviderPlugin(root); const workspace = path.join(root, "workspace"); await mkdir(workspace, { recursive: true }); @@ -427,7 +507,7 @@ test("a clean daemon shutdown leaves a completed plugin-provider agent without a test("reloading a plugin leaves a completed plugin-provider agent without an error", async () => { const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-reload-")); - onTestFinished(() => rm(root, { recursive: true, force: true })); + onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })); const pluginDirectory = await createProviderPlugin(root); const workspace = path.join(root, "workspace"); await mkdir(workspace, { recursive: true }); @@ -476,7 +556,7 @@ test("reloading a plugin leaves a completed plugin-provider agent without an err test("a provider that never acknowledges session.close does not hold the daemon open", async () => { const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-stuck-close-")); - onTestFinished(() => rm(root, { recursive: true, force: true })); + onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })); const pluginDirectory = await createProviderPlugin(root, { acknowledgeClose: false }); const workspace = path.join(root, "workspace"); await mkdir(workspace, { recursive: true }); @@ -586,3 +666,644 @@ test("reloading a plugin does not send on a closed IPC channel", async () => { await rm(pluginDirectory, { recursive: true, force: true }); } }, 60_000); + +test("waiting hook failures reject the operation while notification failures are logged and later handlers run", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hook-failures-")); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "hook-failures", + requirements: { paseo: ">=0.14.0-beta.1" }, + }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.addHook("workspace.create.before", async ({ request }) => { + if (request.title === "reject") throw new Error("waiting hook rejected"); + return request; + }); + server.addHook("workspace.create.after", async () => { + throw new Error("notification rejected"); + }); + server.addHook("workspace.create.after", async ({ workspace }) => { + console.log("Later notification for " + workspace.id); + }); + return () => {}; +}`, + ); + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const failed = await client.createWorkspace({ + title: "reject", + source: { kind: "directory", path: directory }, + }); + expect(failed.workspace).toBeNull(); + expect(failed.error).toContain( + "Plugin hook-failures workspace.create.before failed: waiting hook rejected", + ); + const created = await client.createWorkspace({ + source: { kind: "directory", path: directory }, + }); + expect(created.error).toBeNull(); + expect(created.workspace?.workspaceDirectory).toBe(directory); + await expect + .poll(async () => + (await client.getPluginLogs("hook-failures")).map(({ message }) => message).join("\n"), + ) + .toContain("Later notification for " + created.workspace!.id); + const messages = (await client.getPluginLogs("hook-failures")) + .map(({ message }) => message) + .join("\n"); + expect(messages).toContain("Lifecycle hook workspace.create.after failed"); + expect(messages).toContain("notification rejected"); + expect(messages).toContain("Later notification for " + created.workspace!.id); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test.each([false, true])( + "worktree creation waits for plugins before its first agent and rolls back failures (firstAgentContext: %s)", + async (withFirstAgentContext) => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-hook-")); + const repository = path.join(root, "repository"); + execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: repository, stdio: "pipe" }, + ); + execFileSync("git", ["checkout", "-b", "plugin-base"], { cwd: repository, stdio: "pipe" }); + await writeFile(path.join(repository, "from-plugin-base.txt"), "base"); + execFileSync("git", ["add", "."], { cwd: repository, stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "plugin base", + ], + { cwd: repository, stdio: "pipe" }, + ); + execFileSync("git", ["checkout", "main"], { cwd: repository, stdio: "pipe" }); + const orderFile = path.join(root, "order.jsonl"); + const pluginDirectory = await createProviderPlugin(root, { + acknowledgeClose: true, + hooks: ` + server.addHook("worktree.create.before", async ({ request }) => { + const { appendFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.before", request }) + "\\n"); + return { ...request, path: ${JSON.stringify(path.join(root, "elsewhere"))} + "/" + request.slug, + branch: request.branch === "accept-hook" ? "plugin-branch" : request.branch, + baseRef: request.branch === "accept-hook" ? "plugin-base" : request.baseRef }; + }); + server.addHook("worktree.create.after", async ({ worktree }) => { + const { appendFile, writeFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.after", worktree }) + "\\n"); + if (worktree.branch === "reject-hook") throw new Error("worktree rejected"); + await writeFile(worktree.path + "/plugin-ready.txt", "ready"); + }); + server.addHook("workspace.create.after", async () => { + const { appendFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "workspace.create.after" }) + "\\n"); + }); + server.addHook("agent.create.before", async ({ request }) => { + const { appendFile } = await import("node:fs/promises"); + await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "agent.create.before" }) + "\\n"); + return request; + }); + server.addHook("agent.open.before", async ({ request }) => { + const { readFile } = await import("node:fs/promises"); + const contents = await readFile(request.cwd + "/plugin-ready.txt", "utf8"); + if (contents !== "ready") throw new Error("missing plugin output"); + console.log(JSON.stringify({ hook: "first-agent-ready", agentId: request.agentId, cwd: request.cwd, contents })); + return request; + });`, + }); + let daemon = await createTestPaseoDaemon({ + paseoHomeRoot: path.join(root, "state"), + cleanup: false, + }); + let client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + const installed = await client.installDirectoryPlugin(pluginDirectory); + expect(installed.status).toBe("running"); + const created = await client.createWorkspace({ + ...(withFirstAgentContext + ? { + firstAgentContext: { prompt: "Check the initialized worktree" }, + agent: { + provider: PROVIDER_ID, + cwd: repository, + initialPrompt: "Check the initialized worktree", + }, + } + : {}), + source: { + kind: "worktree", + cwd: repository, + branchName: "accept-hook", + worktreeSlug: "accept-hook", + }, + }); + expect(created.error).toBeNull(); + const cwd = created.workspace!.workspaceDirectory; + expect(created.workspace!.gitRuntime?.currentBranch).toBe("plugin-branch"); + const persisted = JSON.parse( + await readFile(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"), + ); + expect(persisted).toContainEqual( + expect.objectContaining({ + workspaceId: created.workspace!.id, + branch: "plugin-branch", + baseBranch: "refs/heads/plugin-base", + worktreeRoot: cwd, + }), + ); + expect(cwd).toBe(path.join(await realpath(root), "elsewhere", "accept-hook")); + expect(await readFile(path.join(cwd, "from-plugin-base.txt"), "utf8")).toBe("base"); + expect( + execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8" }).trim(), + ).toBe("plugin-branch"); + expect(await readFile(path.join(cwd, "plugin-ready.txt"), "utf8")).toBe("ready"); + const firstAgent = withFirstAgentContext + ? created.agent! + : await client.createAgent({ + provider: PROVIDER_ID, + cwd, + workspaceId: created.workspace!.id, + }); + expect(firstAgent).toMatchObject({ + cwd, + workspaceId: created.workspace!.id, + provider: PROVIDER_ID, + }); + await expect + .poll(async () => + (await client.getPluginLogs("shutdown-provider-plugin")) + .filter(({ message }) => message.startsWith('{"hook":"first-agent-ready"')) + .map(({ message }) => JSON.parse(message)), + ) + .toContainEqual({ + hook: "first-agent-ready", + agentId: firstAgent.id, + cwd, + contents: "ready", + }); + const records = (await readFile(orderFile, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(records.map((record) => record.hook)).toEqual([ + "worktree.create.before", + "worktree.create.after", + "workspace.create.after", + "agent.create.before", + ]); + expect({ + ...records[1].worktree, + sourceCheckout: await realpath(records[1].worktree.sourceCheckout), + }).toEqual({ + path: cwd, + branch: "plugin-branch", + sourceCheckout: await realpath(repository), + }); + expect((await client.fetchWorkspaces()).entries.map((workspace) => workspace.id)).toContain( + created.workspace!.id, + ); + await client.archiveWorkspace(created.workspace!.id); + await expect(stat(cwd)).rejects.toMatchObject({ code: "ENOENT" }); + const hookRecordsBeforeRestore = await readFile(orderFile, "utf8"); + await client.close(); + await daemon.close(); + daemon = await createTestPaseoDaemon({ + paseoHomeRoot: path.join(root, "state"), + cleanup: false, + pluginsEnabled: true, + plugins: { + "shutdown-provider-plugin": { source: "directory", path: pluginDirectory, enabled: true }, + }, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.restoreWorkspace(created.workspace!.id); + expect(await readFile(path.join(cwd, "from-plugin-base.txt"), "utf8")).toBe("base"); + expect(await readFile(orderFile, "utf8")).toBe(hookRecordsBeforeRestore); + const beforeFailure = await client.fetchWorkspaces(); + const worktreesBeforeFailure = execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repository, + encoding: "utf8", + }); + const rejected = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + branchName: "reject-hook", + worktreeSlug: "reject-hook", + }, + }); + expect(rejected.workspace).toBeNull(); + expect(rejected.error).toContain( + "Plugin shutdown-provider-plugin worktree.create.after failed: worktree rejected", + ); + const failedRecords = (await readFile(orderFile, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const failedPath = failedRecords.at(-1).worktree.path; + await expect(stat(failedPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await client.fetchWorkspaces()).entries.map(({ id }) => id).sort()).toEqual( + beforeFailure.entries.map(({ id }) => id).sort(), + ); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repository, + encoding: "utf8", + }), + ).toBe(worktreesBeforeFailure); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } + }, + 60_000, +); + +test("worktree before hook rejects unsafe destinations with plugin attribution before creating branches", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-before-invalid-")); + const repository = path.join(root, "repository"); + execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: repository, stdio: "pipe" }, + ); + const occupied = path.join(root, "occupied"); + await mkdir(occupied); + const destinations = [ + occupied, + "relative/path", + repository, + path.join(repository, ".git", "worktree"), + root, + path.parse(root).root, + (await import("node:os")).homedir(), + ]; + const pluginDirectory = await createProviderPlugin(root, { + acknowledgeClose: true, + hooks: ` + server.addHook("worktree.create.before", async ({ request }) => { + if (request.branch === "throws") throw new Error("creation denied"); + if (request.branch === "readonly") return { ...request, repoRoot: "/different-repository" }; + return { ...request, path: ${JSON.stringify(destinations)}[Number(request.branch.slice(4))] }; + });`, + }); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(pluginDirectory); + for (const branchName of [ + ...destinations.map((_, index) => `bad-${index}`), + "throws", + "readonly", + ]) { + const created = await client.createWorkspace({ + source: { kind: "worktree", cwd: repository, branchName, worktreeSlug: branchName }, + }); + expect(created.workspace).toBeNull(); + expect(created.error).toContain( + "Plugin shutdown-provider-plugin worktree.create.before failed:", + ); + expect( + execFileSync("git", ["branch", "--format=%(refname:short)"], { + cwd: repository, + encoding: "utf8", + }).trim(), + ).toBe("main"); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repository, + encoding: "utf8", + }).match(/^worktree /gm), + ).toHaveLength(1); + } + expect((await client.fetchWorkspaces()).entries).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); + +test("the placement example creates a sibling worktree, opens its first agent there, lists and archives it", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-placement-example-")); + const repository = path.join(root, "repository"); + execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: repository, stdio: "pipe" }, + ); + const providerDirectory = await createProviderPlugin(root, { + acknowledgeClose: true, + reportOpen: true, + }); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(providerDirectory); + const installed = await client.installDirectoryPlugin( + fileURLToPath(new URL("../../../../../plugin-examples/worktree-placement", import.meta.url)), + ); + expect(installed.status).toBe("running"); + const created = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + branchName: "feature/x", + worktreeSlug: "feature-x", + }, + }); + expect(created.error).toBeNull(); + const cwd = path.join(await realpath(root), "repository.feature-x"); + expect(created.workspace!.workspaceDirectory).toBe(cwd); + expect((await stat(cwd)).isDirectory()).toBe(true); + expect((await client.fetchWorkspaces()).entries.map((workspace) => workspace.id)).toContain( + created.workspace!.id, + ); + const worktrees = await client.getPaseoWorktreeList({ cwd: repository }); + expect(worktrees.error).toBeNull(); + expect(worktrees.worktrees).toContainEqual( + expect.objectContaining({ worktreePath: cwd, branchName: "feature/x" }), + ); + const collision = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + branchName: "feature/x", + worktreeSlug: "another-slug", + }, + }); + expect(collision.workspace).toBeNull(); + expect(collision.error).toContain("Plugin worktree-placement worktree.create.before failed:"); + expect(collision.error).toContain("already exists"); + const agent = await client.createAgent({ + provider: PROVIDER_ID, + cwd, + workspaceId: created.workspace!.id, + }); + expect(agent.cwd).toBe(cwd); + const logs = await client.getPluginLogs("shutdown-provider-plugin"); + const opened = logs + .filter((entry) => entry.message.startsWith('{"hook":"provider.open"')) + .map((entry) => JSON.parse(entry.message)); + expect(opened.map((entry) => entry.config.cwd)).toContain(cwd); + await client.archiveWorkspace(created.workspace!.id); + await expect(stat(cwd)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); + +test("worktree includes reach the first agent without copying tracked or unignored files", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-include-")); + const repository = path.join(root, "repository"); + execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" }); + const git = (...args: string[]) => execFileSync("git", args, { cwd: repository, stdio: "pipe" }); + await mkdir(path.join(repository, "config")); + await writeFile(path.join(repository, ".gitignore"), ".env\nconfig/*.local.json\n"); + await writeFile( + path.join(repository, ".worktreeinclude"), + ".env\nconfig/*.local.json\nunignored.txt\n", + ); + await writeFile(path.join(repository, "config/tracked.local.json"), "git version"); + git("add", ".gitignore", ".worktreeinclude"); + git("add", "-f", "config/tracked.local.json"); + git( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "initial", + ); + await writeFile(path.join(repository, ".env"), "SECRET=source"); + await writeFile(path.join(repository, "config/app.local.json"), "local config"); + await writeFile(path.join(repository, "config/tracked.local.json"), "uncommitted source version"); + await writeFile(path.join(repository, "unignored.txt"), "unignored source"); + const observed = path.join(root, "observed.json"); + const pluginDirectory = await createProviderPlugin(root, { + acknowledgeClose: true, + hooks: ` + server.addHook("agent.open.before", async ({ request }) => { + const { readFile, writeFile } = await import("node:fs/promises"); + const contents = await Promise.all([".env", "config/app.local.json", "config/tracked.local.json"].map(file => readFile(request.cwd + "/" + file, "utf8"))); + await writeFile(${JSON.stringify(observed)}, JSON.stringify(contents)); + });`, + }); + const daemon = await createTestPaseoDaemon({ builtinPlugins: new BuiltinPluginLoader() }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + expect((await client.installDirectoryPlugin(pluginDirectory)).status).toBe("running"); + const created = await client.createWorkspace({ + source: { kind: "worktree", cwd: repository, branchName: "include", worktreeSlug: "include" }, + }); + expect(created.error).toBeNull(); + await client.createAgent({ + provider: PROVIDER_ID, + cwd: created.workspace!.workspaceDirectory, + workspaceId: created.workspace!.id, + }); + expect(JSON.parse(await readFile(observed, "utf8"))).toEqual([ + "SECRET=source", + "local config", + "git version", + ]); + await expect( + stat(path.join(created.workspace!.workspaceDirectory, "unignored.txt")), + ).rejects.toMatchObject({ code: "ENOENT" }); + + const sourceWorktree = created.workspace!.workspaceDirectory; + await writeFile(path.join(sourceWorktree, ".env"), "SECRET=worktree"); + await writeFile(path.join(sourceWorktree, "config/app.local.json"), "worktree config"); + const nested = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: sourceWorktree, + branchName: "nested", + worktreeSlug: "nested", + }, + }); + expect(nested.error).toBeNull(); + await client.createAgent({ + provider: PROVIDER_ID, + cwd: nested.workspace!.workspaceDirectory, + workspaceId: nested.workspace!.id, + }); + expect(JSON.parse(await readFile(observed, "utf8"))).toEqual([ + "SECRET=worktree", + "worktree config", + "git version", + ]); + + execFileSync("git", ["add", "-f", ".env"], { cwd: sourceWorktree, stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "commit branch env", + ], + { cwd: sourceWorktree, stdio: "pipe" }, + ); + const existing = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + baseBranch: "include", + branchName: "existing", + worktreeSlug: "existing", + }, + }); + expect(existing.error).toBeNull(); + await client.createAgent({ + provider: PROVIDER_ID, + cwd: existing.workspace!.workspaceDirectory, + workspaceId: existing.workspace!.id, + }); + expect(JSON.parse(await readFile(observed, "utf8"))).toEqual([ + "SECRET=worktree", + "local config", + "git version", + ]); + + await rm(path.join(repository, ".worktreeinclude")); + const noIncludes = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + baseBranch: "main", + branchName: "no-includes", + worktreeSlug: "no-includes", + }, + }); + expect(noIncludes.error).toBeNull(); + await expect( + stat(path.join(noIncludes.workspace!.workspaceDirectory, ".env")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + stat(path.join(noIncludes.workspace!.workspaceDirectory, "config/app.local.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + + await writeFile(path.join(repository, ".worktreeinclude"), ".env\nconfig/*.local.json\n"); + await rm(path.join(sourceWorktree, "config"), { recursive: true }); + await writeFile( + path.join(sourceWorktree, "config"), + "committed file blocks the config directory", + ); + execFileSync("git", ["add", "-A", "config"], { cwd: sourceWorktree, stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "block config directory", + ], + { cwd: sourceWorktree, stdio: "pipe" }, + ); + const beforeFailure = await client.fetchWorkspaces(); + const worktreesBeforeFailure = git("worktree", "list", "--porcelain").toString(); + const failed = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repository, + baseBranch: "include", + branchName: "copy-failure", + worktreeSlug: "copy-failure", + }, + }); + expect(failed.workspace).toBeNull(); + expect(failed.error).toContain("Plugin worktree-include worktree.create.after failed:"); + expect((await client.fetchWorkspaces()).entries.map(({ id }) => id).sort()).toEqual( + beforeFailure.entries.map(({ id }) => id).sort(), + ); + expect(git("worktree", "list", "--porcelain").toString()).toBe(worktreesBeforeFailure); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts index fc9cfbd84d..40de1215d8 100644 --- a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import pino from "pino"; import { expect, test } from "vitest"; -import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; +import type { PluginHookRequests, PluginHookEvents } from "@getpaseo/plugin/server"; import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; @@ -14,14 +14,14 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; type RecordedHook = | { - [Name in keyof PluginLifecycleEvents]: { hook: Name; data: PluginLifecycleEvents[Name] }; - }[keyof PluginLifecycleEvents] + [Name in keyof PluginHookEvents]: { hook: Name; data: PluginHookEvents[Name] }; + }[keyof PluginHookEvents] | { - [Name in keyof PluginBeforeRequests]: { - hook: `before ${Name}`; - data: { request: PluginBeforeRequests[Name] }; + [Name in keyof PluginHookRequests]: { + hook: Name; + data: { request: PluginHookRequests[Name] }; }; - }[keyof PluginBeforeRequests]; + }[keyof PluginHookRequests]; const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url)); const evidenceDirectory = path.join(repoRoot, ".dev", "lifecycle-proof"); @@ -39,8 +39,8 @@ async function readHooks(client: DaemonClient): Promise { async function endedTurns(client: DaemonClient, agentId: string) { const events = await readHooks(client); - return events.filter((event): event is Extract => { - return event.hook === "agent.turn_ended" && event.data.agent.id === agentId; + return events.filter((event): event is Extract => { + return event.hook === "agent.turn.after" && event.data.agent.id === agentId; }); } @@ -63,7 +63,7 @@ async function waitForPermission(client: DaemonClient, agentId: string, command: const events = await readHooks(client); for (const event of events) { if ( - event.hook === "agent.permission_requested" && + event.hook === "agent.permission.before" && event.data.agent.id === agentId && event.data.request.input?.command === command ) { @@ -111,7 +111,7 @@ test.skipIf(process.platform !== "linux")( }); const logger = pino({ level: "info" }, destination); const daemon = await createTestPaseoDaemon({ - daemonVersion: "0.8.0", + daemonVersion: "0.14.0-beta.1", logger, agentClients: { claude: new ClaudeAgentClient({ logger }), @@ -121,7 +121,7 @@ test.skipIf(process.platform !== "linux")( }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.8.0", + appVersion: "0.14.0-beta.1", }); const proof: Record = { port: daemon.port, @@ -169,7 +169,7 @@ test.skipIf(process.platform !== "linux")( const approvalId = await waitForPermission(client, agent.id, "git status"); expect(await readHooks(client)).toContainEqual( expect.objectContaining({ - hook: "agent.permission_resolved", + hook: "agent.permission.after", data: expect.objectContaining({ requestId: approvalId, resolution: expect.objectContaining({ behavior: "allow" }), @@ -186,7 +186,7 @@ test.skipIf(process.platform !== "linux")( const deniedId = await waitForPermission(client, agent.id, "rm -rf canary.txt"); expect(await readHooks(client)).toContainEqual( expect.objectContaining({ - hook: "agent.permission_resolved", + hook: "agent.permission.after", data: expect.objectContaining({ requestId: deniedId, resolution: expect.objectContaining({ behavior: "deny" }), @@ -224,7 +224,7 @@ test.skipIf(process.platform !== "linux")( .poll( async () => { return (await readHooks(client)).filter((event) => { - return event.hook === "agent.turn_started" && event.data.agent.id === agent.id; + return event.hook === "agent.turn.before" && event.data.agent.id === agent.id; }).length; }, { timeout: 30_000 }, @@ -278,23 +278,26 @@ test.skipIf(process.platform !== "linux")( ); return [...names].sort(); }) - .toEqual([ - "agent.archived", - "agent.closed", - "agent.created", - "agent.permission_requested", - "agent.permission_resolved", - "agent.turn_ended", - "agent.turn_started", - "before agent.create", - "before agent.session_open", - "before workspace.create", - "workspace.archived", - "workspace.created", - ]); + .toEqual( + [ + "agent.archive.after", + "agent.close.after", + "agent.create.after", + "agent.permission.before", + "agent.permission.after", + "agent.turn.after", + "agent.turn.before", + "agent.create.before", + "agent.open.before", + "worktree.create.after", + "workspace.create.before", + "workspace.archive.after", + "workspace.create.after", + ].sort(), + ); const openReasons = (await readHooks(client)) .filter((event) => { - return event.hook === "before agent.session_open"; + return event.hook === "agent.open.before"; }) .map((event) => { return event.data.request.reason; diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts index 5e30535953..e17cca1ed5 100644 --- a/packages/server/src/server/plugins/lifecycle/handlers.test.ts +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "vitest"; import { createPaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { PluginHookHandlers } from "./index.js"; +import { validateBeforeResult, PluginHookHandlers } from "./index.js"; const paseo = createPaseoApi( new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }), @@ -9,18 +9,17 @@ const paseo = createPaseoApi( test("removing an old registration twice preserves a newer registration for the same hook", async () => { const hooks = new PluginHookHandlers(() => {}); - const remove = hooks.before("workspace.create", ({ request }) => { + const remove = hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: "old" }; }); remove(); - hooks.before("workspace.create", ({ request }) => { + hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: "new" }; }); remove(); const output = await hooks.invoke( "operation", - "before", - "workspace.create", + "workspace.create.before", { source: { kind: "directory", path: "/project" }, }, @@ -31,17 +30,17 @@ test("removing an old registration twice preserves a newer registration for the test("before hooks compose returned requests and preserve the original input", async () => { const hooks = new PluginHookHandlers(() => {}); - hooks.before("workspace.create", ({ request }) => { + hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: "first" }; }); - hooks.before("workspace.create", () => { + hooks.addHook("workspace.create.before", async () => { return; }); - hooks.before("workspace.create", ({ request }) => { + hooks.addHook("workspace.create.before", async ({ request }) => { return { ...request, title: request.title + ":second" }; }); const input = { source: { kind: "directory", path: "/project" } }; - expect(await hooks.invoke("operation", "before", "workspace.create", input, paseo)).toEqual({ + expect(await hooks.invoke("operation", "workspace.create.before", input, paseo)).toEqual({ source: { kind: "directory", path: "/project" }, title: "first:second", }); @@ -50,7 +49,7 @@ test("before hooks compose returned requests and preserve the original input", a test("teardown aborts an active callback and removes its registrations", async () => { const hooks = new PluginHookHandlers(() => {}); - hooks.before("workspace.create", async (_input, context) => { + hooks.addHook("workspace.create.before", async (_input, context) => { await new Promise((_resolve, reject) => { context.signal.addEventListener( "abort", @@ -63,8 +62,7 @@ test("teardown aborts an active callback and removes its registrations", async ( }); const invocation = hooks.invoke( "operation", - "before", - "workspace.create", + "workspace.create.before", { source: { kind: "directory", path: "/project" }, }, @@ -72,19 +70,18 @@ test("teardown aborts an active callback and removes its registrations", async ( ); hooks.close(); await expect(invocation).rejects.toThrow("Hook aborted"); - expect(hooks.catalog()).toEqual({ events: [], before: [] }); + expect(hooks.catalog()).toEqual([]); }); test("session-open hooks reject changes to session identity instead of silently ignoring them", async () => { const hooks = new PluginHookHandlers(() => {}); - hooks.before("agent.session_open", ({ request }) => { + hooks.addHook("agent.open.before", async ({ request }) => { return { ...request, provider: "another-provider" }; }); await expect( hooks.invoke( "operation", - "before", - "agent.session_open", + "agent.open.before", { agentId: "agent", workspaceId: "workspace", @@ -93,8 +90,166 @@ test("session-open hooks reject changes to session identity instead of silently reason: "resume", purpose: "interactive", env: {}, + title: null, + labels: {}, + tools: [], + mcpServers: {}, }, paseo, ), - ).rejects.toThrow("agent.session_open hooks can only change env"); + ).rejects.toThrow("agent.open.before hooks can only change env"); +}); + +test.each([{ title: "Changed" }, { labels: { changed: "true" } }])( + "session-open targeting metadata is read-only: %j", + async (patch) => { + const hooks = new PluginHookHandlers(() => {}); + hooks.addHook("agent.open.before", async ({ request }) => ({ ...request, ...patch })); + await expect( + hooks.invoke( + "operation", + "agent.open.before", + { + agentId: "agent", + workspaceId: null, + provider: "codex", + cwd: "/project", + title: "Original", + labels: {}, + tools: [], + mcpServers: {}, + env: {}, + reason: "create", + purpose: "interactive", + }, + paseo, + ), + ).rejects.toThrow("agent.open.before hooks can only change env, tools, and mcpServers"); + }, +); + +test("new request-changing registrations compose with legacy registrations in registration order", async () => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("workspace.create", ({ request }) => ({ ...request, title: "legacy" })); + hooks.addHook("workspace.create.before", async ({ request }) => ({ + ...request, + title: request.title + ":new", + })); + hooks.before("workspace.create", ({ request }) => ({ + ...request, + title: request.title + ":last", + })); + expect( + await hooks.invoke( + "operation", + "workspace.create.before", + { + source: { kind: "directory", path: "/project" }, + }, + paseo, + ), + ).toEqual({ source: { kind: "directory", path: "/project" }, title: "legacy:new:last" }); +}); + +test.each(["agent.session_open", "agent.create.prepare", "nonsense"])( + "addHook rejects unsupported name %s at registration", + (name) => { + const hooks = new PluginHookHandlers(() => {}); + // @ts-expect-error Runtime validation also protects untyped plugins. + expect(() => hooks.addHook(name, async () => {})).toThrow(name); + }, +); + +test.each([ + ["workspace.create", "workspace.create.before"], + ["workspace.created", "workspace.create.after"], + ["workspace.archived", "workspace.archive.after"], + ["agent.create", "agent.create.before"], + ["agent.created", "agent.create.after"], + ["agent.session_open", "agent.open.before"], + ["agent.closed", "agent.close.after"], + ["agent.archived", "agent.archive.after"], + ["agent.turn_started", "agent.turn.before"], + ["agent.turn_ended", "agent.turn.after"], + ["agent.permission_requested", "agent.permission.before"], + ["agent.permission_resolved", "agent.permission.after"], +] as const)("legacy %s registers only %s across the process boundary", (oldName, newName) => { + const hooks = new PluginHookHandlers(() => {}); + const handler = async () => {}; + const remove = + oldName === "workspace.create" || oldName === "agent.create" || oldName === "agent.session_open" + ? hooks.before(oldName, handler) + : hooks.on(oldName, handler); + hooks.addHook(newName, async () => {}); + expect(hooks.catalog()).toEqual([newName]); + remove(); + expect(hooks.catalog()).toEqual([newName]); + hooks.close(); + expect(hooks.catalog()).toEqual([]); +}); + +test("legacy notifications and addHook share payloads, registration order, and cleanup", async () => { + const hooks = new PluginHookHandlers(() => {}); + const received: unknown[] = []; + const remove = hooks.on("workspace.created", (event) => { + received.push(["old", event]); + }); + hooks.addHook("workspace.create.after", async (event) => { + received.push(["new", event]); + }); + const payload = { + workspace: { + id: "workspace", + projectId: "project", + cwd: "/project", + name: null, + archivedAt: null, + }, + }; + await hooks.invoke("first", "workspace.create.after", payload, paseo); + remove(); + remove(); + await hooks.invoke("second", "workspace.create.after", payload, paseo); + expect(received).toEqual([ + ["old", payload], + ["new", payload], + ["new", payload], + ]); +}); + +test("a waiting after hook propagates failures and stops later handlers", async () => { + const hooks = new PluginHookHandlers(() => {}); + const received: string[] = []; + hooks.addHook("worktree.create.after", async ({ worktree }) => { + received.push(worktree.path); + throw new Error("cannot initialize worktree"); + }); + hooks.addHook("worktree.create.after", async () => { + received.push("unexpected later handler"); + }); + await expect( + hooks.invoke( + "worktree", + "worktree.create.after", + { + worktree: { path: "/worktree", branch: "feature", sourceCheckout: "/source" }, + }, + paseo, + ), + ).rejects.toThrow("cannot initialize worktree"); + expect(received).toEqual(["/worktree"]); +}); + +test("checkout-branch hooks reject a branch-off baseRef", async () => { + const request = { + kind: "checkout-branch" as const, + repoRoot: "/repo", + sourceCheckout: "/repo", + branch: "feature", + slug: "feature", + path: "/work/feature", + }; + await expect( + validateBeforeResult("worktree.create.before", request, { ...request, baseRef: "main" }), + ).rejects.toThrow(/baseRef/); }); diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts index 29d5fda145..d4f58cc78c 100644 --- a/packages/server/src/server/plugins/lifecycle/index.ts +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -1,36 +1,98 @@ +import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; +import { + createLifecycleRegistration, + pluginHookRegistry, +} from "@getpaseo/plugin/internal/lifecycle"; +import { isDeepStrictEqual } from "node:util"; +import { + serializeAgentTool, + agentToolMetadataSchema, + type SessionOpenWireRequest, + type PluginAgentTools, +} from "../agent-tools.js"; +import type { AgentToolDefinition } from "@getpaseo/plugin/server"; import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js"; import { z } from "zod"; import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages"; import type { PluginHookAgent, + PluginHookPayload, PluginHookContext, PluginLifecycleRegistration, } from "@getpaseo/plugin/server"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { - PluginBeforeRequests, + PluginHookRequests, PluginHookWorkspace, - PluginLifecycleEvents, + PluginHookEvents, } from "@getpaseo/plugin/server"; import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages"; import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; -export const lifecycleEventNames = [ - "agent.created", - "agent.closed", - "agent.turn_started", - "agent.turn_ended", - "agent.permission_requested", - "agent.permission_resolved", - "agent.archived", - "workspace.created", - "workspace.archived", -] as const; -export const beforeHookNames = ["agent.create", "agent.session_open", "workspace.create"] as const; +export type PluginWireBeforeRequests = Omit & { + "agent.open.before": SessionOpenWireRequest; +}; + +/** Daemon-only results; ownership never crosses the plugin process boundary. */ +export type PluginBeforeResults = PluginWireBeforeRequests & { + "agent.open.before": SessionOpenWireRequest & { mcpServerOwners?: Record }; +}; + +const worktreeRequestFields = { + repoRoot: z.string(), + sourceCheckout: z.string(), + branch: z.string().min(1), + slug: z.string().refine((value) => validateBranchSlug(value).valid, "Invalid worktree slug"), + path: z.string().min(1), +}; +const changeRequestFields = { + headRef: z.string().min(1), + headRepositoryOwner: z.string().optional(), + checkoutRefs: z + .array(z.object({ remoteName: z.string().optional(), remoteRef: z.string() })) + .optional(), + pushRemoteUrl: z.string().optional(), + trackOriginHead: z.boolean().optional(), +}; +const worktreeRequestSchema = z.discriminatedUnion("kind", [ + z + .object({ ...worktreeRequestFields, kind: z.literal("branch-off"), baseRef: z.string().min(1) }) + .strict(), + z + .object({ + ...worktreeRequestFields, + kind: z.literal("checkout-branch"), + }) + .strict(), + z + .object({ + ...worktreeRequestFields, + ...changeRequestFields, + kind: z.literal("checkout-change-request"), + baseRef: z.string().min(1), + forge: z.string(), + changeRequestNumber: z.number().int().positive(), + headRepository: z.string().optional(), + }) + .strict(), + z + .object({ + ...worktreeRequestFields, + ...changeRequestFields, + kind: z.literal("checkout-github-pr"), + baseRef: z.string().min(1), + githubPrNumber: z.number().int().positive(), + }) + .strict(), +]); const beforeSchemas = { - "agent.create": CreateAgentRequestMessageSchema.pick({ config: true, env: true }).strict(), - "agent.session_open": z + "worktree.create.before": worktreeRequestSchema, + "agent.create.before": CreateAgentRequestMessageSchema.pick({ + config: true, + env: true, + }).strict(), + "agent.open.before": z .object({ agentId: z.string(), workspaceId: z.string().nullable(), @@ -38,28 +100,49 @@ const beforeSchemas = { cwd: z.string(), reason: z.enum(["create", "resume", "refresh", "import"]), purpose: z.enum(["interactive", "history"]), + title: z.string().nullable(), + labels: z.record(z.string(), z.string()), + tools: z.array(agentToolMetadataSchema), env: z.record(z.string(), z.string()), + mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(), }) .strict(), - "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), + "workspace.create.before": WorkspaceCreateRequestSchema.omit({ + type: true, + requestId: true, + }).strict(), }; +const sessionOpenLocalResultSchema = beforeSchemas["agent.open.before"].extend({ + tools: z.array( + z.custom( + (tool) => + typeof tool === "object" && + tool !== null && + "input" in tool && + "execute" in tool && + typeof tool.execute === "function", + ), + ), +}); + export interface PluginLifecycle { - emit( + wait( + name: "worktree.create.after", + payload: PluginHookPayload<"worktree.create.after">, + ): Promise; + emit(name: Name, event: PluginHookEvents[Name]): void; + before( name: Name, - event: PluginLifecycleEvents[Name], - ): void; - before( - name: Name, - request: PluginBeforeRequests[Name], - ): Promise; + request: PluginWireBeforeRequests[Name], + ): Promise; } -export function validateBeforeRequest( +export function validateBeforeRequest( name: Name, value: unknown, -): PluginBeforeRequests[Name] { - return beforeSchemas[name].parse(value) as PluginBeforeRequests[Name]; +): PluginWireBeforeRequests[Name] { + return beforeSchemas[name].parse(value) as PluginWireBeforeRequests[Name]; } export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): PluginHookWorkspace { @@ -96,32 +179,32 @@ export function publishAgentStream( event: AgentStreamEvent, ): void { if (event.type === "turn_started") { - lifecycle.emit("agent.turn_started", { agent, turnId: event.turnId ?? null }); + lifecycle.emit("agent.turn.before", { agent, turnId: event.turnId ?? null }); } else if (event.type === "turn_completed") { - lifecycle.emit("agent.turn_ended", { + lifecycle.emit("agent.turn.after", { agent, turnId: event.turnId ?? null, timeline: [], outcome: { kind: "completed" }, }); } else if (event.type === "turn_failed") { - lifecycle.emit("agent.turn_ended", { + lifecycle.emit("agent.turn.after", { agent, turnId: event.turnId ?? null, timeline: [], outcome: { kind: "failed", error: { message: event.error, code: event.code } }, }); } else if (event.type === "turn_canceled") { - lifecycle.emit("agent.turn_ended", { + lifecycle.emit("agent.turn.after", { agent, turnId: event.turnId ?? null, timeline: [], outcome: { kind: "canceled", reason: event.reason }, }); } else if (event.type === "permission_requested") { - lifecycle.emit("agent.permission_requested", { agent, request: event.request }); + lifecycle.emit("agent.permission.before", { agent, request: event.request }); } else if (event.type === "permission_resolved") { - lifecycle.emit("agent.permission_resolved", { + lifecycle.emit("agent.permission.after", { agent, requestId: event.requestId, resolution: event.resolution, @@ -129,31 +212,40 @@ export function publishAgentStream( } } -export function validateBeforeResult( +export async function validateBeforeResult( name: Name, - input: PluginBeforeRequests[Name], + input: PluginWireBeforeRequests[Name], output: unknown, -): PluginBeforeRequests[Name] { +): Promise { const result = validateBeforeRequest(name, output); - if (name === "agent.session_open") { - const previous = beforeSchemas["agent.session_open"].parse(input); - const next = beforeSchemas["agent.session_open"].parse(result); + if (name === "agent.open.before") { + const previous = beforeSchemas["agent.open.before"].parse(input); + const next = beforeSchemas["agent.open.before"].parse(result); if ( previous.agentId !== next.agentId || previous.workspaceId !== next.workspaceId || previous.provider !== next.provider || previous.cwd !== next.cwd || previous.reason !== next.reason || - previous.purpose !== next.purpose + previous.purpose !== next.purpose || + previous.title !== next.title || + !isDeepStrictEqual(previous.labels, next.labels) ) { - throw new Error("agent.session_open hooks can only change env"); + throw new Error("agent.open.before hooks can only change env, tools, and mcpServers"); } } - if (name === "agent.create") { - const previous = beforeSchemas["agent.create"].parse(input); - const next = beforeSchemas["agent.create"].parse(result); + if (name === "agent.create.before") { + const previous = beforeSchemas["agent.create.before"].parse(input); + const next = beforeSchemas["agent.create.before"].parse(result); if (previous.config.cwd !== next.config.cwd) { - throw new Error("agent.create hooks cannot change the workspace directory"); + throw new Error("agent.create.before hooks cannot change the workspace directory"); + } + } + if (name === "worktree.create.before") { + const previous = beforeSchemas["worktree.create.before"].parse(input); + const next = beforeSchemas["worktree.create.before"].parse(result); + if (previous.repoRoot !== next.repoRoot || previous.sourceCheckout !== next.sourceCheckout) { + throw new Error("worktree.create.before hooks cannot change repoRoot or sourceCheckout"); } } return result; @@ -162,37 +254,34 @@ export function validateBeforeResult( type Handler = (input: unknown, context: PluginHookContext) => unknown; export class PluginHookHandlers implements PluginLifecycleRegistration { - private readonly events = new Map>(); - private readonly transforms = new Map>(); + private readonly handlers = new Map>(); private readonly active = new Map(); private readonly changed: () => void; - constructor(changed: () => void) { + private readonly agentTools: PluginAgentTools | undefined; + + constructor(changed: () => void, agentTools?: PluginAgentTools) { + this.agentTools = agentTools; this.changed = changed; + const registration = createLifecycleRegistration((name, handler) => + this.register(name, handler as Handler), + ); + this.addHook = registration.addHook; + this.on = registration.on; + this.before = registration.before; } - readonly on: PluginLifecycleRegistration["on"] = (name, handler) => { - if (!lifecycleEventNames.includes(name)) { - throw new Error(`Unknown lifecycle event: ${name}`); - } - return this.register(this.events, name, handler as Handler); - }; + readonly addHook: PluginLifecycleRegistration["addHook"]; + readonly on: PluginLifecycleRegistration["on"]; + readonly before: PluginLifecycleRegistration["before"]; - readonly before: PluginLifecycleRegistration["before"] = (name, handler) => { - if (!beforeHookNames.includes(name)) { - throw new Error(`Unknown before hook: ${name}`); - } - return this.register(this.transforms, name, handler as Handler); - }; - - catalog(): { events: string[]; before: string[] } { - return { events: [...this.events.keys()], before: [...this.transforms.keys()] }; + catalog(): string[] { + return [...this.handlers.keys()]; } async invoke( id: string, - kind: "event" | "before", name: string, input: unknown, paseo: PluginHookContext["paseo"], @@ -200,33 +289,32 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { const controller = new AbortController(); this.active.set(id, controller); try { - if (kind === "before") { - if ( - !beforeHookNames.includes( - name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests, - ) - ) { - throw new Error(`Unknown before hook: ${name}`); + if (Object.hasOwn(beforeSchemas, name)) { + const hookName = name as keyof PluginHookRequests; + if (hookName === "agent.open.before") { + return await this.openSession(input, { paseo, signal: controller.signal }); } - const hookName = name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests; let request = validateBeforeRequest(hookName, input); - for (const handler of this.transforms.get(name) ?? []) { + for (const handler of this.handlers.get(name) ?? []) { controller.signal.throwIfAborted(); const result = await handler( { request: structuredClone(request) }, { paseo, signal: controller.signal }, ); - if (result !== undefined) { - request = validateBeforeResult(hookName, request, result); - } + if (result !== undefined) request = await validateBeforeResult(hookName, request, result); } return request; } - for (const handler of this.events.get(name) ?? []) { + for (const handler of this.handlers.get(name) ?? []) { controller.signal.throwIfAborted(); try { await handler(structuredClone(input), { paseo, signal: controller.signal }); } catch (error) { + if ( + Object.hasOwn(pluginHookRegistry, name) && + pluginHookRegistry[name as keyof typeof pluginHookRegistry].waits + ) + throw error; console.error(`Lifecycle hook ${name} failed`, error); } } @@ -236,6 +324,36 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { } } + private async openSession( + input: unknown, + context: PluginHookContext, + ): Promise { + let request = validateBeforeRequest("agent.open.before", input); + this.agentTools?.detach(request.agentId); + let tools: AgentToolDefinition[] = []; + for (const handler of this.handlers.get("agent.open.before") ?? []) { + context.signal.throwIfAborted(); + const result = await handler( + { request: { ...structuredClone(request), tools: [...tools] } }, + context, + ); + if (result === undefined) continue; + const local = sessionOpenLocalResultSchema.parse(result); + const metadata = local.tools.map(serializeAgentTool); + request = await validateBeforeResult("agent.open.before", request, { + ...local, + tools: metadata, + }); + tools = local.tools; + } + context.signal.throwIfAborted(); + if (request.purpose === "history") { + request.tools = []; + request.mcpServers = {}; + } else this.agentTools?.attach(request.agentId, tools); + return request; + } + cancel(id: string): void { this.active.get(id)?.abort(); } @@ -244,19 +362,14 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { for (const controller of this.active.values()) { controller.abort(); } - this.events.clear(); - this.transforms.clear(); + this.handlers.clear(); } - private register( - registry: Map>, - name: string, - handler: Handler, - ): () => void { - let handlers = registry.get(name); + private register(name: string, handler: Handler): () => void { + let handlers = this.handlers.get(name); if (!handlers) { handlers = new Set(); - registry.set(name, handlers); + this.handlers.set(name, handlers); } handlers.add(handler); this.changed(); @@ -265,7 +378,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration { return; } if (handlers.size === 0) { - registry.delete(name); + this.handlers.delete(name); } this.changed(); }; diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 466780b79a..7b694f1ef3 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -61,10 +61,24 @@ describe("managed Git plugin sources", () => { roots.push(home); const sources = new ManagedPluginSources(home); - const failure = sources.prepareInstall({ - source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git", + // Own the failure response instead of relying on a closed port and OS connect timing. + const server = createServer((_request, response) => { + response.writeHead(500, { "Content-Type": "text/plain" }); + response.end("clone rejected for oauth2:super-secret\n"); }); - await expect(failure).rejects.not.toThrow(/oauth2|super-secret/); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as { port: number }; + try { + const failure = sources.prepareInstall({ + source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`, + }); + await expect(failure).rejects.toThrow("500"); + await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]"); + await expect(failure).rejects.not.toThrow(/oauth2|super-secret/); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } }); it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => { diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index 5da355d5df..042388447b 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -1,3 +1,9 @@ +import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import pino from "pino"; +import { createPaseoDaemon } from "../bootstrap.js"; +import { loadConfig } from "../config.js"; import { createRequire } from "node:module"; import { runInNewContext } from "node:vm"; import { fileURLToPath } from "node:url"; @@ -198,6 +204,51 @@ export default function contribute(server: PluginServerContext) { } }, 60_000); +test("password-protected config reload preserves startup auth and session construction does not read config", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-protected-config-reload-")); + roots.push(home); + const password = "synthetic-audit-password"; + const config = loadConfig(home, { env: { PASEO_PASSWORD: password, PASEO_RELAY_ENABLED: "0" } }); + const daemon = await createPaseoDaemon( + { + ...config, + listen: "127.0.0.1:0", + mcpEnabled: false, + agentClients: createTestAgentClients(), + staticDir: home, + }, + pino({ level: "silent" }), + ); + await daemon.start(); + const target = daemon.getListenTarget(); + if (target?.type !== "tcp") throw new Error("Expected TCP daemon"); + const connect = () => + new DaemonClient({ url: `ws://127.0.0.1:${target.port}/ws`, password, appVersion: "0.4.0" }); + const client = connect(); + const replacement = connect(); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const configPath = path.join(home, "config.json"); + await writeFile(configPath, JSON.stringify({ daemon: { browserTools: { enabled: true } } })); + const { result: reloaded, blockingMs } = await measureMainThreadWork( + "daemon config reload main-thread work", + () => client.reloadDaemonConfig(), + ); + expect(reloaded.appliedPaths).toContain("daemon.browserTools.enabled"); + expect(blockingMs).toBeLessThan(50); + expect((await client.getDaemonConfig()).config.browserTools.enabled).toBe(true); + // A new physical session uses ready daemon state; corrupt config is reported only by an explicit read. + await writeFile(configPath, "invalid JSON"); + await replacement.connect(); + await replacement.fetchAgents({ subscribe: {} }); + await expect(replacement.reloadDaemonConfig()).rejects.toThrow("Invalid JSON"); + } finally { + await Promise.all([client.close(), replacement.close()]); + await daemon.stop(); + } +}, 60_000); + test("daemon config reload enables and disables configured plugins without restarting", async () => { const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-reload-plugin-")); const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-reload-home-")); @@ -465,3 +516,24 @@ export default function contribute(server: PluginServerContext) { await daemon.close(); } }, 60_000); + +test("keeps reload and enable responsive while compiling a real plugin", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-responsive-plugin-")); + roots.push(directory); + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + fileURLToPath(new URL("./test-fixtures/compile-responsiveness.e2e.ts", import.meta.url)), + directory, + ], + { timeout: 20_000 }, + ); + process.stderr.write(stderr); + const results = z + .array(z.object({ blockingMs: z.number() })) + .length(2) + .parse(JSON.parse(stdout)); + for (const result of results) expect(result.blockingMs).toBeLessThan(20); +}); diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index b3f363cb29..e582387d48 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,3 +1,4 @@ +import type { PluginHookAgent } from "@getpaseo/plugin/server"; import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage"; import type { ProviderConnectRequest, @@ -31,6 +32,9 @@ export interface PluginUsageSourceMetadata { } export type PluginProcessRequest = + | { type: "tool.call"; requestId: string; agent: PluginHookAgent; name: string; input: unknown } + | { type: "tool.cancel"; requestId: string } + | { type: "tools.detach"; agentId: string } | { type: "provider.status"; requestId: string; @@ -51,7 +55,7 @@ export type PluginProcessRequest = providerId: string; options: ProviderCatalogOptions; } - | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } + | { type: "hook"; requestId: string; name: string; input: unknown } | { type: "hook.cancel"; requestId: string } | { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown } | { type: "usage.discover"; requestId: string; sourceId: string; scope: UsageScope } @@ -75,13 +79,13 @@ export type PluginProcessRequest = export type PluginProcessMessage = | { type: "settings.changed"; settingsId: string } - | { type: "hooks.changed"; hooks: { events: string[]; before: string[] } } + | { type: "hooks.changed"; hooks: string[] } | { type: "ready"; methods: string[]; providers: PluginProviderMetadata[]; usageSources?: PluginUsageSourceMetadata[]; - hooks?: { events: string[]; before: string[] }; + hooks?: string[]; } | { type: "result"; requestId: string; output: unknown } | { type: "error"; requestId: string; error: string } @@ -105,7 +109,7 @@ export type PluginProcessMessage = | { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean } | { type: "paseo_close" }; -const hooksSchema = z.object({ events: z.array(z.string()), before: z.array(z.string()) }).strict(); +const hooksSchema = z.array(z.string()); const providerMetadataSchema = z .object({ @@ -141,6 +145,26 @@ const frameFields = { export const PluginProcessRequestSchema: z.ZodType = z.discriminatedUnion( "type", [ + z + .object({ + type: z.literal("tool.call"), + requestId: z.string(), + agent: z + .object({ + id: z.string(), + workspaceId: z.string().nullable(), + parentAgentId: z.string().nullable(), + provider: z.string(), + cwd: z.string(), + title: z.string().nullable(), + }) + .strict(), + name: z.string(), + input: z.unknown(), + }) + .strict(), + z.object({ type: z.literal("tool.cancel"), requestId: z.string() }).strict(), + z.object({ type: z.literal("tools.detach"), agentId: z.string() }).strict(), z .object({ type: z.literal("provider.status"), @@ -187,7 +211,6 @@ export const PluginProcessRequestSchema: z.ZodType = z.dis .object({ type: z.literal("hook"), requestId: z.string(), - kind: z.enum(["event", "before"]), name: z.string(), input: z.unknown(), }) diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 5e6efa1925..62514cd0b2 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,3 +1,4 @@ +import { PluginAgentTools } from "./agent-tools.js"; import { PluginHookHandlers } from "./lifecycle/index.js"; import { evaluateBundle } from "./bundle-evaluator.js"; import { @@ -23,6 +24,7 @@ import { createPluginClientId } from "./plugin-session-identity.js"; import { PluginSettingsStore } from "./settings/index.js"; import { readPluginProviderIcon } from "./provider-icon.js"; +import { initializeEnvironmentProxy } from "../network/index.js"; function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -59,9 +61,10 @@ export function createPluginWorker(options: { handler: RpcHandler; } + const agentTools = new PluginAgentTools(); const hooks = new PluginHookHandlers(() => { send({ type: "hooks.changed", hooks: hooks.catalog() }); - }); + }, agentTools); const handlers = new Map(); const providers = new Map(); const usageSources = new Map(); @@ -286,6 +289,7 @@ export function createPluginWorker(options: { registerProvider, registerUsageSource, registerSettings, + addHook: hooks.addHook, on: hooks.on, before: hooks.before, }); @@ -322,6 +326,7 @@ export function createPluginWorker(options: { ?.dispose() .catch((error) => console.error("Plugin API cleanup failed", error)); hooks.close(); + agentTools.close(); for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; const currentCleanup = cleanup; cleanup = null; @@ -381,6 +386,42 @@ export function createPluginWorker(options: { } } + function handleToolMessage( + message: PluginProcessRequest, + ): message is Extract< + PluginProcessRequest, + { type: "tool.call" | "tool.cancel" | "tools.detach" } + > { + if (message.type === "tools.detach") { + agentTools.detach(message.agentId); + return true; + } + if (message.type === "tool.cancel") { + agentTools.cancel(message.requestId); + return true; + } + if (message.type === "tool.call") { + if (!paseo) { + send({ type: "error", requestId: message.requestId, error: "Plugin API unavailable" }); + return true; + } + void agentTools + .call(message.requestId, message.agent, message.name, message.input, paseo) + .then((output) => + send({ + type: "result", + requestId: message.requestId, + output: jsonTransportValue(output), + }), + ) + .catch((error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + return true; + } + return false; + } + function handleMessage(rawMessage: unknown): void { const parsed = PluginProcessRequestSchema.safeParse(rawMessage); if (!parsed.success) { @@ -422,6 +463,7 @@ export function createPluginWorker(options: { rejectWhileStopping(message); return; } + if (handleToolMessage(message)) return; if (message.type === "provider.status") { void (async () => { const provider = providers.get(message.providerId); @@ -523,7 +565,7 @@ export function createPluginWorker(options: { }); return; } - void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then( + void hooks.invoke(message.requestId, message.name, message.input, paseo).then( (output) => { return send({ type: "result", requestId: message.requestId, output }); }, @@ -565,6 +607,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { const parsed = PluginProcessRequestSchema.safeParse(raw); if (!worker && parsed.success && parsed.data.type === "initialize") { try { + initializeEnvironmentProxy(); worker = createPluginWorker({ channel, contribute: evaluateBundle(parsed.data.bundle) }); } catch (error) { channel.send({ type: "fatal", error: describeError(error) }); diff --git a/packages/server/src/server/plugins/preparation-build.ts b/packages/server/src/server/plugins/preparation-build.ts new file mode 100644 index 0000000000..cd42772617 --- /dev/null +++ b/packages/server/src/server/plugins/preparation-build.ts @@ -0,0 +1,76 @@ +import { spawnProcess } from "../../utils/spawn.js"; + +import type { PluginManifest } from "./manifest.js"; + +const OUTPUT_LIMIT = 64 * 1024; + +export async function runPluginBuild( + directory: string, + commands: PluginManifest["build"], + logger: { info(data: Record, message: string): void }, + environment: NodeJS.ProcessEnv, +): Promise { + for (const command of commands ?? []) { + logger.info({ directory, command }, "Running plugin build command"); + const result = await run(command, directory, environment); + if (result.stdout) + logger.info({ directory, command, output: result.stdout }, "Plugin build stdout"); + if (result.stderr) + logger.info({ directory, command, output: result.stderr }, "Plugin build stderr"); + if (result.error) { + throw new Error( + `Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`, + ); + } + if (result.exitCode !== 0) { + throw new Error( + `Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`, + ); + } + } +} + +function run( + command: string[], + directory: string, + environment: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve) => { + const executable = command[0]!; + const arguments_ = command.slice(1); + const child = spawnProcess(executable, arguments_, { cwd: directory, env: environment }); + let stdout = ""; + let stderr = ""; + let error: Error | undefined; + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout = appendOutput(stdout, chunk.toString()); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr = appendOutput(stderr, chunk.toString()); + }); + child.once("error", (spawnError) => { + error = spawnError; + }); + child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error })); + }); +} + +function appendOutput(current: string, next: string): string { + return `${current}${next}`.slice(-OUTPUT_LIMIT); +} + +function formatCommand(command: string[]): string { + return command.map((argument) => JSON.stringify(argument)).join(" "); +} + +function formatOutput(result: CommandResult): string { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + return output ? `\n${output}` : ""; +} + +interface CommandResult { + exitCode: number | null; + stdout: string; + stderr: string; + error?: Error; +} diff --git a/packages/server/src/server/plugins/preparation.test.ts b/packages/server/src/server/plugins/preparation.test.ts index 0fdb0e34fc..d83fa012e3 100644 --- a/packages/server/src/server/plugins/preparation.test.ts +++ b/packages/server/src/server/plugins/preparation.test.ts @@ -1,6 +1,8 @@ import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { Writable } from "node:stream"; +import pino from "pino"; import { afterEach, describe, expect, it } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { runPluginBuild } from "./preparation.js"; @@ -117,6 +119,52 @@ describe("runPluginBuild", () => { ).rejects.toThrow(/Plugin build command failed \(exit 2\)/); }); + it("forwards build progress and output from the worker to the caller's logger", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const records: Array<{ msg: string; output?: string }> = []; + const logger = pino( + new Writable({ + write(chunk, _encoding, callback) { + records.push(JSON.parse(chunk.toString())); + callback(); + }, + }), + ); + await runPluginBuild( + directory, + [[process.execPath, "-e", 'console.log("prepared"); console.error("diagnostic");']], + logger, + ); + expect(records).toMatchObject([ + { msg: "Running plugin build command" }, + { msg: "Plugin build stdout", output: "prepared\n" }, + { msg: "Plugin build stderr", output: expect.stringContaining("diagnostic\n") }, + ]); + }); + + it("uses the caller's current environment across consecutive builds", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const previous = process.env.PASEO_PLUGIN_TEST_BUILD_VALUE; + const command = [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync(process.argv[1], process.env.PASEO_PLUGIN_TEST_BUILD_VALUE);', + ]; + try { + process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "first"; + await runPluginBuild(directory, [[...command, "first.txt"]], createTestLogger()); + process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = "second"; + await runPluginBuild(directory, [[...command, "second.txt"]], createTestLogger()); + expect(await readFile(path.join(directory, "first.txt"), "utf8")).toBe("first"); + expect(await readFile(path.join(directory, "second.txt"), "utf8")).toBe("second"); + } finally { + if (previous === undefined) delete process.env.PASEO_PLUGIN_TEST_BUILD_VALUE; + else process.env.PASEO_PLUGIN_TEST_BUILD_VALUE = previous; + } + }); + it("handles empty or undefined build commands gracefully", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/preparation.ts b/packages/server/src/server/plugins/preparation.ts index 267f9697e9..79aec65cdc 100644 --- a/packages/server/src/server/plugins/preparation.ts +++ b/packages/server/src/server/plugins/preparation.ts @@ -1,71 +1,15 @@ -import { spawnProcess } from "../../utils/spawn.js"; import type pino from "pino"; import type { PluginManifest } from "./manifest.js"; - -const OUTPUT_LIMIT = 64 * 1024; +import { runCompilerTask } from "./compiler.js"; export async function runPluginBuild( directory: string, commands: PluginManifest["build"], logger: pino.Logger, ): Promise { - for (const command of commands ?? []) { - logger.info({ directory, command }, "Running plugin build command"); - const result = await run(command, directory); - if (result.stdout) - logger.info({ directory, command, output: result.stdout }, "Plugin build stdout"); - if (result.stderr) - logger.info({ directory, command, output: result.stderr }, "Plugin build stderr"); - if (result.error) { - throw new Error( - `Plugin build command failed to start: ${formatCommand(command)}\n${result.error.message}`, - ); - } - if (result.exitCode !== 0) { - throw new Error( - `Plugin build command failed (exit ${result.exitCode}): ${formatCommand(command)}${formatOutput(result)}`, - ); - } - } -} - -function run(command: string[], directory: string): Promise { - return new Promise((resolve) => { - const executable = command[0]!; - const arguments_ = command.slice(1); - const child = spawnProcess(executable, arguments_, { cwd: directory }); - let stdout = ""; - let stderr = ""; - let error: Error | undefined; - child.stdout?.on("data", (chunk: Buffer | string) => { - stdout = appendOutput(stdout, chunk.toString()); - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - stderr = appendOutput(stderr, chunk.toString()); - }); - child.once("error", (spawnError) => { - error = spawnError; - }); - child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr, error })); - }); -} - -function appendOutput(current: string, next: string): string { - return `${current}${next}`.slice(-OUTPUT_LIMIT); -} - -function formatCommand(command: string[]): string { - return command.map((argument) => JSON.stringify(argument)).join(" "); -} - -function formatOutput(result: CommandResult): string { - const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); - return output ? `\n${output}` : ""; -} - -interface CommandResult { - exitCode: number | null; - stdout: string; - stderr: string; - error?: Error; + if (!commands?.length) return; + await runCompilerTask( + { kind: "prepare", directory, commands, environment: { ...process.env } }, + (data, message) => logger.info(data, message), + ); } diff --git a/packages/server/src/server/plugins/requirements.posix.test.ts b/packages/server/src/server/plugins/requirements.posix.test.ts index 65462336a3..597d907e18 100644 --- a/packages/server/src/server/plugins/requirements.posix.test.ts +++ b/packages/server/src/server/plugins/requirements.posix.test.ts @@ -1,4 +1,5 @@ import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -68,9 +69,18 @@ it("rejects incompatible installs without persisting them, then accepts the corr await expect(service.installDirectory({ path: root })).resolves.toMatchObject({ status: "running", }); - expect(service.catalog()).toEqual([ - { id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) }, + const catalog = service.catalog(); + expect(catalog).toEqual([ + { + id: "example", + requirements: { paseo: "^0.8.0" }, + clientBundle: expect.any(String), + clientBundleHash: expect.any(String), + }, ]); + expect(catalog[0]!.clientBundleHash).toBe( + createHash("sha256").update(catalog[0]!.clientBundle).digest("hex"), + ); }); it("marks pre-0.8 plugins failed on startup and recovers after migration and reload", async () => { diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 8dd80ce499..cfa3292567 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -1,5 +1,6 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { fork } from "node:child_process"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; import path from "node:path"; @@ -10,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentStreamEvent } from "../agent/agent-sdk-types.js"; import { PluginAgentClientRegistry } from "../agent/plugin-provider.js"; import { PluginRuntime } from "./runtime.js"; +import { resolveDaemonVersion } from "../daemon-version.js"; import type { PluginSessionSocket } from "./session-socket.js"; const temporaryDirectories: string[] = []; @@ -106,6 +108,12 @@ function createReloadChild( }; } +function sha256(text: string | undefined): string { + return createHash("sha256") + .update(text ?? "") + .digest("hex"); +} + function createTestRuntime( dependencies: NonNullable[2]> = {}, logger = pino({ level: "silent" }), @@ -651,7 +659,8 @@ lines.on("line", (line) => { type: "timeline.item", item: expect.objectContaining({ type: "assistant_message", - id: "assistant-1", + id: JSON.stringify(["agent_message_chunk", "assistant-1"]), + messageId: "assistant-1", text: "ACP says hi", }), }), @@ -1535,6 +1544,7 @@ export default function contribute(server: { registerProvider(provider: Provider expect(spawnChild).not.toHaveBeenCalled(); expect(runtime.catalog()[0]?.clientBundle).toContain("addTheme"); + expect(runtime.catalog()[0]?.clientBundleHash).toBe(sha256(runtime.catalog()[0]?.clientBundle)); await runtime.stopAll(); }); @@ -1697,6 +1707,7 @@ export function inspectHost(_input: z.input) { const plugin = runtime.catalog()[0]; expect(plugin?.clientBundle).toContain("Client-only surface"); expect(plugin?.clientBundle).not.toContain("node:os"); + expect(plugin?.clientBundleHash).toBe(sha256(plugin?.clientBundle)); await expect(runtime.invoke("split-runtime", "inspect", {})).resolves.toMatchObject({ platform: expect.any(String), }); @@ -1849,9 +1860,9 @@ export default function contribute(plugin: any) { const child = createReloadChild("blocked", events); const runtime = createTestRuntime({ spawnChild: () => child }); - await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow( - "Plugin start cancelled: blocked", - ); + await expect( + runtime.startPlugin("blocked", directory, () => events.length === 0), + ).rejects.toThrow("Plugin start cancelled: blocked"); expect(runtime.catalog()).toEqual([]); expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]); @@ -2055,3 +2066,54 @@ export default function contribute(server) { await runtime.stopAll(); } }); + +const examplesDirectory = fileURLToPath( + new URL("../../../../../plugin-examples/", import.meta.url), +); +// The Markdown example owns npm preparation in the real app fixture: +// packages/app/e2e/browser/plugin-file-panel-examples.spec.ts. +const examples = (await readdir(examplesDirectory, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && entry.name !== "markdown-panel") + .map((entry) => entry.name); + +it.each(examples)("builds and loads the %s plugin example", async (example) => { + const settingsDirectory = await mkdtemp(path.join(tmpdir(), "paseo-example-settings-")); + temporaryDirectories.push(settingsDirectory); + const runtime = createTestRuntime( + { settingsDirectory }, + undefined, + resolveDaemonVersion(import.meta.url), + ); + try { + await runtime.startPlugin(example, path.join(examplesDirectory, example)); + expect(runtime.catalog()).toMatchObject([{ id: example }]); + expect(pluginMessages(runtime, example)).toContain("[paseo] Plugin ready"); + } finally { + await runtime.stopAll(); + } +}); + +it("does not initialize a plugin cancelled during compilation", async () => { + const directory = await createPlugin("cancelled-build", "export default () => {};"); + const marker = path.join(directory, "initialized.txt"); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import { writeFileSync } from "node:fs"; +export default function contribute() { + writeFileSync(${JSON.stringify(marker)}, "initialized"); + return () => {}; +}`, + ); + const runtime = createTestRuntime(); + let allowed = true; + try { + const loading = runtime.startPlugin("cancelled-build", directory, () => allowed); + allowed = false; + await expect(loading).rejects.toThrow("Plugin start cancelled: cancelled-build"); + expect(await readTextIfPresent(marker)).toBeNull(); + expect(runtime.catalog()).toEqual([]); + } finally { + await runtime.stopAll(); + } +}); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 9886a368e7..31f313cb7f 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,16 +1,19 @@ +import { validateWorktreePath } from "../../utils/worktree-path.js"; +import type { PluginHookPayload } from "@getpaseo/plugin/server"; +import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js"; +import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js"; import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { ProviderStatusSchema, type ProviderStatus, type ProviderStatusRequest, } from "@getpaseo/plugin/server/provider"; -import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; +import type { PluginHookEvents, PluginHookAgent, AgentToolResult } from "@getpaseo/plugin/server"; import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { fork } from "node:child_process"; -import { stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import type pino from "pino"; import { PROVIDER_CAPABILITIES, @@ -22,10 +25,8 @@ import { type ProviderInput, } from "@getpaseo/plugin/server/provider"; import type { PluginLogEntry } from "@getpaseo/protocol/messages"; -import { compilePlugin } from "./compiler.js"; -import { readPluginManifest } from "./manifest.js"; +import { compileDirectoryPlugin } from "./compiler.js"; import type { PluginRequirements } from "@getpaseo/protocol/messages"; -import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; import type { PluginProcessMessage, PluginProcessRequest, @@ -37,9 +38,8 @@ import { PluginSessionSocket } from "./session-socket.js"; import { InternalPluginChild } from "./internal-child.js"; import { evaluateBundle } from "./bundle-evaluator.js"; -const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; -const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; const REQUEST_TIMEOUT_MS = 30_000; +const TOOL_CALL_TIMEOUT_MS = 10 * 60_000; const MAX_LOG_ENTRIES = 500; const MAX_LOG_BYTES = 256 * 1024; const MAX_LOG_LINE_BYTES = 16 * 1024; @@ -69,10 +69,13 @@ interface PendingInvocation { interface LoadedPlugin { id: string; + instanceId: string; requirements: PluginRequirements | undefined; clientBundle: string; + /** Lets a client that already holds this bundle skip downloading it again. */ + clientBundleHash: string; methods: ReadonlySet; - hooks: { events: string[]; before: string[] }; + hooks: string[]; providers: readonly PluginProviderMetadata[]; usageSources: readonly PluginUsageSourceMetadata[]; child: PluginChild | null; @@ -243,6 +246,10 @@ function terminatePluginChild(child: PluginChild): void { if (!child.killed) child.kill(); } +function hashClientBundle(clientBundle: string): string { + return createHash("sha256").update(clientBundle).digest("hex"); +} + function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -265,35 +272,6 @@ function send(child: PluginChild, message: PluginProcessRequest): Promise }); } -async function findEntry(directory: string, filenames: readonly string[]): Promise { - for (const filename of filenames) { - const filePath = path.join(directory, filename); - const info = await stat(filePath).catch(() => null); - if (info?.isFile()) return filePath; - } - return null; -} - -async function resolveEntryPaths(directory: string): Promise<{ - client: string | null; - server: string | null; -}> { - const [client, server] = await Promise.all([ - findEntry(directory, CLIENT_ENTRY_FILENAMES), - findEntry(directory, SERVER_ENTRY_FILENAMES), - ]); - if (client || server) return { client, server }; - const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]); - if (legacyEntry) { - throw new Error( - "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration", - ); - } - throw new Error( - `Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`, - ); -} - export class PluginRuntime { private readonly plugins = new Map(); private readonly pendingEvents = new Set>(); @@ -302,6 +280,7 @@ export class PluginRuntime { private readonly spawnChild: () => PluginChild; private sessionHost: PluginPaseoSessionHost | null; private readonly listeners = new Set<(pluginId: string, error?: string) => void>(); + private readonly sessionOpenListeners = new Set<() => void>(); constructor( logger: pino.Logger, @@ -324,6 +303,16 @@ export class PluginRuntime { return () => this.listeners.delete(listener); } + subscribeSessionOpenChanges(listener: () => void): () => void { + this.sessionOpenListeners.add(listener); + return () => this.sessionOpenListeners.delete(listener); + } + + private notifySessionOpenChange(loaded: LoadedPlugin): void { + if (!loaded.hooks.includes("agent.open.before")) return; + for (const listener of this.sessionOpenListeners) listener(); + } + async startPlugin( pluginId: string, configuredPath: string, @@ -331,15 +320,22 @@ export class PluginRuntime { ): Promise { if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`); this.appendLog(pluginId, "stdout", "[paseo] Loading plugin"); - const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath).catch((error) => { - this.appendLog(pluginId, "stderr", `[paseo] Plugin failed to load: ${describeError(error)}`); - throw error; - }); + const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath, canPublish).catch( + (error) => { + this.appendLog( + pluginId, + "stderr", + `[paseo] Plugin failed to load: ${describeError(error)}`, + ); + throw error; + }, + ); if (!canPublish()) { await this.stopPlugin(loaded); throw new Error(`Plugin start cancelled: ${pluginId}`); } this.plugins.set(pluginId, loaded); + this.notifySessionOpenChange(loaded); this.appendLog(pluginId, "stdout", "[paseo] Plugin ready"); } @@ -347,12 +343,8 @@ export class PluginRuntime { if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`); this.appendLog(input.id, "stdout", "[paseo] Loading plugin"); const directory = path.resolve(input.directory); - const manifest = await readPluginManifest(directory); - if (manifest.id !== input.id) { - throw new Error(`Built-in plugin ${input.id} has manifest ID ${manifest.id}`); - } - assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); - const bundles = await compilePlugin(await resolveEntryPaths(directory)); + const bundles = await compileDirectoryPlugin(directory, this.daemonVersion, input.id); + const { manifest } = bundles; if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`); const loaded = await this.launchPlugin({ pluginId: input.id, @@ -363,29 +355,37 @@ export class PluginRuntime { clientBundle: bundles.clientBundle ?? "", }); this.plugins.set(input.id, loaded); + this.notifySessionOpenChange(loaded); this.appendLog(input.id, "stdout", "[paseo] Plugin ready"); } async validatePlugin(configuredPath: string): Promise { - const directory = path.resolve(configuredPath); - const manifest = await readPluginManifest(directory); - assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); - const entryPaths = await resolveEntryPaths(directory); - await compilePlugin(entryPaths); + await compileDirectoryPlugin(path.resolve(configuredPath), this.daemonVersion); } async stopPluginById(pluginId: string): Promise { const loaded = this.plugins.get(pluginId); if (!loaded) return false; this.plugins.delete(pluginId); + this.notifySessionOpenChange(loaded); this.rejectPending(loaded, `Plugin stopped: ${pluginId}`); await this.stopPlugin(loaded); return true; } - catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> { + catalog(): Array<{ + id: string; + clientBundle: string; + clientBundleHash: string; + requirements?: PluginRequirements; + }> { return [...this.plugins.values()] - .map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements })) + .map(({ id, clientBundle, clientBundleHash, requirements }) => ({ + id, + clientBundle, + clientBundleHash, + requirements, + })) .sort((left, right) => left.id.localeCompare(right.id)); } @@ -514,18 +514,14 @@ export class PluginRuntime { return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input }); } - emit( - name: Name, - event: PluginLifecycleEvents[Name], - ): void { + emit(name: Name, event: PluginHookEvents[Name]): void { for (const loaded of this.plugins.values()) { - if (!loaded.hooks.events.includes(name)) { + if (!loaded.hooks.includes(name)) { continue; } const request = this.request(loaded, { type: "hook", requestId: randomUUID(), - kind: "event", name, input: event, }) @@ -546,33 +542,76 @@ export class PluginRuntime { await Promise.all(this.pendingEvents); } - async before( + async wait( + name: "worktree.create.after", + payload: PluginHookPayload<"worktree.create.after">, + ): Promise { + const plugins = [...this.plugins.values()].sort((left, right) => + left.id.localeCompare(right.id), + ); + for (const loaded of plugins) { + if (!loaded.hooks.includes(name)) continue; + try { + await this.request(loaded, { type: "hook", requestId: randomUUID(), name, input: payload }); + } catch (error) { + throw new Error(`Plugin ${loaded.id} ${name} failed: ${describeError(error)}`, { + cause: error, + }); + } + } + } + + async before( name: Name, - input: PluginBeforeRequests[Name], - ): Promise { + input: PluginWireBeforeRequests[Name], + ): Promise { let request = validateBeforeRequest(name, input); const plugins = [...this.plugins.values()].sort((left, right) => { return left.id.localeCompare(right.id); }); + const attached: AttachedAgentTool[] = []; + const mcpServers: SessionOpenWireRequest["mcpServers"] = {}; + const mcpServerOwners: Record = {}; for (const loaded of plugins) { - if (!loaded.hooks.before.includes(name)) { + if (!loaded.hooks.includes(name)) { continue; } try { const output = await this.request(loaded, { type: "hook", requestId: randomUUID(), - kind: "before", name, - input: request, + input: name === "agent.open.before" ? { ...request, tools: [], mcpServers: {} } : request, }); - request = validateBeforeResult(name, request, output); + request = await validateBeforeResult(name, request, output); + if (name === "worktree.create.before") { + const worktree = request as PluginWireBeforeRequests["worktree.create.before"]; + worktree.path = await validateWorktreePath({ + candidate: worktree.path, + repoPath: worktree.repoRoot, + }); + } + if (name === "agent.open.before") { + const opened = request as SessionOpenWireRequest; + this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers); + this.collectAgentTools(attached, loaded, opened.tools); + } } catch (error) { - throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, { + if (name === "agent.open.before") + this.detachTools((request as SessionOpenWireRequest).agentId); + throw new Error(`Plugin ${loaded.id} ${name} failed: ${describeError(error)}`, { cause: error, }); } } + if (name === "agent.open.before") { + return { + ...request, + tools: attached, + mcpServers, + mcpServerOwners, + } as PluginBeforeResults[Name]; + } return request; } @@ -594,25 +633,119 @@ export class PluginRuntime { return output; } + private collectMcpServers( + servers: SessionOpenWireRequest["mcpServers"], + owners: Record, + loaded: LoadedPlugin, + contributions: SessionOpenWireRequest["mcpServers"], + ): void { + for (const [name, server] of Object.entries(contributions)) { + if (Object.hasOwn(servers, name)) { + throw new Error( + `MCP server ${name} from plugin ${loaded.id} collides with plugin ${owners[name]}`, + ); + } + servers[name] = server; + owners[name] = loaded.id; + } + } + + private collectAgentTools( + attached: AttachedAgentTool[], + loaded: LoadedPlugin, + tools: SessionOpenWireRequest["tools"], + ): void { + for (const tool of tools) { + const collision = attached.find((entry) => entry.name === tool.name); + if (collision) + throw new Error( + `Agent tool ${tool.name} from plugin ${loaded.id} collides with plugin ${collision.pluginId}`, + ); + attached.push({ ...tool, pluginId: loaded.id, pluginInstanceId: loaded.instanceId }); + } + } + + isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean { + return this.plugins.get(pluginId)?.instanceId === pluginInstanceId; + } + + detachTools(agentId: string): void { + for (const loaded of this.plugins.values()) { + if (loaded.child) void send(loaded.child, { type: "tools.detach", agentId }).catch(() => {}); + } + } + + async callTool( + pluginId: string, + pluginInstanceId: string, + agent: PluginHookAgent, + name: string, + input: unknown, + signal?: AbortSignal, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded || loaded.instanceId !== pluginInstanceId) + return { isError: true, text: `Plugin is not available: ${pluginId}` }; + const requestId = randomUUID(); + try { + return (await this.request( + loaded, + { type: "tool.call", requestId, agent, name, input }, + signal, + )) as AgentToolResult; + } catch (error) { + if (signal?.aborted) throw error; + return { isError: true, text: describeError(error) }; + } + } + private request( loaded: LoadedPlugin, message: Extract, + signal?: AbortSignal, ): Promise { const child = loaded.child; const pluginId = loaded.id; if (!child) throw new Error(`Plugin ${pluginId} has no server entry`); const requestId = message.requestId; + signal?.throwIfAborted(); return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { + const cancel = () => { + clearTimeout(timeout); loaded.pending.delete(requestId); - if (message.type === "hook") { - void send(child, { type: "hook.cancel", requestId }).catch(() => {}); - } - reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); - }, REQUEST_TIMEOUT_MS); - loaded.pending.set(requestId, { resolve, reject, timeout }); + if (message.type === "tool.call") + void send(child, { type: "tool.cancel", requestId }).catch(() => {}); + signal?.removeEventListener("abort", cancel); + reject(signal?.reason ?? new Error("Tool call canceled")); + }; + const timeout = setTimeout( + () => { + loaded.pending.delete(requestId); + signal?.removeEventListener("abort", cancel); + if (message.type === "tool.call") + void send(child, { type: "tool.cancel", requestId }).catch(() => {}); + if (message.type === "hook") { + void send(child, { type: "hook.cancel", requestId }).catch(() => {}); + } + reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); + }, + message.type === "tool.call" ? TOOL_CALL_TIMEOUT_MS : REQUEST_TIMEOUT_MS, + ); + signal?.addEventListener("abort", cancel, { once: true }); + loaded.pending.set(requestId, { + resolve: (value) => { + signal?.removeEventListener("abort", cancel); + resolve(value); + }, + reject: (error) => { + signal?.removeEventListener("abort", cancel); + reject(error); + }, + timeout, + }); void send(child, message).catch((error) => { clearTimeout(timeout); + signal?.removeEventListener("abort", cancel); loaded.pending.delete(requestId); reject(error); }); @@ -623,6 +756,7 @@ export class PluginRuntime { const loaded = [...this.plugins.values()]; this.plugins.clear(); for (const plugin of loaded) { + this.notifySessionOpenChange(plugin); this.rejectPending(plugin, `Plugin stopped: ${plugin.id}`); } await Promise.all(loaded.map((plugin) => this.stopPlugin(plugin))); @@ -631,20 +765,23 @@ export class PluginRuntime { private async loadDirectoryPlugin( pluginId: string, configuredPath: string, + canPublish: () => boolean, ): Promise { const directory = path.resolve(configuredPath); - const manifest = await readPluginManifest(directory); - assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); - const entryPaths = await resolveEntryPaths(directory); - const bundles = await compilePlugin(entryPaths); + const bundles = await compileDirectoryPlugin(directory, this.daemonVersion); + const { manifest } = bundles; + if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`); const serverBundle = bundles.serverBundle; if (!serverBundle) { + const clientBundle = bundles.clientBundle ?? ""; return { id: pluginId, - clientBundle: bundles.clientBundle ?? "", + instanceId: randomUUID(), + clientBundle, + clientBundleHash: hashClientBundle(clientBundle), requirements: manifest.requirements, methods: new Set(), - hooks: { events: [], before: [] }, + hooks: [], providers: [], usageSources: [], child: null, @@ -769,10 +906,12 @@ export class PluginRuntime { } loaded = { id: pluginId, + instanceId: randomUUID(), clientBundle, + clientBundleHash: hashClientBundle(clientBundle), requirements, methods: new Set(ready.methods), - hooks: ready.hooks ?? { events: [], before: [] }, + hooks: ready.hooks ?? [], providers: ready.providers ?? [], usageSources: ready.usageSources ?? [], child, @@ -852,7 +991,14 @@ export class PluginRuntime { private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { if (message.type === "hooks.changed") { + const hadSessionOpen = loaded.hooks.includes("agent.open.before"); loaded.hooks = message.hooks; + if ( + this.plugins.get(loaded.id) === loaded && + hadSessionOpen !== loaded.hooks.includes("agent.open.before") + ) { + for (const listener of this.sessionOpenListeners) listener(); + } return; } if (message.type === "settings.changed") { @@ -1166,6 +1312,7 @@ export class PluginRuntime { const wasPublished = this.plugins.get(loaded.id) === loaded; if (wasPublished) { this.plugins.delete(loaded.id); + this.notifySessionOpenChange(loaded); } this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`); for (const state of loaded.providerConnections.values()) { diff --git a/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts b/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts new file mode 100644 index 0000000000..95aaec0d6a --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/compile-responsiveness.e2e.ts @@ -0,0 +1,69 @@ +import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js"; +import assert from "node:assert/strict"; +import { open as openFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import pino from "pino"; +import { DaemonConfigStore } from "../../daemon-config-store.js"; +import { PluginService } from "../index.js"; + +// A fresh process excludes the E2E harness's transform/bootstrap garbage from +// the compiler measurement. All work and dependencies below are real. +const directory = process.argv[2]; +assert.ok(directory, "Fixture directory is required"); +const results: { blockingMs: number }[] = []; +await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "responsive-build" }), +); +// Erased types stress TypeScript's AST walk without a multi-MiB runtime bundle. +// Payload transfer is a different cost from import validation. +const entryFile = await openFile(path.join(directory, "index.client.ts"), "w"); +try { + await entryFile.write("type FixtureValues = ["); + for (let offset = 0; offset < 350_000; offset += 10_000) { + await entryFile.write( + Array.from({ length: 10_000 }, (_, index) => offset + index).join(",") + ",", + ); + } + await entryFile.write( + ']; export default function contribute() { return () => "compiled fixture"; }', + ); +} finally { + await entryFile.close(); +} +const store = new DaemonConfigStore(directory, { + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + pluginsEnabled: true, + plugins: { + "responsive-build": { source: "directory", path: directory, enabled: false }, + }, +}); +const service = new PluginService(pino({ level: "silent" }), store, "0.4.0"); +await service.start(); +try { + for (const action of [ + () => service.enablePlugin("responsive-build"), + () => service.reloadPlugin("responsive-build"), + ]) { + const { blockingMs } = await measureMainThreadWork("plugin compile main-thread work", action); + assert.ok(service.catalog()[0]?.clientBundle.includes("compiled fixture")); + results.push({ blockingMs }); + } + // Evaluate after both probes so parsing the returned code is not charged to compilation. + const factory = globalThis.eval(service.catalog()[0]!.clientBundle) as ( + require: (specifier: string) => never, + ) => { default: () => () => string }; + const result = factory((specifier) => { + throw new Error(`Unexpected fixture dependency: ${specifier}`); + }).default()(); + assert.equal(result, "compiled fixture"); +} finally { + await service.stopAllPlugins(); +} +process.stdout.write(JSON.stringify(results)); diff --git a/packages/server/src/server/plugins/test-fixtures/usage-proxy-daemon.ts b/packages/server/src/server/plugins/test-fixtures/usage-proxy-daemon.ts new file mode 100644 index 0000000000..23364d3fda --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-proxy-daemon.ts @@ -0,0 +1,31 @@ +import path from "node:path"; +import { initializeEnvironmentProxy } from "../../network/index.js"; +import { DaemonClient } from "../../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "../builtin/index.js"; + +const mode = process.argv[2]; +if (mode !== "builtin" && mode !== "directory") throw new Error("Expected builtin or directory"); +const sources = ["claude-usage-source", "codex-usage-source"]; +// Built-ins run inside the daemon; directory plugins initialize their own subprocess network. +if (mode === "builtin") initializeEnvironmentProxy(); +const daemon = await createTestPaseoDaemon({ + pluginsEnabled: mode === "directory", + builtinPlugins: new BuiltinPluginLoader( + resolveBuiltinPluginsRoot(), + mode === "builtin" ? sources : [], + ), +}); +const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); +try { + await client.connect(); + if (mode === "directory") { + for (const source of sources) { + await client.installDirectoryPlugin(path.join(resolveBuiltinPluginsRoot(), source), source); + } + } + await client.listUsageReports(); +} finally { + await client.close(); + await daemon.close(); +} diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts index 037da5e0c6..46dc134ff6 100644 --- a/packages/server/src/server/plugins/usage-sources.e2e.test.ts +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -1,7 +1,11 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { createServer } from "node:http"; +import { once } from "node:events"; import { expect, test } from "vitest"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -335,3 +339,74 @@ export default function contribute(server) { await rm(root, { recursive: true, force: true }); } }, 60_000); + +test.each(["builtin", "directory"])( + "%s usage plugins reach the environment proxy", + async (mode) => { + const root = await mkdtemp(path.join(os.tmpdir(), "usage-proxy-")); + const tunnels: string[] = []; + const proxy = createServer(); + proxy.on("connect", (request, socket) => { + tunnels.push(request.url!); + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + proxy.listen(0, "127.0.0.1"); + await once(proxy, "listening"); + const address = proxy.address(); + if (!address || typeof address === "string") throw new Error("Missing proxy address"); + const guard = path.join(root, "network-guard.cjs"); + // Prevent vendor DNS/network access even when proxy support is broken. + await writeFile( + guard, + `const dns = require('node:dns'); +const lookup = dns.lookup; +dns.lookup = function(host, ...args) { + if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return lookup.call(this, host, ...args); + args.at(-1)(Object.assign(new Error('External DNS forbidden in proxy test'), { code: 'ENOTFOUND' })); +};`, + ); + await mkdir(path.join(root, ".claude")); + await mkdir(path.join(root, ".codex")); + await writeFile( + path.join(root, ".claude", ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "fixture-claude" } }), + ); + await writeFile( + path.join(root, ".codex", "auth.json"), + JSON.stringify({ tokens: { access_token: "fixture-codex", account_id: "fixture-account" } }), + ); + try { + await promisify(execFile)( + process.execPath, + [ + "--import", + "tsx", + fileURLToPath(new URL("./test-fixtures/usage-proxy-daemon.ts", import.meta.url)), + mode, + ], + { + timeout: 45_000, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + HOME: root, + USERPROFILE: root, + CODEX_HOME: path.join(root, ".codex"), + CLAUDE_CONFIG_DIR: path.join(root, ".claude"), + NODE_OPTIONS: `--require=${JSON.stringify(guard.replaceAll("\\", "/"))}`, + https_proxy: `http://127.0.0.1:${address.port}`, + no_proxy: "", + NO_PROXY: "", + }, + }, + ); + expect(tunnels).toContain("api.anthropic.com:443"); + expect(tunnels).toContain("chatgpt.com:443"); + } finally { + proxy.closeAllConnections(); + await new Promise((resolve) => proxy.close(() => resolve())); + await rm(root, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/packages/server/src/server/private-files.ts b/packages/server/src/server/private-files.ts index 0e12457be1..182101149e 100644 --- a/packages/server/src/server/private-files.ts +++ b/packages/server/src/server/private-files.ts @@ -1,3 +1,4 @@ +import { chmod, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises"; import { chmodSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import path from "node:path"; @@ -52,3 +53,30 @@ function resolveSymlinkedFile(filePath: string): string { throw error; } } + +export async function ensurePrivateFileAsync(filePath: string): Promise { + if (process.platform === "win32") return; + await chmod(filePath, PRIVATE_FILE_MODE).catch(() => {}); +} + +export async function writePrivateFileAtomic(filePath: string, data: string): Promise { + const directory = path.dirname(filePath); + await mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }); + if (process.platform !== "win32") await chmod(directory, PRIVATE_DIRECTORY_MODE).catch(() => {}); + const destination = await realpath(filePath).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return filePath; + throw error; + }); + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}`, + ); + try { + await writeFile(temporary, data, { mode: PRIVATE_FILE_MODE }); + await rename(temporary, destination); + await ensurePrivateFileAsync(destination); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 08fcd826d5..a0898df247 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -2,6 +2,7 @@ import { WebSocket } from "ws"; import type pino from "pino"; import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; +import { webSocketProxyAgent } from "./network/index.js"; import type { ExternalSocketMetadata } from "./websocket-server.js"; export interface RelayTransportOptions { @@ -56,7 +57,7 @@ const RELAY_WEBSOCKET_OPTIONS = { } as const; function createDefaultRelayWebSocket(url: string): RelayWebSocketLike { - return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS); + return new WebSocket(url, { ...RELAY_WEBSOCKET_OPTIONS, agent: webSocketProxyAgent(url) }); } function isRecord(value: unknown): value is Record { diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 3b462cc5b1..6121b7e6bd 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -226,6 +226,7 @@ type ScheduleAgentManager = Pick< AgentRunController, | "getAgent" | "reloadAgentSession" + | "reloadStalePluginSession" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index 96af0133ca..c5e4904a9d 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -68,13 +68,7 @@ function createStubTerminalManager( subscribe: () => () => {}, onExit: () => () => {}, onCommandFinished: () => () => {}, - getState: () => ({ - rows: 1, - cols: 1, - grid: [[{ char: "$" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }), + hasOutput: () => true, kill: () => {}, onTitleChange: () => () => {}, getSize: () => ({ rows: 1, cols: 1 }), diff --git a/packages/server/src/server/script-status-projection.test.ts b/packages/server/src/server/script-status-projection.test.ts index 3c963f907c..b6ef6e18f5 100644 --- a/packages/server/src/server/script-status-projection.test.ts +++ b/packages/server/src/server/script-status-projection.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -7,6 +7,7 @@ import { ScriptRouteStore } from "./script-proxy.js"; import { buildWorkspaceScriptPayloads, createScriptStatusEmitter, + readPaseoConfigForProjection, } from "./script-status-projection.js"; import { WorkspaceScriptPayloadSchema } from "@getpaseo/protocol/messages"; import type { ScriptHealthState } from "./script-health-monitor.js"; @@ -50,7 +51,7 @@ function createWorkspaceRepo(options?: { }; } -function buildPayloads(input: { +async function buildPayloads(input: { workspaceId: string; workspaceDirectory: string; paseoConfig?: PaseoConfig | null; @@ -63,7 +64,9 @@ function buildPayloads(input: { resolveHealth?: (hostname: string) => ScriptHealthState | null; }) { const paseoConfig = - input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory); + input.paseoConfig !== undefined + ? input.paseoConfig + : await loadConfig(input.workspaceDirectory); const { routeStore, serviceProxy, ...rest } = input; return buildWorkspaceScriptPayloads({ ...rest, @@ -72,8 +75,8 @@ function buildPayloads(input: { }); } -function loadConfig(repoRoot: string): PaseoConfig | null { - const result = readPaseoConfig(repoRoot); +async function loadConfig(repoRoot: string): Promise { + const result = await readPaseoConfig(repoRoot); return result.ok ? result.config : null; } @@ -93,7 +96,7 @@ describe("script-status-projection", () => { ).toBeNull(); }); - it("projects plain scripts and services differently", () => { + it("projects plain scripts and services differently", async () => { const workspaceId = "workspace-plain-and-service"; const workspace = createWorkspaceRepo({ paseoConfig: { @@ -116,7 +119,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -154,7 +157,7 @@ describe("script-status-projection", () => { } }); - it("builds service hostnames from service-provided git metadata", () => { + it("builds service hostnames from service-provided git metadata", async () => { const workspaceId = "workspace-service-metadata"; const workspace = createWorkspaceRepo({ branchName: "local-branch-that-should-not-be-read", @@ -168,7 +171,7 @@ describe("script-status-projection", () => { const runtimeStore = new WorkspaceScriptRuntimeStore(); try { - const payloads = buildPayloads({ + const payloads = await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, serviceProxy: routeStore, @@ -200,7 +203,7 @@ describe("script-status-projection", () => { } }); - it("projects local and public service URLs while keeping proxyUrl public-first", () => { + it("projects local and public service URLs while keeping proxyUrl public-first", async () => { const workspaceId = "workspace-public-service"; const workspace = createWorkspaceRepo({ paseoConfig: { @@ -214,7 +217,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -243,7 +246,7 @@ describe("script-status-projection", () => { } }); - it("overlays runtime, route, and health state for running services", () => { + it("overlays runtime, route, and health state for running services", async () => { const workspaceId = "workspace-running-service"; const workspace = createWorkspaceRepo({ branchName: "feature/card", @@ -273,7 +276,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -301,7 +304,7 @@ describe("script-status-projection", () => { } }); - it("maps internal pending health to null on the wire", () => { + it("maps internal pending health to null on the wire", async () => { const workspaceId = "workspace-pending-health"; const workspace = createWorkspaceRepo({ paseoConfig: { @@ -330,7 +333,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -358,7 +361,7 @@ describe("script-status-projection", () => { } }); - it("includes orphaned running runtime entries even after config removal", () => { + it("includes orphaned running runtime entries even after config removal", async () => { const workspaceId = "workspace-orphaned-service"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); @@ -381,7 +384,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -408,7 +411,7 @@ describe("script-status-projection", () => { } }); - it("projects orphaned plain scripts as scripts instead of services", () => { + it("projects orphaned plain scripts as scripts instead of services", async () => { const workspaceId = "workspace-orphaned-script"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); @@ -424,7 +427,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, routeStore, @@ -449,7 +452,7 @@ describe("script-status-projection", () => { } }); - it("readPaseoConfig fails with configPath and error when paseo.json is malformed", () => { + it("readPaseoConfig fails with configPath and error when paseo.json is malformed", async () => { const workspace = createWorkspaceRepo(); const configPath = path.join(workspace.repoDir, "paseo.json"); writeFileSync( @@ -458,7 +461,7 @@ describe("script-status-projection", () => { ); try { - const result = readPaseoConfig(workspace.repoDir); + const result = await readPaseoConfig(workspace.repoDir); expect(result.ok).toBe(false); if (result.ok) throw new Error("unreachable"); expect(result.configPath).toBe(configPath); @@ -468,7 +471,7 @@ describe("script-status-projection", () => { } }); - it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", () => { + it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", async () => { const workspaceId = "workspace-null-config"; const workspace = createWorkspaceRepo(); const routeStore = new ScriptRouteStore(); @@ -484,7 +487,7 @@ describe("script-status-projection", () => { try { expect( - buildPayloads({ + await buildPayloads({ workspaceId, workspaceDirectory: workspace.repoDir, paseoConfig: null, @@ -538,7 +541,12 @@ describe("script-status-projection", () => { exitCode: null, }); - const session = { emit: vi.fn() }; + const messages: unknown[] = []; + const session = { + emit: (message: unknown) => { + messages.push(message); + }, + }; const emitUpdate = createScriptStatusEmitter({ sessions: () => [session], serviceProxy: routeStore, @@ -550,7 +558,11 @@ describe("script-status-projection", () => { }); try { - emitUpdate(workspaceId, [ + const originalConfig = await readPaseoConfigForProjection( + workspace.repoDir, + createTestLogger(), + ); + await emitUpdate(workspaceId, [ { scriptName: "api", hostname: "api--repo.localhost", @@ -558,9 +570,25 @@ describe("script-status-projection", () => { health: "healthy", }, ]); - await Promise.resolve(); - expect(session.emit).toHaveBeenCalledWith({ + expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe( + originalConfig, + ); + await emitUpdate(workspaceId, [ + { scriptName: "api", hostname: "api--repo.localhost", port: 3001, health: "unhealthy" }, + ]); + expect(messages.at(-1)).toMatchObject({ + payload: { + scripts: [ + { scriptName: "api", health: "unhealthy" }, + { scriptName: "typecheck", health: null }, + ], + }, + }); + expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe( + originalConfig, + ); + expect(messages).toContainEqual({ type: "script_status_update", payload: { workspaceId, diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index 2d02751fc6..a66f5fb073 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -33,11 +33,11 @@ interface BuildWorkspaceScriptPayloadsOptions { resolveHealth?: (hostname: string) => ScriptHealthState | null; } -export function readPaseoConfigForProjection( +export async function readPaseoConfigForProjection( workspaceDirectory: string, logger: Logger, -): PaseoConfig | null { - const result = readPaseoConfig(workspaceDirectory); +): Promise { + const result = await readPaseoConfig(workspaceDirectory); if (result.ok) { return result.config; } @@ -288,9 +288,9 @@ export function createScriptStatusEmitter({ serviceProxyPublicBaseUrl?: string | null; resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise; logger: Logger; -}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void { - return (workspaceId, scripts) => { - void (async () => { +}): (workspaceId: string, scripts: ScriptHealthEntry[]) => Promise { + return async (workspaceId, scripts) => { + try { const workspaceDirectory = await resolveWorkspaceDirectory(workspaceId); if (!workspaceDirectory) { return; @@ -304,7 +304,7 @@ export function createScriptStatusEmitter({ const projected = buildWorkspaceScriptPayloads({ workspaceId, workspaceDirectory, - paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger), + paseoConfig: await readPaseoConfigForProjection(workspaceDirectory, logger), serviceProxy, runtimeStore, daemonPort: resolvedDaemonPort, @@ -320,6 +320,8 @@ export function createScriptStatusEmitter({ for (const session of sessions()) { session.emit(message); } - })(); + } catch (error) { + logger.warn({ err: error, workspaceId }, "Failed to project workspace script status"); + } }; } diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts index 5764b19539..eb679d5629 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/service-proxy.test.ts @@ -1,7 +1,9 @@ +import { type EventEmitter, once } from "node:events"; import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import http from "node:http"; import net from "node:net"; +import type { Duplex } from "node:stream"; import express from "express"; import { describe, expect, it } from "vitest"; import pino from "pino"; @@ -332,48 +334,39 @@ describe("service proxy subsystem shape", () => { }); }); -interface ForwardedFixture { +interface ProxiedServiceFixture { upstreamPort: number; daemonPort: number; + daemon: http.Server; hostname: string; close(): Promise; } /** - * Runs a real workspace service behind a real daemon listener. The upstream - * echoes the headers it received so tests can assert what actually crossed the - * proxy, not what a helper returned. + * Runs a real workspace service behind a real daemon listener that serves the + * service proxy for both HTTP and upgrades. */ -async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise { - const upstream = http.createServer((req, res) => { - let body = ""; - req.on("data", (chunk: Buffer) => { - body += chunk.toString(); +async function startProxiedService( + upstream: http.Server, + options: { upstreamHost?: string; logger?: pino.Logger } = {}, +): Promise { + const { upstreamHost = "127.0.0.1", logger: proxyLogger = logger } = options; + // Upgraded sockets keep the server alive; close() alone would hang. + const sockets = new Set(); + function trackConnections(server: http.Server) { + server.on("connection", (socket: net.Socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); }); - req.on("end", () => { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body })); - }); - }); - const upgradeSockets: net.Socket[] = []; - upstream.on("upgrade", (req, socket) => { - upgradeSockets.push(socket); - // The client resets this connection once it has the echo, so the reset - // reaches us as an 'error'. A raw upgrade socket has no default handler — - // without this the event goes unhandled and takes down the test process. - socket.on("error", () => socket.destroy()); - const payload = JSON.stringify(req.headers); - socket.write( - `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`, - ); - }); + } + trackConnections(upstream); await new Promise((resolve, reject) => { upstream.once("error", reject); upstream.listen(0, upstreamHost, resolve); }); const upstreamPort = (upstream.address() as net.AddressInfo).port; - const serviceProxy = createServiceProxySubsystem({ logger }); + const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger }); const route = serviceProxy.registerWorkspaceService({ workspaceId: "workspace-a", projectSlug: "repo", @@ -390,24 +383,51 @@ async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise res.status(404).send("404 Not Found"); }); const daemon = http.createServer(app); + trackConnections(daemon); daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false })); await new Promise((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve)); return { upstreamPort, daemonPort, + daemon, hostname: route.hostname, async close() { - // Upgraded sockets keep the server alive; close() alone would hang. - for (const socket of upgradeSockets) socket.destroy(); - daemon.closeAllConnections(); + for (const socket of sockets) socket.destroy(); await new Promise((resolve) => daemon.close(() => resolve())); - upstream.closeAllConnections(); await new Promise((resolve) => upstream.close(() => resolve())); }, }; } +/** + * The upstream echoes the headers it received so tests can assert what + * actually crossed the proxy, not what a helper returned. + */ +function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise { + const upstream = http.createServer((req, res) => { + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", () => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body })); + }); + }); + upstream.on("upgrade", (req, socket) => { + // The client resets this connection once it has the echo, so the reset + // reaches us as an 'error'. A raw upgrade socket has no default handler — + // without this the event goes unhandled and takes down the test process. + socket.on("error", () => socket.destroy()); + const payload = JSON.stringify(req.headers); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`, + ); + }); + return startProxiedService(upstream, { upstreamHost }); +} + function upgradeThroughProxy( port: number, host: string, @@ -651,3 +671,198 @@ describe("service proxy forwarded headers", () => { } }); }); + +function closed(emitter: EventEmitter): Promise { + return new Promise((resolve) => { + emitter.once("close", () => resolve()); + }); +} + +async function within(promise: Promise, ms: number, failure: string): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${failure} after ${ms}ms`)), ms); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer); + } +} + +function requestThroughProxy( + fixture: ProxiedServiceFixture, + requestPath: string, +): http.ClientRequest { + return http.get({ + hostname: "127.0.0.1", + port: fixture.daemonPort, + path: requestPath, + headers: { host: fixture.hostname }, + }); +} + +const STRAY_WARNING_GRACE_MS = 100; + +function createUpgradeUpstream(onUpgrade: (socket: Duplex) => void): http.Server { + const upstream = http.createServer(); + upstream.on("upgrade", (_req, socket) => { + socket.on("error", () => socket.destroy()); + socket.on("end", () => socket.end()); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n", + ); + onUpgrade(socket); + }); + return upstream; +} + +function openUpgradeThroughProxy(fixture: ProxiedServiceFixture): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect({ host: "127.0.0.1", port: fixture.daemonPort }, () => { + const lines = [ + "GET /ws HTTP/1.1", + `Host: ${fixture.hostname}`, + "Upgrade: websocket", + "Connection: Upgrade", + "", + "", + ]; + socket.write(lines.join("\r\n")); + }); + let raw = ""; + socket.on("data", (chunk: Buffer) => { + raw += chunk.toString(); + if (raw.includes("\r\n\r\n")) resolve(socket); + }); + socket.on("close", () => reject(new Error("upgrade closed before the 101 arrived"))); + socket.on("error", reject); + }); +} + +describe("service proxy connection teardown", () => { + it("closes the upstream connection when a client leaves an event stream", async () => { + const upstreamResponse = Promise.withResolvers(); + const upstream = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.write("data: open\n\n"); + upstreamResponse.resolve(res); + }); + const fixture = await startProxiedService(upstream); + try { + const request = requestThroughProxy(fixture, "/events"); + const [response] = (await once(request, "response")) as [http.IncomingMessage]; + await once(response, "data"); + const upstreamClosed = closed(await upstreamResponse.promise); + + request.destroy(); + + await within(upstreamClosed, 1000, "the service still holds the event stream"); + } finally { + await fixture.close(); + } + }, 5000); + + it("aborts the upstream request when a client leaves before the response starts", async () => { + const warnings: unknown[] = []; + const proxyLogger = pino( + { level: "warn" }, + { write: (line: string) => warnings.push(JSON.parse(line)) }, + ); + const upstreamResponse = Promise.withResolvers(); + const upstream = http.createServer((_req, res) => { + upstreamResponse.resolve(res); + }); + const fixture = await startProxiedService(upstream, { logger: proxyLogger }); + try { + const request = requestThroughProxy(fixture, "/slow"); + request.on("error", () => undefined); + const upstreamClosed = closed(await upstreamResponse.promise); + + request.destroy(); + + await within(upstreamClosed, 1000, "the service still holds the abandoned request"); + await new Promise((resolve) => setTimeout(resolve, STRAY_WARNING_GRACE_MS)); + expect(warnings).toEqual([]); + } finally { + await fixture.close(); + } + }, 5000); + + it("lets a complete response finish without resetting the service", async () => { + const upstreamResponse = Promise.withResolvers(); + const upstream = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.write("first;"); + upstreamResponse.resolve(res); + }); + const fixture = await startProxiedService(upstream); + try { + const response = httpGet(fixture.daemonPort, fixture.hostname, { path: "/" }); + const res = await upstreamResponse.promise; + const upstreamClosed = closed(res); + + res.end("second"); + + await expect(response).resolves.toEqual({ status: 200, body: "first;second" }); + await within(upstreamClosed, 1000, "the service response never closed"); + expect(res.writableFinished).toBe(true); + } finally { + await fixture.close(); + } + }, 5000); + + it("closes the upstream WebSocket when the daemon drops the client socket", async () => { + const upstreamSocket = Promise.withResolvers(); + const fixture = await startProxiedService( + createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)), + ); + const daemonSide = Promise.withResolvers(); + fixture.daemon.on("upgrade", (_req, socket) => daemonSide.resolve(socket)); + try { + const client = await openUpgradeThroughProxy(fixture); + const upstreamClosed = closed(await upstreamSocket.promise); + + (await daemonSide.promise).destroy(); + + await within(upstreamClosed, 1000, "the service still holds the WebSocket"); + client.destroy(); + } finally { + await fixture.close(); + } + }, 5000); + + it("closes the upstream WebSocket when the client disconnects", async () => { + const upstreamSocket = Promise.withResolvers(); + const fixture = await startProxiedService( + createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)), + ); + try { + const client = await openUpgradeThroughProxy(fixture); + const upstreamClosed = closed(await upstreamSocket.promise); + + client.destroy(); + + await within(upstreamClosed, 1000, "the service still holds the WebSocket"); + } finally { + await fixture.close(); + } + }, 5000); + + it("closes the client WebSocket when the service closes it", async () => { + const upstreamSocket = Promise.withResolvers(); + const fixture = await startProxiedService( + createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)), + ); + try { + const client = await openUpgradeThroughProxy(fixture); + const clientClosed = closed(client); + + (await upstreamSocket.promise).destroy(); + + await within(clientClosed, 1000, "the client WebSocket is still open"); + } finally { + await fixture.close(); + } + }, 5000); +}); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index ef93a27f9a..24bfd80c93 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -342,7 +342,14 @@ function proxyHttpRequest({ proxyRes.pipe(res, { end: true }); }, ); + let clientClosed = false; + res.on("close", () => { + if (res.writableFinished) return; + clientClosed = true; + proxyReq.destroy(); + }); proxyReq.on("error", (err) => { + if (clientClosed) return; logger.warn( { err, hostname: route.hostname, port: route.port }, "Service proxy: upstream unreachable", @@ -399,6 +406,9 @@ function proxyUpgradeRequest({ socket.on("error", () => { targetSocket.destroy(); }); + socket.on("close", () => { + targetSocket.destroy(); + }); } function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 2fc05d0131..d1408f9880 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -8,7 +8,15 @@ import { createTestCreationService, } from "./test-utils/session-stubs.js"; import { execSync } from "child_process"; -import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; import { tmpdir } from "os"; import { join, resolve as resolvePath } from "path"; import pino from "pino"; @@ -28,6 +36,11 @@ import { type FileTransferFrame, } from "@getpaseo/protocol/binary-frames/index"; import { Session } from "./session.js"; +import { + terminalCleanupRecordPath, + terminalWorkerDirectory, + writeTerminalCleanupRecord, +} from "../terminal/terminal-worker-record.js"; import { OWNER_PERMISSIONS, type DaemonPermission } from "./authorization/index.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; @@ -425,6 +438,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { github: asGitHubService(github), workspaceGitService: asWorkspaceGitService(workspaceGitService), daemonConfigStore: asDaemonConfigStore({ + readServicePorts: async () => undefined, get: vi.fn(() => ({ mcp: { injectIntoAgents: false }, providers: {}, @@ -587,6 +601,78 @@ test("routes plugin requests and releases its owned catalog subscription on clea expect(releasePluginSubscription).toHaveBeenCalledOnce(); }); +test("leaves out the client bundles a catalog request already holds", async () => { + const messages: SessionOutboundMessage[] = []; + const pluginRuntime: NonNullable = { + before: async (_name, request) => request, + emit: () => {}, + listPlugins: async () => [], + getLogs: () => [], + installDirectory: async () => { + throw new Error("Unexpected install"); + }, + inspectDirectory: async () => ({ id: "unused" }), + reloadPlugin: async () => { + throw new Error("Unexpected reload"); + }, + enablePlugin: async () => { + throw new Error("Unexpected enable"); + }, + disablePlugin: async () => { + throw new Error("Unexpected disable"); + }, + removePlugin: async () => undefined, + subscribe: () => () => {}, + catalog: () => [ + { id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" }, + { id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" }, + { id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" }, + ], + invokePluginRpc: async () => null, + listUsageReports: async () => [], + listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }), + }; + const session = createSessionForTest({ messages, pluginRuntime }); + + await session.handleMessage({ + type: "plugin.catalog.get.request", + requestId: "reconnect", + cachedClientBundles: [ + { id: "cached", clientBundleHash: "hash-cached" }, + { id: "changed", clientBundleHash: "hash-old" }, + { id: "removed", clientBundleHash: "hash-removed" }, + ], + }); + // Clients from before cachedClientBundles always receive every bundle. + await session.handleMessage({ type: "plugin.catalog.get.request", requestId: "legacy" }); + + expect(messages).toEqual([ + { + type: "plugin.catalog.get.response", + payload: { + requestId: "reconnect", + plugins: [ + { id: "cached", clientBundleHash: "hash-cached" }, + { id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" }, + { id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" }, + ], + }, + }, + { + type: "plugin.catalog.get.response", + payload: { + requestId: "legacy", + plugins: [ + { id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" }, + { id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" }, + { id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" }, + ], + }, + }, + ]); + await session.cleanup(); +}); + describe("workspace label subscriptions", () => { type LabelSubscription = Awaited>; type LabelChange = Parameters[0]["onChange"]>[0]; @@ -1998,11 +2084,84 @@ describe("daemon status + pairing RPC", () => { { provider: "claude", available: true, error: null }, { provider: "codex", available: false, error: "boom" }, ], + terminalCleanupUnconfirmed: [], }, }, ]); }); + test("daemon.get_status.request reports what a terminal cleanup could not confirm gone", async () => { + const messages: unknown[] = []; + const paseoHome = makeHome(); + const unresolved = { + pid: 4242, + role: "terminal program" as const, + reason: "still alive" as const, + detail: "survived SIGKILL", + }; + await writeTerminalCleanupRecord(paseoHome, { + recordId: "closing-record", + pid: 4241, + bootId: null, + createdAt: new Date().toISOString(), + identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "worker" }, + protocolVersion: 1, + paseoVersion: "0.0.0-test", + socketPath: join(paseoHome, "closing.sock"), + secret: "closing-secret", + terminals: [{ id: "t1", pid: 4242, startedAt: "Thu Jan 1 00:00:00 1970" }], + closing: { unresolved: [unresolved] }, + }); + const session = createSessionForTest({ + messages, + paseoHome, + serverId: "srv-test", + daemonVersion: "9.9.9", + daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null }, + agentManager: { listProviderAvailability: vi.fn().mockResolvedValue([]) }, + }); + + await session.handleMessage({ type: "daemon.get_status.request", requestId: "status-2" }); + + expect(messages).toEqual([ + expect.objectContaining({ + type: "daemon.get_status.response", + payload: expect.objectContaining({ terminalCleanupUnconfirmed: [unresolved] }), + }), + ]); + }); + + test("daemon.get_status.request fails when a terminal cleanup record cannot be read", async () => { + const messages: unknown[] = []; + const paseoHome = makeHome(); + mkdirSync(terminalWorkerDirectory(paseoHome), { recursive: true }); + const cleanupPath = terminalCleanupRecordPath(paseoHome, "closing-record"); + writeFileSync(cleanupPath, "{ not json"); + const session = createSessionForTest({ + messages, + paseoHome, + serverId: "srv-test", + daemonVersion: "9.9.9", + daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null }, + agentManager: { listProviderAvailability: vi.fn().mockResolvedValue([]) }, + }); + + await session.handleMessage({ type: "daemon.get_status.request", requestId: "status-3" }); + + expect(messages).toContainEqual( + expect.objectContaining({ + type: "rpc_error", + payload: expect.objectContaining({ + requestId: "status-3", + error: expect.stringContaining(cleanupPath), + }), + }), + ); + expect(messages).not.toContainEqual( + expect.objectContaining({ type: "daemon.get_status.response" }), + ); + }); + test("daemon.get_status.request falls back to a null/empty status when provider listing fails", async () => { const messages: unknown[] = []; const session = createSessionForTest({ @@ -2034,6 +2193,7 @@ describe("daemon status + pairing RPC", () => { listen: null, relay: null, providers: [], + terminalCleanupUnconfirmed: [], }, }, ]); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9a2b2c0820..00c8529482 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -81,7 +81,6 @@ import { type WorkspaceScriptsService, } from "./session/workspace-scripts/workspace-scripts-service.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; -import { loadPersistedConfig } from "./persisted-config.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; @@ -182,6 +181,10 @@ import { import { ScheduleSession } from "./session/schedule/schedule-session.js"; import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js"; import { UsageSession } from "./session/usage/usage-session.js"; +import { + SpeechModelsSession, + type SpeechModelsSessionOptions, +} from "./session/speech/speech-models-session.js"; import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js"; import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; @@ -482,6 +485,7 @@ export interface SessionOptions { workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; pluginRuntime?: { + wait: import("./plugins/lifecycle/index.js").PluginLifecycle["wait"]; before: import("./plugins/lifecycle/index.js").PluginLifecycle["before"]; emit: import("./plugins/lifecycle/index.js").PluginLifecycle["emit"]; listPlugins(): Promise; @@ -515,7 +519,7 @@ export interface SessionOptions { removePlugin(pluginId: string): Promise; subscribe(listener: (pluginId: string) => void): () => void; subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void; - catalog(): Array<{ id: string; clientBundle: string }>; + catalog(): Array<{ id: string; clientBundle: string; clientBundleHash?: string }>; invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; listUsageReports(options?: { forceRefresh?: boolean; @@ -528,6 +532,7 @@ export interface SessionOptions { stt: Resolvable; sttLanguage?: string; tts: Resolvable; + speechModels?: SpeechModelsSessionOptions["runtime"]; terminalManager: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; hubExecutionAgents?: HubExecutionAgents; @@ -572,12 +577,14 @@ export type SessionLifecycleIntent = clientId: string; requestId: string; reason: string; + keepTerminals: boolean; } | { type: "restart"; clientId: string; requestId: string; reason: string; + keepTerminals: boolean; }; function parseClientCapabilities( @@ -811,6 +818,7 @@ export class Session { private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; private readonly usageSession: UsageSession; + private readonly speechModelsSession: SpeechModelsSession; private readonly workspaceFilesSession: WorkspaceFilesSession; private readonly agentConfigSession: AgentConfigSession; private readonly projectConfigSession: ProjectConfigSession; @@ -861,6 +869,7 @@ export class Session { stt, sttLanguage, tts, + speechModels, terminalManager, providerSnapshotManager, serviceProxy, @@ -1033,10 +1042,15 @@ export class Session { logger: this.sessionLogger, }); this.usageSession = new UsageSession({ + isEnabled: () => daemonConfigStore.get().usage?.enabled !== false, emit: (msg) => this.emit(msg), runtime: pluginRuntime, logger: this.sessionLogger, }); + this.speechModelsSession = new SpeechModelsSession({ + emit: (msg) => this.emit(msg), + runtime: speechModels, + }); this.agentConfigSession = new AgentConfigSession({ host: { emit: (msg) => this.emit(msg), @@ -1094,6 +1108,7 @@ export class Session { "Terminal output": this.terminalController.getMetrics().streamSubscriptionCount, }), listProviderAvailability: () => this.agentManager.listProviderAvailability(), + getTerminalsUnavailable: () => terminalManager?.getUnavailableError?.()?.message ?? null, listAgents: () => this.agentManager.listAgents(), listProjects: () => this.projectRegistry.list(), listWorkspaces: () => this.workspaceRegistry.list(), @@ -1115,11 +1130,11 @@ export class Session { terminalManager, emit: (msg) => this.emit(msg), hasBinaryChannel: () => this.onBinaryMessage !== null, + supportsChunkedReplay: (source) => + this.supportsForSource(CLIENT_CAPS.terminalChunkedReplay, source), isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(), - clientSupportsWrapReflow: (source) => - this.supportsForSource(CLIENT_CAPS.terminalReflowableSnapshot, source), getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source), }); this.agentDirectory = new AgentDirectory({ @@ -1221,7 +1236,7 @@ export class Session { spawnWorkspaceScript, assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), - globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts, + getGlobalServicePorts: () => this.daemonConfigStore.readServicePorts(), }); this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, @@ -1978,6 +1993,24 @@ export class Session { return; } + if (event.type === "agent_removed") { + if (event.internal) return; + void this.agentUpdates + .removeAgent(event.agentId) + .then(() => { + if (event.workspaceId) + return this.emitWorkspaceUpdateForWorkspaceId(event.workspaceId); + return undefined; + }) + .catch((error) => { + this.sessionLogger.error( + { err: error, agentId: event.agentId }, + "Failed to emit agent removal", + ); + }); + return; + } + if (event.type === "agent_state") { this.sessionLogger.trace( { @@ -2421,8 +2454,7 @@ export class Session { this.dispatchOrchestrationSkillsMessage(msg) ?? this.dispatchPluginDirectoryMessage(msg) ?? this.dispatchPluginMessage(msg) ?? - this.dispatchTerminalMessage(msg) ?? - this.dispatchScheduleMessage(msg) ?? + this.dispatchHostServiceMessage(msg) ?? this.dispatchMiscMessage(msg); if (promise) await promise; } @@ -2438,6 +2470,14 @@ export class Session { ); } + private dispatchHostServiceMessage(msg: SessionInboundMessage): Promise | undefined { + return ( + this.dispatchTerminalMessage(msg) ?? + this.dispatchScheduleMessage(msg) ?? + this.speechModelsSession.dispatch(msg) + ); + } + private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { return ( this.dispatchWorkspaceStateMessage(msg) ?? @@ -2567,12 +2607,17 @@ export class Session { }); } if (msg.type === "plugin.catalog.get.request") { + const cached = new Map( + msg.cachedClientBundles?.map((entry) => [entry.id, entry.clientBundleHash]), + ); + const plugins = (this.pluginRuntime?.catalog() ?? []).map(({ clientBundle, ...plugin }) => + plugin.clientBundleHash !== undefined && cached.get(plugin.id) === plugin.clientBundleHash + ? plugin + : Object.assign(plugin, { clientBundle }), + ); this.emit({ type: "plugin.catalog.get.response", - payload: { - requestId: msg.requestId, - plugins: this.pluginRuntime?.catalog() ?? [], - }, + payload: { requestId: msg.requestId, plugins }, }); return undefined; } @@ -2705,9 +2750,13 @@ export class Session { case "dictation_stream_cancel": return this.voiceSessions.handleMessage(msg); case "restart_server_request": - return this.handleRestartServerRequest(msg.requestId, msg.reason); + return this.handleRestartServerRequest(msg.requestId, msg.reason, { + keepTerminals: msg.killTerminals !== true, + }); case "shutdown_server_request": - return this.handleShutdownServerRequest(msg.requestId); + return this.handleShutdownServerRequest(msg.requestId, { + keepTerminals: msg.keepTerminals === true, + }); case "client_heartbeat": this.handleClientHeartbeat(msg); return undefined; @@ -2909,8 +2958,7 @@ export class Session { case "daemon.get_pairing_offer.request": return this.daemonSession.handleGetPairingOfferRequest(msg); case "daemon.config.reload.request": - this.daemonSession.handleConfigReloadRequest(msg); - return undefined; + return this.daemonSession.handleConfigReloadRequest(msg); case "hub.management.daemon.connect.request": case "hub.management.daemon.get_status.request": case "hub.management.daemon.disconnect.request": @@ -2921,14 +2969,15 @@ export class Session { case "daemon.update.request": return this.daemonSession.handleUpdateRequest(msg); case "set_daemon_config_request": - this.emit({ - type: "set_daemon_config_response", - payload: { - requestId: msg.requestId, - config: this.daemonConfigStore.patch(msg.config), - }, + return this.daemonConfigStore.patch(msg.config).then((config) => { + return this.emit({ + type: "set_daemon_config_response", + payload: { + requestId: msg.requestId, + config, + }, + }); }); - return undefined; case "read_project_config_request": return this.projectConfigSession.handleReadProjectConfigRequest(msg); case "write_project_config_request": @@ -2946,7 +2995,7 @@ export class Session { case "checkout.commits.list.request": return this.checkoutSession.handleCommitsListRequest(msg); case "checkout.commits.file_diff.request": - return this.checkoutSession.handleCommitFileDiffRequest(msg); + return this.checkoutSession.handleCommitFileDiffRequest(msg, this.delivery.requestSignal); case "validate_branch_request": return this.checkoutSession.handleValidateBranchRequest(msg); case "branch_suggestions_request": @@ -3233,7 +3282,11 @@ export class Session { this.terminalController.handleBinaryFrame(binaryFrame.frame, source); } - private async handleRestartServerRequest(requestId: string, reason?: string): Promise { + private async handleRestartServerRequest( + requestId: string, + reason: string | undefined, + options: { keepTerminals: boolean }, + ): Promise { const lifecycleReason = normalizeClientRestartRpcReason(reason); const payload: { status: string } & Record = { status: "restart_requested", @@ -3244,7 +3297,10 @@ export class Session { } payload.requestId = requestId; - this.sessionLogger.warn({ reason: lifecycleReason }, "Restart requested via websocket"); + this.sessionLogger.warn( + { reason: lifecycleReason, keepTerminals: options.keepTerminals }, + "Restart requested via websocket", + ); this.emit({ type: "status", payload, @@ -3255,12 +3311,19 @@ export class Session { clientId: this.clientId, requestId, reason: lifecycleReason, + keepTerminals: options.keepTerminals, }); } - private async handleShutdownServerRequest(requestId: string): Promise { + private async handleShutdownServerRequest( + requestId: string, + options: { keepTerminals: boolean }, + ): Promise { const reason = CLIENT_SHUTDOWN_RPC_REASON; - this.sessionLogger.warn({ reason }, "Shutdown requested via websocket"); + this.sessionLogger.warn( + { reason, keepTerminals: options.keepTerminals }, + "Shutdown requested via websocket", + ); this.emit({ type: "status", payload: { @@ -3275,6 +3338,7 @@ export class Session { clientId: this.clientId, requestId, reason, + keepTerminals: options.keepTerminals, }); } @@ -3292,10 +3356,8 @@ export class Session { private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise { this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`); - const knownWorkspaceId = - this.agentManager.getAgent(agentId)?.workspaceId ?? - (await this.agentStorage.get(agentId))?.workspaceId ?? - null; + const knownAgent = + this.agentManager.getAgent(agentId) ?? (await this.agentStorage.get(agentId)); // File-backed storage still needs an early delete fence before closeAgent(). beginAgentDeleteIfSupported(this.agentStorage, agentId); @@ -3316,6 +3378,8 @@ export class Session { try { await this.agentStorage.remove(agentId); await this.agentManager.deleteAgentState(agentId); + // Directory removal is daemon-wide; agent_deleted below stays a source-scoped RPC reply. + if (knownAgent) this.agentManager.notifyAgentRemoved(knownAgent); } catch (error) { this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`); } @@ -3327,12 +3391,6 @@ export class Session { requestId, }, }); - - await this.agentUpdates.removeAgent(agentId); - - if (knownWorkspaceId) { - await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId); - } } private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise { @@ -4454,7 +4512,10 @@ export class Session { }, ); createdAgentId = snapshot.id; - await this.agentUpdates.forwardLiveAgent(snapshot); + // `snapshot` predates the initial prompt; subscribers keep the last state they receive. + await this.agentUpdates.forwardLiveAgent( + this.agentManager.getAgent(snapshot.id) ?? liveSnapshot, + ); if (resolvedIntent.createdDirectoryWorkspace && trimmedPrompt) { this.workspaceAutoName.scheduleForDirectory( { @@ -5652,7 +5713,7 @@ export class Session { activityAt: null, createdAt: workspace.createdAt, diffStat, - scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), + scripts: await this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord), ...(resolvedProjectRecord ? { project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord), @@ -5965,6 +6026,7 @@ export class Session { }, ): Promise { const result = await createPaseoWorktree(input, { + lifecycle: this.pluginRuntime, github: this.github, ...(options?.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } @@ -6773,7 +6835,7 @@ export class Session { // 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); + const transformed = await this.pluginRuntime.before("workspace.create.before", input); creationRequest = { ...transformed, type, requestId }; } return creationRequest.source.kind === "directory" @@ -7245,7 +7307,7 @@ export class Session { private buildWorkspaceScriptPayloadSnapshot( workspace: PersistedWorkspaceRecord, project: PersistedProjectRecord | null, - ): WorkspaceDescriptorPayload["scripts"] { + ): Promise { return this.workspaceScripts.buildSnapshot(workspace, project); } @@ -7769,8 +7831,6 @@ export class Session { ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); const projection = "projected" as const; - const requestedLimit = msg.limit; - const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200); const cursor: AgentTimelineCursor | undefined = msg.cursor ? { epoch: msg.cursor.epoch, @@ -7789,7 +7849,8 @@ export class Session { const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, { direction, cursor, - limit: pageLimit, + limit: msg.limit, + wholeTurns: msg.wholeTurns, }); const selectedTimeline = { timeline: fetchedControlTimeline, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e4a0ca7668..343c0a8843 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1393,6 +1393,136 @@ test("create_agent_request does not title an existing workspace from the agent p } }); +test("create_agent_request does not publish the pre-prompt snapshot after the prompt starts", async () => { + vi.useFakeTimers(); + const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-live-state-")); + const logger = createTestLogger(); + const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); + // The test session's turn starts and then reports nothing, like a provider + // that stays quiet until its turn ends. + const agentManager = new AgentManager({ + clients: { codex: new CreateAgentTestClient() }, + registry: agentStorage, + logger: asSessionLogger(logger), + idFactory: () => "00000000-0000-4000-8000-000000000553", + }); + try { + const cwd = path.join(workdir, "repo"); + mkdirSync(cwd, { recursive: true }); + + const projectRegistry = new FileBackedProjectRegistry( + path.join(workdir, "projects.json"), + asSessionLogger(logger), + ); + const workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(workdir, "workspaces.json"), + asSessionLogger(logger), + ); + + await projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: "proj-live-state", + rootPath: cwd, + kind: "git", + displayName: "repo", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "ws-live-state", + projectId: "proj-live-state", + cwd, + kind: "local_checkout", + displayName: "repo", + title: null, + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + + const agentUpdates: Extract["payload"][] = []; + const session = asTestSession( + new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), + messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), + creationService: createTestCreationService(), + clientId: "test-client", + permissions: OWNER_PERMISSIONS, + appVersion: null, + onMessage: (message) => { + if (message.type === "agent_update") agentUpdates.push(message.payload); + }, + logger: asSessionLogger(logger), + downloadTokenStore: asDownloadTokenStore(), + pushNotifications: asPushNotifications(), + paseoHome: path.join(workdir, "paseo-home"), + agentManager, + agentStorage, + projectRegistry, + workspaceRegistry, + scheduleService: asScheduleService(), + checkoutDiffManager: asCheckoutDiffManager({ + subscribe: async () => ({ + initial: { cwd, files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, + invalidateForge: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + }), + workspaceGitService: createNoopWorkspaceGitService(), + daemonConfigStore: asDaemonConfigStore({ + get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), + onChange: () => () => {}, + }), + mcpBaseUrl: null, + stt: null, + tts: null, + generateWorkspaceName: async () => null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + terminalManager: null, + }), + ); + await activateAgentUpdatesSubscription(session, "sub-live-state"); + + await session.handleMessage({ + type: "create_agent_request", + requestId: "req-create-live-state", + workspaceId: "ws-live-state", + config: { provider: "codex", cwd }, + initialPrompt: "Print the report", + attachments: [], + }); + await vi.runAllTimersAsync(); + + const [createdAgent] = agentManager.listAgents(); + expect(createdAgent?.lifecycle).toBe("running"); + const upserts = agentUpdates.flatMap((update) => + update.kind === "upsert" && update.agent.id === createdAgent?.id ? [update.agent] : [], + ); + expect(upserts.length).toBeGreaterThan(0); + // A client keeps the last update it hears, so the latest one must be the running agent. + expect(upserts.at(-1)).toMatchObject({ status: "running" }); + const updatedAt = upserts.map((agent) => Date.parse(agent.updatedAt)); + expect(updatedAt).toEqual([...updatedAt].sort((left, right) => left - right)); + } finally { + // Drain snapshot writes before removing their storage directory. + await agentManager.flush(); + vi.useRealTimers(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => { const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" }); const storedRecord = { @@ -5991,6 +6121,9 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t projectId, cwd: worktree.worktreePath, kind: "worktree", + worktreeRoot: worktree.worktreePath, + mainRepoRoot: repoDir, + isPaseoOwnedWorktree: true, displayName: "worktree-kind-archive", createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", @@ -9228,8 +9361,8 @@ test("workspace auto-name uses the backing root for a nested worktree", async () execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", "-M", "placeholder-branch"], { cwd: repoDir, stdio: "pipe" }); - writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" }); - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, { + await writePaseoWorktreeMetadata(repoDir, { baseRefName: "main" }); + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, { placeholderBranchName: "placeholder-branch", }); const workspaceCwd = path.join(repoDir, "packages", "app"); @@ -9288,6 +9421,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async () firstAgentContext: { prompt: "Fix checkout title" }, }); await vi.runAllTimersAsync(); + await vi.waitFor(() => expect(emittedCwds).toEqual([workspaceCwd])); expect(generateCalls).toBe(1); expect(stored.get(workspace.workspaceId)).toMatchObject({ @@ -9299,7 +9433,7 @@ test("workspace auto-name uses the backing root for a nested worktree", async () .toString() .trim(), ).toBe("placeholder-branch"); - expect(readPaseoWorktreeMetadata(repoDir)).toMatchObject({ + expect(await readPaseoWorktreeMetadata(repoDir)).toMatchObject({ version: 2, firstAgentBranchAutoName: { status: "attempted", diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 419d418050..ceb69e9623 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -290,7 +290,10 @@ export class CheckoutSession { } } - async handleCommitFileDiffRequest(msg: CheckoutCommitFileDiffRequest): Promise { + async handleCommitFileDiffRequest( + msg: CheckoutCommitFileDiffRequest, + signal: AbortSignal, + ): Promise { const { cwd, sha, path, requestId } = msg; try { @@ -298,7 +301,7 @@ export class CheckoutSession { if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) { throw new Error(`Invalid path: ${path}`); } - const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path }); + const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path, signal }); this.host.emit({ type: "checkout.commits.file_diff.response", payload: { cwd, sha, path, file, error: null, requestId }, 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 81c41f3421..e9bcf94b65 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 @@ -118,6 +118,7 @@ describe("DaemonSelfUpdateSessionController", () => { clientId: "client-1", requestId: "update-1", reason: "daemon_update", + keepTerminals: true, }, ]); }); diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts index 461a97277b..bb75f008bd 100644 --- a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.ts @@ -19,6 +19,7 @@ interface DaemonSelfUpdateRestartIntent { clientId: string; requestId: string; reason: string; + keepTerminals: boolean; } export interface DaemonSelfUpdateSessionControllerOptions { @@ -88,6 +89,8 @@ export class DaemonSelfUpdateSessionController { clientId: this.clientId, requestId: msg.requestId, reason: "daemon_update", + // A self-update restart keeps terminals: the worker outlives the daemon. + keepTerminals: true, }); } catch (error) { if (error instanceof DaemonSelfUpdateInProgressError) { diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts index cac6a91ea6..cb6da8777b 100644 --- a/packages/server/src/server/session/daemon/daemon-session.test.ts +++ b/packages/server/src/server/session/daemon/daemon-session.test.ts @@ -43,7 +43,7 @@ function makeSubsystem(overrides: { listProviderAvailability?: () => Promise; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; hubRelationships?: HubRelationshipManagement; - reloadConfig?: () => DaemonConfigReloadResult; + reloadConfig?: () => Promise; }) { const emitted: SessionOutboundMessage[] = []; const restartIntents: Parameters[0][] = []; @@ -67,7 +67,7 @@ function makeSubsystem(overrides: { hubRelationships: overrides.hubRelationships, reloadConfig: overrides.reloadConfig ?? - (() => ({ + (async () => ({ appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: [], @@ -78,16 +78,16 @@ function makeSubsystem(overrides: { } describe("DaemonSession", () => { - test("config reload returns the daemon-owned classification", () => { + test("config reload returns the daemon-owned classification", async () => { const { subsystem, emitted } = makeSubsystem({ - reloadConfig: () => ({ + reloadConfig: async () => ({ appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: ["app.baseUrl"], }), }); - subsystem.handleConfigReloadRequest({ + await subsystem.handleConfigReloadRequest({ type: "daemon.config.reload.request", requestId: "reload-1", }); @@ -105,14 +105,14 @@ describe("DaemonSession", () => { ]); }); - test("config reload failures return a correlated RPC error", () => { + test("config reload failures return a correlated RPC error", async () => { const { subsystem, emitted } = makeSubsystem({ - reloadConfig: () => { + reloadConfig: async () => { throw new Error("Invalid config"); }, }); - subsystem.handleConfigReloadRequest({ + await subsystem.handleConfigReloadRequest({ type: "daemon.config.reload.request", requestId: "reload-2", }); @@ -212,6 +212,7 @@ describe("DaemonSession", () => { { provider: "claude", available: true, error: null }, { provider: "codex", available: false, error: "boom" }, ], + terminalCleanupUnconfirmed: [], }, }, ]); @@ -242,6 +243,7 @@ describe("DaemonSession", () => { listen: null, relay: null, providers: [], + terminalCleanupUnconfirmed: [], }, }, ]); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index fbe1a06415..585fa45573 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -2,6 +2,7 @@ import type pino from "pino"; import type { ProviderAvailability } from "../../agent/agent-manager.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import { getPidLockInfo } from "../../pid-lock.js"; +import { readUnconfirmedTerminalCleanup } from "../../../terminal/terminal-worker-reaper.js"; import { generateLocalPairingOffer } from "../../pairing-offer.js"; import { collectDaemonDiagnostics, @@ -35,6 +36,7 @@ export interface DaemonSessionHost { clientId: string; requestId: string; reason: string; + keepTerminals: boolean; }): void; } @@ -49,11 +51,13 @@ export interface DaemonSessionOptions { listProjects: () => Promise; listWorkspaces: () => Promise; listProviderAvailability: () => Promise; + /** Why this host's terminals cannot be served (held by a newer Paseo's worker), if so. */ + getTerminalsUnavailable?: () => string | null; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; getObservationMetrics?: () => Record; logger: pino.Logger; hubRelationships?: HubRelationshipManagement; - reloadConfig: () => DaemonConfigReloadResult; + reloadConfig: () => Promise; } /** @@ -74,12 +78,13 @@ export class DaemonSession { private readonly listProjects: () => Promise; private readonly listWorkspaces: () => Promise; private readonly listProviderAvailability: () => Promise; + private readonly getTerminalsUnavailable: () => string | null; private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; private readonly getObservationMetrics: DaemonSessionOptions["getObservationMetrics"]; private readonly logger: pino.Logger; private readonly selfUpdate: DaemonSelfUpdateSessionController; private readonly hubRelationships: HubRelationshipManagement | null; - private readonly reloadConfig: () => DaemonConfigReloadResult; + private readonly reloadConfig: () => Promise; constructor(options: DaemonSessionOptions) { this.host = options.host; @@ -92,6 +97,7 @@ export class DaemonSession { this.listProjects = options.listProjects; this.listWorkspaces = options.listWorkspaces; this.listProviderAvailability = options.listProviderAvailability; + this.getTerminalsUnavailable = options.getTerminalsUnavailable ?? (() => null); this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null); this.getObservationMetrics = options.getObservationMetrics; this.logger = options.logger; @@ -173,6 +179,12 @@ export class DaemonSession { async handleGetStatusRequest( msg: Extract, ): Promise { + // Read outside the degraded-status fallback below: a status that cannot say whether + // terminal cleanup finished must fail the request, not report that nothing is left. + const terminalCleanupUnconfirmed = (await readUnconfirmedTerminalCleanup(this.paseoHome)).map( + ({ pid, role, reason, detail }) => ({ pid, role, reason, detail }), + ); + const terminalsUnavailable = this.getTerminalsUnavailable(); try { const pidInfo = await getPidLockInfo(this.paseoHome); const providers = (await this.listProviderAvailability()).map((p) => ({ @@ -192,6 +204,8 @@ export class DaemonSession { listen: this.daemonRuntimeConfig?.listen ?? null, relay: this.daemonRuntimeConfig?.getRelayConfig() ?? null, providers, + terminalCleanupUnconfirmed, + ...(terminalsUnavailable ? { terminalsUnavailable } : {}), }, }); } catch (error) { @@ -208,6 +222,8 @@ export class DaemonSession { listen: null, relay: null, providers: [], + terminalCleanupUnconfirmed, + ...(terminalsUnavailable ? { terminalsUnavailable } : {}), }, }); } @@ -252,13 +268,13 @@ export class DaemonSession { } } - handleConfigReloadRequest( + async handleConfigReloadRequest( msg: Extract, - ): void { + ): Promise { try { this.host.emit({ type: "daemon.config.reload.response", - payload: { requestId: msg.requestId, ...this.reloadConfig() }, + payload: { requestId: msg.requestId, ...(await this.reloadConfig()) }, }); } catch (error) { this.logger.error({ err: error }, "Failed to reload daemon config"); diff --git a/packages/server/src/server/session/project-config/project-config-session.test.ts b/packages/server/src/server/session/project-config/project-config-session.test.ts index 6e93dff805..f8f6b59304 100644 --- a/packages/server/src/server/session/project-config/project-config-session.test.ts +++ b/packages/server/src/server/session/project-config/project-config-session.test.ts @@ -228,7 +228,7 @@ describe("ProjectConfigSession", () => { requestId: "write-uncommitted-setup", repoRoot, config: { worktree: { setup: "npm install" } }, - expectedRevision: statPaseoConfigPath(repoRoot), + expectedRevision: await statPaseoConfigPath(repoRoot), }); expect(emitted).toEqual([ diff --git a/packages/server/src/server/session/project-config/project-config-session.ts b/packages/server/src/server/session/project-config/project-config-session.ts index 6056d08826..d2e8c419f0 100644 --- a/packages/server/src/server/session/project-config/project-config-session.ts +++ b/packages/server/src/server/session/project-config/project-config-session.ts @@ -1,10 +1,10 @@ -import { realpathSync } from "node:fs"; -import { resolve, sep } from "path"; +import { resolve } from "path"; import type pino from "pino"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import type { ProjectRegistry } from "../../workspace-registry.js"; import { readPaseoConfigForEdit, + resolveProjectConfigRoot, writePaseoConfigForEdit, type ProjectConfigRpcError, } from "../../../utils/paseo-config-file.js"; @@ -47,7 +47,7 @@ export class ProjectConfigSession { return; } - const result = readPaseoConfigForEdit(repoRoot); + const result = await readPaseoConfigForEdit(repoRoot); if (!result.ok) { this.logger.warn( { repoRoot, requestId: msg.requestId, outcome: result.error.code }, @@ -94,7 +94,7 @@ export class ProjectConfigSession { { repoRoot, requestId: msg.requestId, outcome: "write_attempt" }, "Writing project config", ); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot, config: msg.config, expectedRevision: msg.expectedRevision, @@ -173,34 +173,21 @@ export class ProjectConfigSession { } private async resolveKnownProjectRoot(repoRoot: string): Promise { - const requestedRoot = canonicalizeConfigRoot(repoRoot); + const requestedRoot = resolve(repoRoot); const projects = await this.projectRegistry.list(); - for (const project of projects) { - if (project.archivedAt !== null) { - continue; - } - const projectRoot = canonicalizeConfigRoot(project.rootPath); - if (requestedRoot === projectRoot) { - return projectRoot; - } - } - return null; + const activeRoots = new Set( + projects + .filter((project) => project.archivedAt === null) + .map((project) => resolve(project.rootPath)), + ); + const root = await resolveProjectConfigRoot(requestedRoot, activeRoots); + if (!root) return null; + // Authorization follows live registry state after alias IO, including archive/removal. + const current = await this.projectRegistry.list(); + return current.some( + (project) => project.archivedAt === null && resolve(project.rootPath) === root, + ) + ? root + : null; } } - -function canonicalizeConfigRoot(repoRoot: string): string { - const resolved = resolve(repoRoot); - try { - return stripTrailingPathSeparators(realpathSync(resolved)); - } catch { - return stripTrailingPathSeparators(resolved); - } -} - -function stripTrailingPathSeparators(path: string): string { - let normalized = path; - while (normalized.length > 1 && normalized.endsWith(sep)) { - normalized = normalized.slice(0, -1); - } - return normalized; -} diff --git a/packages/server/src/server/session/speech/speech-models-session.test.ts b/packages/server/src/server/session/speech/speech-models-session.test.ts new file mode 100644 index 0000000000..9624475ad4 --- /dev/null +++ b/packages/server/src/server/session/speech/speech-models-session.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; + +import type { SessionOutboundMessage } from "../../messages.js"; +import { + listLocalSpeechModels, + type LocalSpeechModelId, +} from "../../speech/providers/local/models.js"; +import { + LocalSpeechModelInUseError, + type LocalSpeechModelStatus, +} from "../../speech/speech-runtime.js"; +import { SpeechModelsSession } from "./speech-models-session.js"; + +const [firstModel] = listLocalSpeechModels(); + +function createSession(models: LocalSpeechModelStatus[], inUse: LocalSpeechModelId[] = []) { + const emitted: SessionOutboundMessage[] = []; + const downloads: LocalSpeechModelId[] = []; + const session = new SpeechModelsSession({ + emit: (message) => emitted.push(message), + runtime: { + listLocalModels: async () => models, + downloadLocalModel: (modelId) => { + downloads.push(modelId); + }, + deleteLocalModel: async (modelId) => { + if (inUse.includes(modelId)) throw new LocalSpeechModelInUseError(modelId); + }, + }, + }); + return { session, emitted, downloads }; +} + +describe("SpeechModelsSession", () => { + test("lists catalog details and download progress before anything is installed", async () => { + const { session, emitted } = createSession([ + { + spec: firstModel, + state: { status: "downloading", receivedBytes: 10, totalBytes: 40 }, + inUse: true, + }, + ]); + + await session.dispatch({ type: "speech.local_models.list.request", requestId: "r1" }); + + expect(emitted).toEqual([ + { + type: "speech.local_models.list.response", + payload: { + requestId: "r1", + error: null, + models: [ + { + id: firstModel.id, + kind: "stt", + label: firstModel.label, + description: firstModel.description, + languages: [...firstModel.languages], + downloadSizeBytes: firstModel.downloadSizeBytes, + isDefault: true, + inUse: true, + status: "downloading", + receivedBytes: 10, + totalBytes: 40, + }, + ], + }, + }, + ]); + }); + + test("rejects a model id outside the catalog without starting a download", () => { + const { session, emitted, downloads } = createSession([]); + + void session.dispatch({ + type: "speech.local_models.download.request", + requestId: "r2", + modelId: "../../etc", + }); + + expect(downloads).toEqual([]); + expect(emitted).toEqual([ + { + type: "speech.local_models.download.response", + payload: { requestId: "r2", error: "Unknown local speech model: ../../etc" }, + }, + ]); + }); + + test("reports why an in-use model cannot be deleted", async () => { + const { session, emitted } = createSession([], [firstModel.id]); + + await session.dispatch({ + type: "speech.local_models.delete.request", + requestId: "r3", + modelId: firstModel.id, + }); + + expect(emitted).toEqual([ + { + type: "speech.local_models.delete.response", + payload: { requestId: "r3", error: new LocalSpeechModelInUseError(firstModel.id).message }, + }, + ]); + }); +}); diff --git a/packages/server/src/server/session/speech/speech-models-session.ts b/packages/server/src/server/session/speech/speech-models-session.ts new file mode 100644 index 0000000000..d987623a8d --- /dev/null +++ b/packages/server/src/server/session/speech/speech-models-session.ts @@ -0,0 +1,124 @@ +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; + +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import { LocalSpeechModelIdSchema } from "../../speech/providers/local/models.js"; +import type { LocalSpeechModelStatus, SpeechService } from "../../speech/speech-runtime.js"; + +type SpeechModelsRuntime = Pick< + SpeechService, + "listLocalModels" | "downloadLocalModel" | "deleteLocalModel" +>; + +export interface SpeechModelsSessionOptions { + emit(message: SessionOutboundMessage): void; + // Absent when the daemon runs without speech. + runtime?: SpeechModelsRuntime | null; +} + +export class SpeechModelsSession { + constructor(private readonly options: SpeechModelsSessionOptions) {} + + // Handles the speech.local_models.* requests; returns undefined for any other message. + dispatch(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { + case "speech.local_models.list.request": + return this.handleList(msg); + case "speech.local_models.download.request": + this.handleDownload(msg); + return Promise.resolve(); + case "speech.local_models.delete.request": + return this.handleDelete(msg); + default: + return undefined; + } + } + + private async handleList( + msg: Extract, + ): Promise { + try { + const models = await this.requireRuntime().listLocalModels(); + this.options.emit({ + type: "speech.local_models.list.response", + payload: { requestId: msg.requestId, models: models.map(toWireModel), error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.list.response", + payload: { requestId: msg.requestId, models: [], error: describeError(error) }, + }); + } + } + + private handleDownload( + msg: Extract, + ): void { + try { + this.requireRuntime().downloadLocalModel(parseModelId(msg.modelId)); + this.options.emit({ + type: "speech.local_models.download.response", + payload: { requestId: msg.requestId, error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.download.response", + payload: { requestId: msg.requestId, error: describeError(error) }, + }); + } + } + + private async handleDelete( + msg: Extract, + ): Promise { + try { + await this.requireRuntime().deleteLocalModel(parseModelId(msg.modelId)); + this.options.emit({ + type: "speech.local_models.delete.response", + payload: { requestId: msg.requestId, error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.delete.response", + payload: { requestId: msg.requestId, error: describeError(error) }, + }); + } + } + + private requireRuntime(): SpeechModelsRuntime { + if (!this.options.runtime) { + throw new Error("Speech is unavailable on this daemon"); + } + return this.options.runtime; + } +} + +function parseModelId(modelId: string) { + const parsed = LocalSpeechModelIdSchema.safeParse(modelId); + if (!parsed.success) { + throw new Error(`Unknown local speech model: ${modelId}`); + } + return parsed.data; +} + +function toWireModel(model: LocalSpeechModelStatus): LocalSpeechModel { + const { spec, state, inUse } = model; + return { + id: spec.id, + kind: spec.kind === "tts" ? "tts" : "stt", + label: spec.label, + description: spec.description, + languages: [...spec.languages], + downloadSizeBytes: spec.downloadSizeBytes, + isDefault: spec.defaultFor !== undefined, + inUse, + status: state.status, + ...(state.status === "downloading" + ? { receivedBytes: state.receivedBytes, totalBytes: state.totalBytes } + : {}), + ...(state.status === "failed" ? { error: state.error } : {}), + }; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/server/src/server/session/usage/usage-session.test.ts b/packages/server/src/server/session/usage/usage-session.test.ts index 2aa65992b8..3a397094a3 100644 --- a/packages/server/src/server/session/usage/usage-session.test.ts +++ b/packages/server/src/server/session/usage/usage-session.test.ts @@ -15,6 +15,7 @@ test("lists reports from usage sources", async () => { report: { status: "available" as const, windows: [] }, }; const usage = new UsageSession({ + isEnabled: () => true, emit: (message) => emitted.push(message), runtime: { async listUsageReports(options) { @@ -40,6 +41,7 @@ test("lists reports from usage sources", async () => { test("surfaces a legacy usage-list failure as an rpc_error envelope", async () => { const emitted: SessionOutboundMessage[] = []; const usage = new UsageSession({ + isEnabled: () => true, emit: (message) => emitted.push(message), runtime: { async listUsageReports() { @@ -61,6 +63,7 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () = test("request failures terminate with an error response and no updates", async () => { const emitted: SessionOutboundMessage[] = []; const usage = new UsageSession({ + isEnabled: () => true, emit: (message) => emitted.push(message), logger: pino({ level: "silent" }), }); @@ -72,3 +75,36 @@ test("request failures terminate with an error response and no updates", async ( }, ]); }); + +test("off rejects both protocols before runtime discovery and turning on restores reports", async () => { + let enabled = false; + let calls = 0; + const emitted: SessionOutboundMessage[] = []; + const usage = new UsageSession({ + isEnabled: () => enabled, + emit: (message) => emitted.push(message), + logger: pino({ level: "silent" }), + runtime: { + async listUsageReports() { + calls++; + return []; + }, + async listLegacyUsage() { + calls++; + return { fetchedAt: "now", providers: [] }; + }, + }, + }); + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "off" }); + await usage.handleLegacyList({ type: "provider.usage.list.request", requestId: "old" }); + expect(calls).toBe(0); + expect(emitted[0]).toMatchObject({ payload: { error: "Usage is off on this host." } }); + expect(emitted[1]).toMatchObject({ + type: "rpc_error", + payload: { error: "Usage is off on this host." }, + }); + enabled = true; + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "on" }); + expect(calls).toBe(1); + expect(emitted[2]).toMatchObject({ payload: { error: null } }); +}); diff --git a/packages/server/src/server/session/usage/usage-session.ts b/packages/server/src/server/session/usage/usage-session.ts index 500c03a088..cd1e2e8801 100644 --- a/packages/server/src/server/session/usage/usage-session.ts +++ b/packages/server/src/server/session/usage/usage-session.ts @@ -9,6 +9,7 @@ export interface UsageSessionOptions { listUsageReports(options: ListUsageReportsOptions): Promise; listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; + isEnabled: () => boolean; logger: pino.Logger; } @@ -19,6 +20,7 @@ export class UsageSession { msg: Extract, ): Promise { try { + if (!this.options.isEnabled()) throw new Error("Usage is off on this host."); if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); await this.options.runtime.listUsageReports({ forceRefresh: msg.forceRefresh, @@ -49,6 +51,18 @@ export class UsageSession { async handleLegacyList( msg: Extract, ): Promise { + if (!this.options.isEnabled()) { + this.options.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: "Usage is off on this host.", + code: "provider_usage_list_failed", + }, + }); + return; + } try { if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); const usage = await this.options.runtime.listLegacyUsage(); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 0a3b8cb257..c93e535709 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -175,7 +175,7 @@ test("background workspace creation preserves hooks, inheritance and durable vis expect(child.background).toBe(true); expect(visible.background).toBe(false); expect(human.background).toBe(false); - expect(events).toEqual(Array(4).fill("workspace.created")); + expect(events).toEqual(Array(4).fill("workspace.create.after")); const reloaded = new FileBackedWorkspaceRegistry( path.join(tmpDir, "projects", "workspaces.json"), logger, 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 3ac91a834a..08d7b93bf4 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 @@ -307,7 +307,9 @@ export function createWorkspaceProvisioningService(deps: { } function emitWorkspaceCreated(workspace: PersistedWorkspaceRecord): void { - deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); + deps.lifecycle?.emit("workspace.create.after", { + workspace: describeHookWorkspace(workspace), + }); } async function resolveSourceProjectForWorktree(input: { diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index f738a0b0d6..5526166d05 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, realpathSync, + readFileSync, rmSync, statSync, writeFileSync, @@ -347,7 +348,7 @@ async function createBaseRecoveryFixture(baseBranch: string | null) { test("preserves ordinary checkout behavior when no separate base was recorded", async () => { const fixture = await createBaseRecoveryFixture(null); - writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" }); + await writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" }); await fixture.archiveAndRemove(); await fixture.service.restore(fixture.workspace.workspaceId); expect((await fixture.registry.get(fixture.workspace.workspaceId))?.baseBranch).toBeNull(); @@ -430,7 +431,7 @@ test("restores the branch HEAD with a base name when the exact base is missing", }); expect(existsSync(fixture.workspace.cwd)).toBe(true); expect((await fixture.registry.get(fixture.workspace.workspaceId))?.archivedAt).toBeNull(); - const metadata = readPaseoWorktreeMetadata(fixture.workspace.cwd); + const metadata = await readPaseoWorktreeMetadata(fixture.workspace.cwd); expect(metadata).toMatchObject({ baseRefName: "main" }); expect(metadata?.baseRef).toBeUndefined(); expect( @@ -450,7 +451,7 @@ test("restore fetches a deleted local branch from origin and retains its base", await fixture.archiveAndRemove(); execFileSync("git", ["branch", "-D", "feature"], { cwd: fixture.repoDir }); await fixture.service.restore(fixture.workspace.workspaceId); - expect(readPaseoWorktreeMetadata(fixture.workspace.cwd)?.baseRef).toBe("refs/heads/main"); + expect((await readPaseoWorktreeMetadata(fixture.workspace.cwd))?.baseRef).toBe("refs/heads/main"); expect( await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }), ).toMatchObject({ @@ -476,3 +477,43 @@ test("restore rejects a branch checked out elsewhere without inventing another b }).trim(), ).toBe("* feature"); }); + +test("restores a user-created worktree at its recorded path outside Paseo's root", async () => { + const { tempDir, repoDir } = createGitRepository(); + const worktreeRoot = join(realpathSync(tempDir), "external-worktree"); + execFileSync("git", ["worktree", "add", "-b", "external-feature", worktreeRoot], { + cwd: repoDir, + stdio: "pipe", + }); + const workspace = createWorkspace({ + cwd: worktreeRoot, + worktreeRoot, + mainRepoRoot: repoDir, + branch: "external-feature", + baseBranch: "main", + isPaseoOwnedWorktree: false, + }); + execFileSync("git", ["worktree", "remove", worktreeRoot], { cwd: repoDir, stdio: "pipe" }); + const unarchived: string[] = []; + const service = createWorkspaceRecoveryService({ + paseoHome: join(tempDir, "paseo-home"), + getWorkspace: async () => workspace, + getProject: async () => createProject({ rootPath: repoDir }), + isDirectory: async (target) => existsSync(target) && statSync(target).isDirectory(), + unarchiveWorkspace: async (record) => { + unarchived.push(record.workspaceId); + }, + }); + await expect(service.restore(workspace.workspaceId)).resolves.toEqual({ + workspaceId: workspace.workspaceId, + action: "restore", + }); + expect(readFileSync(join(worktreeRoot, "README.md"), "utf8").trim()).toBe("initial"); + expect( + execFileSync("git", ["branch", "--show-current"], { + cwd: worktreeRoot, + encoding: "utf8", + }).trim(), + ).toBe("external-feature"); + expect(unarchived).toEqual([workspace.workspaceId]); +}); diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index 1a715dd349..a683a07744 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -166,6 +166,7 @@ export function createWorkspaceRecoveryService(deps: { } let previousWorktreePath = workspace.worktreeRoot; + let hasRecordedWorktreePath = Boolean(previousWorktreePath); if (!previousWorktreePath) { // COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110 // lack durable backing placement; remove filesystem discovery after 2027-01-17. @@ -176,6 +177,7 @@ export function createWorkspaceRecoveryService(deps: { previousWorktreePath = ownership.allowed ? (ownership.worktreePath ?? workspace.cwd) : workspace.cwd; + hasRecordedWorktreePath = ownership.allowed; } let recreatedWorktreePath: string; @@ -187,6 +189,7 @@ export function createWorkspaceRecoveryService(deps: { paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot, + ...(hasRecordedWorktreePath ? { worktreePath: previousWorktreePath } : {}), }); recreatedWorktreePath = result.worktreePath; } catch (error) { diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 18ae1f1366..664fcc14de 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -1,3 +1,5 @@ +import { DaemonConfigStore } from "../../daemon-config-store.js"; +import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -82,6 +84,7 @@ interface BuildOptions { spawnThrows?: string; runtimeStore?: Pick; automationError?: Error; + getGlobalServicePorts?: () => Promise; } function buildService(options: BuildOptions = {}) { @@ -111,6 +114,7 @@ function buildService(options: BuildOptions = {}) { getDaemonTcpHost: () => "127.0.0.1", serviceProxyPublicBaseUrl: null, resolveScriptHealth: null, + getGlobalServicePorts: options.getGlobalServicePorts ?? (async () => undefined), logger, emit: (message) => emitted.push(message), publishStatusUpdate: (message) => published.push(message), @@ -152,18 +156,42 @@ afterEach(() => { } }); +test("reads external service-port edits at the next launch without construction IO", async () => { + const home = mkdtempSync(join(tmpdir(), "workspace-service-ports-")); + tempDirs.push(home); + const store = new DaemonConfigStore(home, { + providers: {}, + mcp: { enabled: true, injectIntoAgents: false }, + }); + const { service, spawnCalls } = buildService({ + getGlobalServicePorts: () => store.readServicePorts(), + }); + writeFileSync( + join(home, "config.json"), + JSON.stringify({ worktrees: { servicePorts: { range: "9000-9999" } } }), + ); + await service.start(request); + expect(spawnCalls.map((call) => call.globalServicePorts)).toEqual([{ range: "9000-9999" }]); +}); + describe("buildSnapshot", () => { test("returns no scripts when the service proxy is unavailable", async () => { const { service } = buildService({ serviceProxy: null }); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + await service.buildSnapshot({ + workspaceId: "ws-1", + cwd: "/tmp/repo", + } as PersistedWorkspaceRecord), ).toEqual([]); }); test("returns no scripts when the runtime store is unavailable", async () => { const { service } = buildService({ scriptRuntimeStore: null }); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord), + await service.buildSnapshot({ + workspaceId: "ws-1", + cwd: "/tmp/repo", + } as PersistedWorkspaceRecord), ).toEqual([]); }); @@ -172,7 +200,7 @@ describe("buildSnapshot", () => { tempDirs.push(dir); const { service } = buildService(); expect( - service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord), + await service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord), ).toEqual([]); }); @@ -207,7 +235,7 @@ describe("buildSnapshot", () => { runtimeStore: { get: () => null }, }); - expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe( + expect((await service.buildSnapshot(workspace, project))[0]?.hostname).toBe( serviceProxy.projectWorkspaceService({ projectSlug: deriveProjectServiceSlug(project), branchName: workspace.branch, @@ -352,10 +380,12 @@ describe("start", () => { daemonPort: 6767, daemonListenHost: "127.0.0.1", }); - expect(published).toContainEqual({ - type: "script_status_update", - payload: { workspaceId: "ws-1", scripts: [] }, - }); + await expect + .poll(() => published) + .toContainEqual({ + type: "script_status_update", + payload: { workspaceId: "ws-1", scripts: [] }, + }); expect(emitted).toContainEqual({ type: "start_workspace_script_response", payload: { @@ -449,7 +479,7 @@ describe("start", () => { const serviceProxy = createServiceProxySubsystem({ logger }); const { service, spawnCalls } = buildService({ workspace, project, serviceProxy }); - const snapshot = service.buildSnapshot(workspace, project); + const snapshot = await service.buildSnapshot(workspace, project); await service.start({ ...request, workspaceId: workspace.workspaceId }); const started = spawnCalls[0]!; 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 103c3ed16b..eebad7f1e5 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 @@ -43,7 +43,7 @@ export interface WorkspaceScriptsService { buildSnapshot( workspace: PersistedWorkspaceRecord, project?: PersistedProjectRecord | null, - ): WorkspaceScriptsPayload; + ): Promise; emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise; list(workspaceId: string): Promise; launch(input: { workspaceId: string; scriptName: string }): Promise; @@ -62,7 +62,7 @@ export function createWorkspaceScriptsService(deps: { getDaemonTcpHost: (() => string | null) | null; serviceProxyPublicBaseUrl: string | null; resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null; - globalServicePorts?: PaseoServicePortAllocation; + getGlobalServicePorts: () => Promise; logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; // Script status is daemon state, so it reaches every client, not only the requester. @@ -81,7 +81,7 @@ export function createWorkspaceScriptsService(deps: { getDaemonTcpHost, serviceProxyPublicBaseUrl, resolveScriptHealth, - globalServicePorts, + getGlobalServicePorts, logger, emit, publishStatusUpdate, @@ -110,17 +110,17 @@ export function createWorkspaceScriptsService(deps: { }; } - function buildSnapshot( + async function buildSnapshot( workspace: PersistedWorkspaceRecord, project: PersistedProjectRecord | null = null, - ): WorkspaceScriptsPayload { + ): Promise { if (!serviceProxy || !scriptRuntimeStore) { return []; } return buildWorkspaceScriptPayloads({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.cwd, - paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger), + paseoConfig: await readPaseoConfigForProjection(workspace.cwd, logger), serviceProxy, runtimeStore: scriptRuntimeStore, daemonPort: getDaemonTcpPort?.() ?? null, @@ -137,7 +137,7 @@ export function createWorkspaceScriptsService(deps: { const project = await projectRegistry.get(workspace.projectId); publishStatusUpdate({ type: "script_status_update", - payload: { workspaceId, scripts: buildSnapshot(workspace, project) }, + payload: { workspaceId, scripts: await buildSnapshot(workspace, project) }, }); } catch (error) { logger.warn({ err: error, workspaceId }, "Failed to project workspace script status"); @@ -172,6 +172,7 @@ export function createWorkspaceScriptsService(deps: { async function launchProcess(input: { workspaceId: string; scriptName: string }) { const available = requireAvailable(); + const globalServicePorts = await getGlobalServicePorts(); const workspace = await getWorkspace(input.workspaceId); await assertAutomationAllowed(workspace.workspaceId); const project = await projectRegistry.get(workspace.projectId); @@ -202,7 +203,7 @@ export function createWorkspaceScriptsService(deps: { scriptName: string; }): Promise { const { workspace, project } = await launchProcess(input); - const script = buildSnapshot(workspace, project).find( + const script = (await buildSnapshot(workspace, project)).find( (entry) => entry.scriptName === input.scriptName, ); if (!script) { @@ -230,7 +231,7 @@ export function createWorkspaceScriptsService(deps: { // The launcher's terminal exit listener owns route removal and runtime state updates. await available.terminalManager.killTerminalAndWait(runtime.terminalId); - const script = buildSnapshot(workspace, project).find( + const script = (await buildSnapshot(workspace, project)).find( (entry) => entry.scriptName === input.scriptName, ); if (!script) { diff --git a/packages/server/src/server/speech/providers/local/config.ts b/packages/server/src/server/speech/providers/local/config.ts index 8e7d500dc2..dd52537796 100644 --- a/packages/server/src/server/speech/providers/local/config.ts +++ b/packages/server/src/server/speech/providers/local/config.ts @@ -29,6 +29,7 @@ export interface LocalSpeechProviderConfig { export interface ResolvedLocalSpeechConfig { local: LocalSpeechProviderConfig | undefined; + modelsDir: string; sttLanguages: LocalSpeechSttLanguageConfig; } @@ -68,12 +69,12 @@ const LocalSpeechResolutionSchema = z.object({ function persistedLocalFeatureModel( provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"], enabled: boolean | undefined, - model: string | undefined, + site: { provider: string; model?: string } | undefined, ): string | undefined { if (provider !== "local" || enabled === false) { return undefined; } - return model; + return site?.provider === "local" ? site.model : undefined; } function shouldIncludeLocalProviderConfig(params: { @@ -143,6 +144,8 @@ function buildLocalSpeechResolutionInput(params: { includeProviderConfig: boolean; }): Record { const { paseoHome, env, persisted, providers, includeProviderConfig } = params; + const tts = persisted.features?.voiceMode?.tts; + const localTts = tts?.provider === "local" ? tts : undefined; return { includeProviderConfig, modelsDir: firstDefinedValue([ @@ -155,7 +158,7 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.dictationStt.provider, providers.dictationStt.enabled, - persisted.features?.dictation?.stt?.model, + persisted.features?.dictation?.stt, ), DEFAULT_LOCAL_STT_MODEL, ]), @@ -164,7 +167,7 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.voiceStt.provider, providers.voiceStt.enabled, - persisted.features?.voiceMode?.stt?.model, + persisted.features?.voiceMode?.stt, ), DEFAULT_LOCAL_STT_MODEL, ]), @@ -173,18 +176,18 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.voiceTts.provider, providers.voiceTts.enabled, - persisted.features?.voiceMode?.tts?.model, + persisted.features?.voiceMode?.tts, ), DEFAULT_LOCAL_TTS_MODEL, ]), ...buildLocalSpeechLanguageResolutionInput({ env, persisted }), voiceLocalTtsSpeakerId: firstDefinedValue([ env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID, - persisted.features?.voiceMode?.tts?.speakerId, + localTts?.speakerId, ]), voiceLocalTtsSpeed: firstDefinedValue([ env.PASEO_VOICE_LOCAL_TTS_SPEED, - persisted.features?.voiceMode?.tts?.speed, + localTts?.speed, ]), }; } @@ -200,11 +203,10 @@ export function resolveLocalSpeechConfig(params: { buildLocalSpeechResolutionInput({ ...params, includeProviderConfig }), ); - const resolvedVoiceTtsSpeakerId = - parsed.voiceLocalTtsSpeakerId ?? - (parsed.voiceLocalTtsModel === "kokoro-en-v0_19" ? 0 : undefined); + const resolvedVoiceTtsSpeakerId = parsed.voiceLocalTtsSpeakerId ?? 0; return { + modelsDir: parsed.modelsDir, sttLanguages: { dictation: parsed.dictationLanguage, voice: parsed.voiceLanguage, diff --git a/packages/server/src/server/speech/providers/local/model-library.ts b/packages/server/src/server/speech/providers/local/model-library.ts new file mode 100644 index 0000000000..4ef19e3fa9 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/model-library.ts @@ -0,0 +1,137 @@ +import type { Logger } from "pino"; + +import { + deleteSherpaOnnxModel, + ensureSherpaOnnxModel, + isSherpaOnnxModelInstalled, +} from "./sherpa/model-downloader.js"; +import { + listLocalSpeechModels, + type LocalSpeechModelId, + type LocalSpeechModelSpec, +} from "./models.js"; + +export type LocalSpeechModelInstallState = + | { status: "installed" } + | { status: "not_installed" } + | { status: "downloading"; receivedBytes: number; totalBytes: number } + | { status: "extracting" } + | { status: "failed"; error: string }; + +export interface LocalSpeechModelEntry { + spec: LocalSpeechModelSpec; + state: LocalSpeechModelInstallState; +} + +type InFlightState = Extract< + LocalSpeechModelInstallState, + { status: "downloading" | "extracting" } +>; + +interface InFlightDownload { + promise: Promise; + state: InFlightState; +} + +export class LocalSpeechModelBusyError extends Error { + constructor(public readonly modelId: LocalSpeechModelId) { + super(`${modelId} is downloading. Wait for the download to finish before deleting it.`); + this.name = "LocalSpeechModelBusyError"; + } +} + +// Owns the models directory: which catalog models are installed, which are downloading, +// and the last failure per model. Concurrent downloads of one model share a single transfer. +export class LocalSpeechModelLibrary { + private readonly inFlight = new Map(); + private readonly failures = new Map(); + private readonly abortController = new AbortController(); + private readonly logger: Logger; + + constructor( + private readonly modelsDir: string, + logger: Logger, + ) { + this.logger = logger.child({ module: "speech", component: "local-model-library" }); + } + + getModelsDir(): string { + return this.modelsDir; + } + + async list(): Promise { + return Promise.all( + listLocalSpeechModels().map(async (spec) => ({ spec, state: await this.getState(spec.id) })), + ); + } + + async listMissing(modelIds: LocalSpeechModelId[]): Promise { + const installed = await Promise.all( + modelIds.map((modelId) => isSherpaOnnxModelInstalled(this.modelsDir, modelId)), + ); + return modelIds.filter((_modelId, index) => !installed[index]); + } + + download(modelId: LocalSpeechModelId): Promise { + const existing = this.inFlight.get(modelId); + if (existing) { + return existing.promise; + } + this.failures.delete(modelId); + const entry: InFlightDownload = { + state: { status: "downloading", receivedBytes: 0, totalBytes: 0 }, + promise: Promise.resolve(""), + }; + entry.promise = ensureSherpaOnnxModel({ + modelsDir: this.modelsDir, + modelId, + logger: this.logger, + signal: this.abortController.signal, + onProgress: (progress) => { + entry.state = + progress.phase === "downloading" + ? { + status: "downloading", + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes, + } + : { status: "extracting" }; + }, + }) + .catch((error: unknown) => { + this.failures.set(modelId, error instanceof Error ? error.message : String(error)); + throw error; + }) + .finally(() => { + this.inFlight.delete(modelId); + }); + this.inFlight.set(modelId, entry); + return entry.promise; + } + + async delete(modelId: LocalSpeechModelId): Promise { + if (this.inFlight.has(modelId)) { + throw new LocalSpeechModelBusyError(modelId); + } + this.failures.delete(modelId); + await deleteSherpaOnnxModel(this.modelsDir, modelId); + this.logger.info({ modelId }, "Deleted local speech model"); + } + + async stop(): Promise { + this.abortController.abort(); + await Promise.allSettled(Array.from(this.inFlight.values(), (entry) => entry.promise)); + } + + private async getState(modelId: LocalSpeechModelId): Promise { + const inFlight = this.inFlight.get(modelId); + if (inFlight) { + return inFlight.state; + } + if (await isSherpaOnnxModelInstalled(this.modelsDir, modelId)) { + return { status: "installed" }; + } + const failure = this.failures.get(modelId); + return failure ? { status: "failed", error: failure } : { status: "not_installed" }; + } +} diff --git a/packages/server/src/server/speech/providers/local/models.test.ts b/packages/server/src/server/speech/providers/local/models.test.ts index e40cc25384..be010dcae9 100644 --- a/packages/server/src/server/speech/providers/local/models.test.ts +++ b/packages/server/src/server/speech/providers/local/models.test.ts @@ -13,12 +13,8 @@ import path from "node:path"; import pino from "pino"; import { describe, expect, onTestFinished, test } from "vitest"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, - listMissingLocalSpeechModels, -} from "./models.js"; +import { LocalSpeechModelLibrary } from "./model-library.js"; +import { getLocalSpeechModelDir, listLocalSpeechModels } from "./models.js"; const logger = pino({ level: "silent" }); const modelId = "parakeet-tdt-0.6b-v2-int8"; @@ -55,16 +51,31 @@ describe("local speech models", () => { const encoder = path.join(modelDir, spec.requiredFiles[0]); truncateSync(encoder, 1_000); - expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([ - modelId, - ]); + const library = new LocalSpeechModelLibrary(modelsDir, logger); + expect(await library.listMissing([modelId])).toEqual([modelId]); - await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger }); + await library.download(modelId); expect(readFileSync(encoder, "utf8")).toBe( `complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."), ); expect(existsSync(archive)).toBe(false); - expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]); + expect(await library.listMissing([modelId])).toEqual([]); + }); + + test("lists install state and deletes an installed model", async () => { + const { modelsDir } = setupModelsDirWithArchive(); + const library = new LocalSpeechModelLibrary(modelsDir, logger); + const stateOf = async () => + (await library.list()).find((entry) => entry.spec.id === modelId)?.state; + + expect(await stateOf()).toEqual({ status: "not_installed" }); + await library.download(modelId); + expect(await stateOf()).toEqual({ status: "installed" }); + + await library.delete(modelId); + + expect(await stateOf()).toEqual({ status: "not_installed" }); + expect(existsSync(getLocalSpeechModelDir(modelsDir, modelId))).toBe(false); }); }); diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index a884be86d7..7d4c7f56b5 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -1,11 +1,8 @@ -import { - ensureSherpaOnnxModels, - getSherpaOnnxModelDir, - isSherpaOnnxModelInstalled, -} from "./sherpa/model-downloader.js"; +import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js"; import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, + LocalSpeechModelIdSchema, LocalSttModelIdSchema, LocalTtsModelIdSchema, listSherpaOnnxModels, @@ -17,6 +14,7 @@ import { export { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, + LocalSpeechModelIdSchema, LocalSttModelIdSchema, LocalTtsModelIdSchema, type LocalSpeechModelId, @@ -34,16 +32,6 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo return getSherpaOnnxModelDir(modelsDir, modelId); } -export async function listMissingLocalSpeechModels(options: { - modelsDir: string; - modelIds: LocalSpeechModelId[]; -}): Promise { - const installed = await Promise.all( - options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)), - ); - return options.modelIds.filter((_modelId, index) => !installed[index]); -} - export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts b/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts index 06d838c49a..b142f52dd6 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts @@ -4,43 +4,467 @@ export type SherpaOnnxModelKind = "stt-offline" | "tts"; type DefaultModelRole = "stt" | "tts"; -interface SherpaOnnxCatalogEntry { - kind: SherpaOnnxModelKind; +// How sherpa-onnx loads a model, with file names relative to the extracted directory. +export type SttEngine = + | { family: "transducer"; encoder: string; decoder: string; joiner: string; tokens: string } + | { family: "whisper"; encoder: string; decoder: string; tokens: string } + | { family: "sense_voice"; model: string; tokens: string } + | { family: "dolphin"; model: string; tokens: string } + | { family: "nemo_ctc"; model: string; tokens: string }; + +export type TtsEngine = + | { + family: "kokoro"; + model: string; + voices: string; + tokens: string; + dataDir: string; + // Multi-language Kokoro models read pronunciations from these instead of espeak alone. + lexicons?: readonly string[]; + dictDir?: string; + ruleFsts?: readonly string[]; + } + | { family: "kitten"; model: string; voices: string; tokens: string; dataDir: string } + | { family: "vits"; model: string; tokens: string; dataDir: string }; + +interface SherpaOnnxCatalogEntryBase { + label: string; + // One user-facing sentence shown before download. + description: string; + // BCP 47 language tags the model transcribes or speaks. + languages: readonly string[]; + // Exact size of the release archive. The downloader rejects any other size. + downloadSizeBytes: number; archiveUrl: string; extractedDir: string; - requiredFiles: string[]; - description: string; defaultFor?: DefaultModelRole; } +type SherpaOnnxCatalogEntry = + | (SherpaOnnxCatalogEntryBase & { kind: "stt-offline"; engine: SttEngine }) + | (SherpaOnnxCatalogEntryBase & { kind: "tts"; engine: TtsEngine }); + +// Every sherpa-onnx release archive extracts to a directory named after the archive. +function releaseArchive(tag: "asr-models" | "tts-models", name: string) { + return { + archiveUrl: `https://github.com/k2-fsa/sherpa-onnx/releases/download/${tag}/${name}.tar.bz2`, + extractedDir: name, + }; +} + +const PARAKEET_INT8_ENGINE = { + family: "transducer", + encoder: "encoder.int8.onnx", + decoder: "decoder.int8.onnx", + joiner: "joiner.int8.onnx", + tokens: "tokens.txt", +} as const satisfies SttEngine; + +function whisperEngine(size: string): SttEngine { + return { + family: "whisper", + encoder: `${size}-encoder.int8.onnx`, + decoder: `${size}-decoder.int8.onnx`, + tokens: `${size}-tokens.txt`, + }; +} + +function piperEngine(voice: string): TtsEngine { + return { + family: "vits", + model: `${voice}.onnx`, + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }; +} + +// Parakeet TDT v3 transcribes these 25 European languages and detects which one is spoken. +const PARAKEET_V3_LANGUAGES = [ + "bg", + "hr", + "cs", + "da", + "nl", + "en", + "et", + "fi", + "fr", + "de", + "el", + "hu", + "it", + "lv", + "lt", + "mt", + "pl", + "pt", + "ro", + "sk", + "sl", + "es", + "sv", + "ru", + "uk", +] as const; + +// The 99 languages multilingual Whisper models transcribe, detecting which one is spoken. +const WHISPER_LANGUAGES = [ + "en", + "zh", + "de", + "es", + "ru", + "ko", + "fr", + "ja", + "pt", + "tr", + "pl", + "ca", + "nl", + "ar", + "sv", + "it", + "id", + "hi", + "fi", + "vi", + "he", + "uk", + "el", + "ms", + "cs", + "ro", + "da", + "hu", + "ta", + "no", + "th", + "ur", + "hr", + "bg", + "lt", + "la", + "mi", + "ml", + "cy", + "sk", + "te", + "fa", + "lv", + "bn", + "sr", + "az", + "sl", + "kn", + "et", + "mk", + "br", + "eu", + "is", + "hy", + "ne", + "mn", + "bs", + "kk", + "sq", + "sw", + "gl", + "mr", + "pa", + "si", + "km", + "sn", + "yo", + "so", + "af", + "oc", + "ka", + "be", + "tg", + "sd", + "gu", + "am", + "yi", + "lo", + "uz", + "fo", + "ht", + "ps", + "tk", + "nn", + "mt", + "sa", + "lb", + "my", + "bo", + "tl", + "mg", + "as", + "tt", + "haw", + "ln", + "ha", + "ba", + "jv", + "su", +] as const; + +// Dolphin's 40 Eastern languages. It also covers 22 Chinese dialects under "zh". +const DOLPHIN_LANGUAGES = [ + "zh", + "ja", + "th", + "ru", + "ko", + "id", + "vi", + "yue", + "hi", + "ur", + "ms", + "uz", + "ar", + "fa", + "bn", + "ta", + "te", + "ug", + "gu", + "my", + "tl", + "kk", + "or", + "ne", + "mn", + "km", + "jv", + "lo", + "si", + "fil", + "ps", + "pa", + "kab", + "ba", + "ks", + "tg", + "su", + "mr", + "ky", + "az", +] as const; + export const SHERPA_ONNX_MODEL_CATALOG = { "parakeet-tdt-0.6b-v2-int8": { kind: "stt-offline", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2", - extractedDir: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", - requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"], - description: "NVIDIA Parakeet TDT v2 (offline NeMo transducer, English).", + label: "Parakeet v2", + description: "Accurate English transcription that runs well on a laptop CPU.", + languages: ["en"], + downloadSizeBytes: 482_468_385, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8"), + engine: PARAKEET_INT8_ENGINE, defaultFor: "stt", }, + "parakeet-tdt-110m-en-int8": { + kind: "stt-offline", + label: "Parakeet Small", + description: "Small, fast English model with punctuation. A quick download for any laptop.", + languages: ["en"], + downloadSizeBytes: 108_035_095, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet_tdt_transducer_110m-en-36000-int8"), + engine: PARAKEET_INT8_ENGINE, + }, "parakeet-tdt-0.6b-v3-int8": { kind: "stt-offline", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2", - extractedDir: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", - requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"], - description: - "NVIDIA Parakeet TDT v3 (offline NeMo transducer, 25 European languages, auto-detected).", + label: "Parakeet v3", + description: "Parakeet for 25 European languages, detecting which one you speak.", + languages: PARAKEET_V3_LANGUAGES, + downloadSizeBytes: 487_170_055, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"), + engine: PARAKEET_INT8_ENGINE, + }, + "whisper-tiny-en": { + kind: "stt-offline", + label: "Whisper Tiny (English)", + description: "Small English Whisper model. Quick to download, less accurate than Parakeet.", + languages: ["en"], + downloadSizeBytes: 118_071_777, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-tiny.en"), + engine: whisperEngine("tiny.en"), + }, + "whisper-base": { + kind: "stt-offline", + label: "Whisper Base", + description: "Compact Whisper model for 99 languages, detecting which one you speak.", + languages: WHISPER_LANGUAGES, + downloadSizeBytes: 207_557_382, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-base"), + engine: whisperEngine("base"), + }, + "whisper-turbo": { + kind: "stt-offline", + label: "Whisper Turbo", + description: "Whisper's most accurate model for 99 languages and Cantonese. Slower on a CPU.", + languages: [...WHISPER_LANGUAGES, "yue"], + downloadSizeBytes: 563_790_207, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-turbo"), + engine: whisperEngine("turbo"), + }, + "sense-voice-int8": { + kind: "stt-offline", + label: "SenseVoice", + description: "Chinese, Cantonese, English, Japanese and Korean, detecting which one you speak.", + languages: ["zh", "yue", "en", "ja", "ko"], + downloadSizeBytes: 165_783_878, + ...releaseArchive("asr-models", "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09"), + engine: { family: "sense_voice", model: "model.int8.onnx", tokens: "tokens.txt" }, + }, + "dolphin-base-int8": { + kind: "stt-offline", + label: "Dolphin", + description: "Small model for 40 Asian and Middle Eastern languages and Chinese dialects.", + languages: DOLPHIN_LANGUAGES, + downloadSizeBytes: 80_671_385, + ...releaseArchive("asr-models", "sherpa-onnx-dolphin-base-ctc-multi-lang-int8-2025-04-02"), + engine: { family: "dolphin", model: "model.int8.onnx", tokens: "tokens.txt" }, + }, + "giga-am-v3-ru": { + kind: "stt-offline", + label: "GigaAM v3 (Russian)", + description: "Russian transcription with punctuation.", + languages: ["ru"], + downloadSizeBytes: 170_197_019, + ...releaseArchive( + "asr-models", + "sherpa-onnx-nemo-transducer-punct-giga-am-v3-russian-2025-12-16", + ), + engine: { + family: "transducer", + encoder: "encoder.int8.onnx", + decoder: "decoder.onnx", + joiner: "joiner.onnx", + tokens: "tokens.txt", + }, + }, + "parakeet-tdt-ctc-0.6b-ja-int8": { + kind: "stt-offline", + label: "Parakeet (Japanese)", + description: "Japanese transcription with punctuation.", + languages: ["ja"], + downloadSizeBytes: 489_389_564, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt_ctc-0.6b-ja-35000-int8"), + engine: { family: "nemo_ctc", model: "model.int8.onnx", tokens: "tokens.txt" }, }, "kokoro-en-v0_19": { kind: "tts", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2", - extractedDir: "kokoro-en-v0_19", - requiredFiles: ["model.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"], - description: "Kokoro TTS (higher quality; larger).", + label: "Kokoro (English)", + description: "Natural-sounding English voice for voice mode replies.", + languages: ["en"], + downloadSizeBytes: 319_625_534, + ...releaseArchive("tts-models", "kokoro-en-v0_19"), + engine: { + family: "kokoro", + model: "model.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, defaultFor: "tts", }, + "kokoro-int8-en-v0_19": { + kind: "tts", + label: "Kokoro Lite (English)", + description: "The same Kokoro English voices, quantized to a third of the download.", + languages: ["en"], + downloadSizeBytes: 103_248_205, + ...releaseArchive("tts-models", "kokoro-int8-en-v0_19"), + engine: { + family: "kokoro", + model: "model.int8.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, + }, + "kokoro-int8-multi-lang-v1_1": { + kind: "tts", + label: "Kokoro (Chinese and English)", + description: "Kokoro voices that speak both Mandarin Chinese and English.", + languages: ["zh", "en"], + downloadSizeBytes: 147_031_220, + ...releaseArchive("tts-models", "kokoro-int8-multi-lang-v1_1"), + engine: { + family: "kokoro", + model: "model.int8.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + lexicons: ["lexicon-us-en.txt", "lexicon-zh.txt"], + dictDir: "dict", + ruleFsts: ["date-zh.fst", "phone-zh.fst", "number-zh.fst"], + }, + }, + // Kitten v0.8 needs a newer sherpa-onnx than we pin; v0.2 loads on 1.12.28. + "kitten-nano-en-v0_2-fp16": { + kind: "tts", + label: "Kitten Nano (English)", + description: "Tiny, very fast English voice.", + languages: ["en"], + downloadSizeBytes: 26_586_708, + ...releaseArchive("tts-models", "kitten-nano-en-v0_2-fp16"), + engine: { + family: "kitten", + model: "model.fp16.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, + }, + "piper-en-us-libritts-r": { + kind: "tts", + label: "Piper LibriTTS (US English)", + description: "Lightweight American English voice.", + languages: ["en-US"], + downloadSizeBytes: 23_398_348, + ...releaseArchive("tts-models", "vits-piper-en_US-libritts_r-medium-int8"), + engine: piperEngine("en_US-libritts_r-medium"), + }, + "piper-en-gb-alba": { + kind: "tts", + label: "Piper Alba (British English)", + description: "Lightweight British English voice.", + languages: ["en-GB"], + downloadSizeBytes: 21_104_326, + ...releaseArchive("tts-models", "vits-piper-en_GB-alba-medium-int8"), + engine: piperEngine("en_GB-alba-medium"), + }, + "piper-de-thorsten": { + kind: "tts", + label: "Piper Thorsten (German)", + description: "Lightweight German voice.", + languages: ["de"], + downloadSizeBytes: 20_949_833, + ...releaseArchive("tts-models", "vits-piper-de_DE-thorsten-medium-int8"), + engine: piperEngine("de_DE-thorsten-medium"), + }, + "piper-fr-siwis": { + kind: "tts", + label: "Piper Siwis (French)", + description: "Lightweight French voice.", + languages: ["fr"], + downloadSizeBytes: 20_914_888, + ...releaseArchive("tts-models", "vits-piper-fr_FR-siwis-medium-int8"), + engine: piperEngine("fr_FR-siwis-medium"), + }, + "piper-es-davefx": { + kind: "tts", + label: "Piper Davefx (Spanish)", + description: "Lightweight Spanish voice.", + languages: ["es"], + downloadSizeBytes: 21_171_632, + ...releaseArchive("tts-models", "vits-piper-es_ES-davefx-medium-int8"), + engine: piperEngine("es_ES-davefx-medium"), + }, } as const satisfies Record; export type SherpaOnnxModelId = keyof typeof SHERPA_ONNX_MODEL_CATALOG; @@ -97,24 +521,44 @@ function createModelIdSchema(modelIds: readonly T[]): z.ZodTyp .transform((value) => value as T); } +export const LocalSpeechModelIdSchema = createModelIdSchema(ALL_MODEL_IDS); export const LocalSttModelIdSchema = createModelIdSchema(LOCAL_STT_MODEL_IDS); export const LocalTtsModelIdSchema = createModelIdSchema(LOCAL_TTS_MODEL_IDS); export type SherpaOnnxModelSpec = SherpaOnnxCatalogEntry & { id: SherpaOnnxModelId; + // Every file and directory that must exist for the model to count as installed. + requiredFiles: string[]; }; export function listSherpaOnnxModels(): SherpaOnnxModelSpec[] { - return ALL_MODEL_IDS.map((id) => Object.assign({ id }, SHERPA_ONNX_MODEL_CATALOG[id])); + return ALL_MODEL_IDS.map(getSherpaOnnxModelSpec); } export function getSherpaOnnxModelSpec(id: SherpaOnnxModelId): SherpaOnnxModelSpec { - const spec = SHERPA_ONNX_MODEL_CATALOG[id]; + const spec: SherpaOnnxCatalogEntry | undefined = SHERPA_ONNX_MODEL_CATALOG[id]; if (!spec) { throw new Error(`Unknown local speech model id: ${id}`); } return { id, ...spec, + requiredFiles: engineFiles(spec.engine), }; } + +// Every path an engine names, so the downloader can verify an install. +export function engineFiles(engine: SttEngine | TtsEngine): string[] { + return Object.entries(engine).flatMap(([key, value]) => { + if (key === "family") return []; + return typeof value === "string" ? [value] : (value as readonly string[]); + }); +} + +export function getLocalSttEngine(id: LocalSttModelId): SttEngine { + return SHERPA_ONNX_MODEL_CATALOG[id].engine; +} + +export function getLocalTtsEngine(id: LocalTtsModelId): TtsEngine { + return SHERPA_ONNX_MODEL_CATALOG[id].engine; +} diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index cdd286389f..b7ba511224 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -8,11 +8,16 @@ import type pino from "pino"; import { getSherpaOnnxModelSpec, type SherpaOnnxModelId } from "./model-catalog.js"; import { spawnProcess } from "../../../../../utils/spawn.js"; +export type SherpaOnnxModelInstallProgress = + | { phase: "downloading"; receivedBytes: number; totalBytes: number } + | { phase: "extracting" }; + export interface EnsureSherpaOnnxModelOptions { modelsDir: string; modelId: SherpaOnnxModelId; logger: pino.Logger; signal?: AbortSignal; + onProgress?: (progress: SherpaOnnxModelInstallProgress) => void; } export function getSherpaOnnxModelDir(modelsDir: string, modelId: SherpaOnnxModelId): string { @@ -41,7 +46,9 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom interface DownloadToFileOptions { url: string; outputPath: string; + expectedBytes: number; signal?: AbortSignal; + onProgress?: (receivedBytes: number, totalBytes: number) => void; } async function downloadToFile(options: DownloadToFileOptions): Promise { @@ -60,6 +67,13 @@ async function downloadToFile(options: DownloadToFileOptions): Promise { // The fetch ReadableStream type is slightly different from what Readable.fromWeb expects // eslint-disable-next-line @typescript-eslint/no-explicit-any const nodeStream = Readable.fromWeb(res.body as any); + const contentLength = Number(res.headers.get("content-length")); + const totalBytes = contentLength > 0 ? contentLength : options.expectedBytes; + let receivedBytes = 0; + nodeStream.on("data", (chunk: Buffer) => { + receivedBytes += chunk.length; + options.onProgress?.(receivedBytes, totalBytes); + }); try { await pipeline(nodeStream, createWriteStream(tmpPath), { signal: options.signal }); @@ -157,9 +171,13 @@ export async function ensureSherpaOnnxModel( await downloadToFile({ url: spec.archiveUrl, outputPath: archivePath, + expectedBytes: spec.downloadSizeBytes, signal: options.signal, + onProgress: (receivedBytes, totalBytes) => + options.onProgress?.({ phase: "downloading", receivedBytes, totalBytes }), }); } + options.onProgress?.({ phase: "extracting" }); logger.info( { @@ -213,6 +231,16 @@ export async function ensureSherpaOnnxModel( } } +export async function deleteSherpaOnnxModel( + modelsDir: string, + modelId: SherpaOnnxModelId, +): Promise { + const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths(modelsDir, modelId); + await rm(modelDir, { recursive: true, force: true }); + await rm(archivePath, { force: true }); + await rm(extractionDir, { recursive: true, force: true }); +} + export async function ensureSherpaOnnxModels(options: { modelsDir: string; modelIds: SherpaOnnxModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts index cfde73addf..5137f51810 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; +import path from "node:path"; import type pino from "pino"; +import { engineFiles, type SttEngine } from "./model-catalog.js"; import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js"; function assertFileExists(filePath: string, label: string): void { @@ -9,16 +11,43 @@ function assertFileExists(filePath: string, label: string): void { } } -export interface SherpaOfflineRecognizerModel { - kind: "nemo_transducer"; - encoder: string; - decoder: string; - joiner: string; - tokens: string; +// Each engine family takes its own block under sherpa's modelConfig. +function buildModelConfig(modelDir: string, engine: SttEngine): Record { + const file = (name: string) => path.join(modelDir, name); + switch (engine.family) { + case "transducer": + return { + transducer: { + encoder: file(engine.encoder), + decoder: file(engine.decoder), + joiner: file(engine.joiner), + }, + modelType: "nemo_transducer", + }; + case "whisper": + // An empty language makes Whisper detect the spoken language. + return { + whisper: { + encoder: file(engine.encoder), + decoder: file(engine.decoder), + language: "", + task: "transcribe", + }, + }; + case "sense_voice": + return { + senseVoice: { model: file(engine.model), language: "", useInverseTextNormalization: 1 }, + }; + case "dolphin": + return { dolphin: { model: file(engine.model) } }; + case "nemo_ctc": + return { nemoCtc: { model: file(engine.model) } }; + } } export interface SherpaOfflineRecognizerConfig { - model: SherpaOfflineRecognizerModel; + modelDir: string; + engine: SttEngine; numThreads?: number; provider?: "cpu"; debug?: 0 | 1; @@ -54,10 +83,9 @@ export class SherpaOfflineRecognizerEngine { component: "offline-recognizer", }); - assertFileExists(config.model.encoder, "offline encoder"); - assertFileExists(config.model.decoder, "offline decoder"); - assertFileExists(config.model.joiner, "offline joiner"); - assertFileExists(config.model.tokens, "tokens"); + for (const name of engineFiles(config.engine)) { + assertFileExists(path.join(config.modelDir, name), `${config.engine.family} model file`); + } const sherpa = loadSherpaOnnxNode(); @@ -67,13 +95,8 @@ export class SherpaOfflineRecognizerEngine { featureDim: config.featureDim ?? 80, }, modelConfig: { - transducer: { - encoder: config.model.encoder, - decoder: config.model.decoder, - joiner: config.model.joiner, - }, - tokens: config.model.tokens, - modelType: "nemo_transducer", + ...buildModelConfig(config.modelDir, config.engine), + tokens: path.join(config.modelDir, config.engine.tokens), numThreads: config.numThreads ?? 1, provider: config.provider ?? "cpu", debug: config.debug ?? 0, diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts index 06d4a5b41d..0ae51f86ee 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import pino from "pino"; +import { engineFiles, getLocalTtsEngine, type LocalTtsModelId } from "./model-catalog.js"; import { SherpaOnnxTTS } from "./sherpa-tts.js"; const directories: string[] = []; @@ -11,17 +12,28 @@ afterEach(() => { rmSync(directory, { recursive: true, force: true }); }); -function createTts(numThreads?: number, beforeGenerate?: () => Promise) { +function createTts( + numThreads?: number, + beforeGenerate?: () => Promise, + modelId: LocalTtsModelId = "kokoro-en-v0_19", +) { const modelDir = mkdtempSync(join(tmpdir(), "paseo-tts-")); directories.push(modelDir); - for (const name of ["model.onnx", "voices.bin", "tokens.txt"]) - writeFileSync(join(modelDir, name), ""); - mkdirSync(join(modelDir, "espeak-ng-data")); + // Data directories have no extension; everything else is a model file. + for (const name of engineFiles(getLocalTtsEngine(modelId))) { + if (name.includes(".")) writeFileSync(join(modelDir, name), ""); + else mkdirSync(join(modelDir, name)); + } let nativeConfig: unknown; const requests: unknown[] = []; const released: boolean[] = []; const tts = new SherpaOnnxTTS( - { preset: "kokoro-en-v0_19", modelDir, numThreads }, + { + modelId, + modelDir, + engine: getLocalTtsEngine(modelId), + numThreads, + }, pino({ level: "silent" }), () => ({ OfflineTts: class { @@ -138,4 +150,31 @@ describe("SherpaOnnxTTS", () => { expect(nativeConfig).not.toHaveProperty("provider"); }, ); + + it("loads Piper voices through the vits engine", () => { + const { nativeConfig } = createTts(undefined, undefined, "piper-de-thorsten"); + expect(nativeConfig).toMatchObject({ + model: { + vits: { + model: expect.stringMatching(/de_DE-thorsten-medium\.onnx$/), + tokens: expect.stringMatching(/tokens\.txt$/), + dataDir: expect.stringMatching(/espeak-ng-data$/), + }, + }, + }); + expect(nativeConfig).not.toHaveProperty("model.kokoro"); + }); + + it("gives multi-language Kokoro its lexicons, dictionary and number rules", () => { + const { nativeConfig } = createTts(undefined, undefined, "kokoro-int8-multi-lang-v1_1"); + expect(nativeConfig).toMatchObject({ + model: { + kokoro: { + lexicon: expect.stringMatching(/lexicon-us-en\.txt,.*lexicon-zh\.txt$/), + dictDir: expect.stringMatching(/dict$/), + }, + }, + ruleFsts: expect.stringMatching(/date-zh\.fst,.*phone-zh\.fst,.*number-zh\.fst$/), + }); + }); }); diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts index a078f81f51..12c86b8777 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts @@ -1,16 +1,17 @@ import type pino from "pino"; import { Readable } from "node:stream"; import { existsSync } from "node:fs"; +import path from "node:path"; import type { SpeechStreamResult, TextToSpeechProvider } from "../../../speech-provider.js"; import { chunkBuffer, float32ToPcm16le } from "../../../audio.js"; import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js"; - -export type SherpaTtsPreset = "kokoro-en-v0_19"; +import { engineFiles, type LocalTtsModelId, type TtsEngine } from "./model-catalog.js"; export interface SherpaTtsConfig { - preset: SherpaTtsPreset; + modelId: LocalTtsModelId; modelDir: string; + engine: TtsEngine; speakerId?: number; speed?: number; lengthScale?: number; @@ -23,6 +24,53 @@ function assertFileExists(filePath: string, label: string): void { } } +// Each engine family takes its own block under sherpa's TTS model config. +function buildTtsModelConfig( + modelDir: string, + engine: TtsEngine, + lengthScale: number, +): Record { + const file = (name: string) => path.join(modelDir, name); + switch (engine.family) { + case "kokoro": + return { + kokoro: { + model: file(engine.model), + voices: file(engine.voices), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lexicon: (engine.lexicons ?? []).map(file).join(","), + dictDir: engine.dictDir ? file(engine.dictDir) : "", + lengthScale, + }, + }; + case "kitten": + return { + kitten: { + model: file(engine.model), + voices: file(engine.voices), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lengthScale, + }, + }; + case "vits": + return { + vits: { + model: file(engine.model), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lengthScale, + }, + }; + } +} + +function ruleFsts(modelDir: string, engine: TtsEngine): string { + if (engine.family !== "kokoro" || !engine.ruleFsts) return ""; + return engine.ruleFsts.map((name) => path.join(modelDir, name)).join(","); +} + interface SherpaOfflineTtsNative { sampleRate?: number; generateAsync: (args: { @@ -59,39 +107,28 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { throw new Error("sherpa-onnx-node OfflineTts is unavailable"); } - const modelPath = `${config.modelDir}/model.onnx`; - const voicesPath = `${config.modelDir}/voices.bin`; - const tokensPath = `${config.modelDir}/tokens.txt`; - const dataDir = `${config.modelDir}/espeak-ng-data`; - - assertFileExists(modelPath, "TTS model"); - assertFileExists(voicesPath, "TTS voices"); - assertFileExists(tokensPath, "TTS tokens"); - assertFileExists(dataDir, "TTS espeak-ng dataDir"); + for (const name of engineFiles(config.engine)) { + assertFileExists(path.join(config.modelDir, name), `${config.engine.family} TTS file`); + } const modelConfig = { // The native parser reads these under model, despite the upstream JS typedef. numThreads: config.numThreads ?? 2, provider: "cpu", - kokoro: { - model: modelPath, - voices: voicesPath, - tokens: tokensPath, - dataDir, - lengthScale: config.lengthScale ?? 1.0, - }, + ...buildTtsModelConfig(config.modelDir, config.engine, config.lengthScale ?? 1.0), }; const offlineTtsConfig = { model: modelConfig, maxNumSentences: 1, + ruleFsts: ruleFsts(config.modelDir, config.engine), }; this.tts = new ( sherpa as unknown as { OfflineTts: new (config: unknown) => SherpaOfflineTtsNative } ).OfflineTts(offlineTtsConfig); this.logger.info( - { preset: config.preset, modelDir: config.modelDir }, + { modelId: config.modelId, modelDir: config.modelDir }, "Sherpa offline TTS initialized", ); } diff --git a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts index ab2eefef55..43ab8af01a 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts @@ -7,6 +7,7 @@ import pino from "pino"; import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./model-downloader.js"; import { createDaemonTestContext } from "../../../../test-utils/index.js"; import { parsePcm16MonoWav, wordSimilarity } from "../../../../test-utils/dictation-e2e.js"; +import { getLocalSttEngine, getLocalTtsEngine } from "./model-catalog.js"; import { SherpaOnnxTTS } from "./sherpa-tts.js"; import { SherpaOfflineRecognizerEngine } from "./sherpa-offline-recognizer.js"; import { SherpaOnnxParakeetSTT } from "./sherpa-parakeet-stt.js"; @@ -192,7 +193,12 @@ downloadTest( const ttsText = "This is a voice note."; const ttsModelDir = getSherpaOnnxModelDir(modelsDir, "kokoro-en-v0_19"); const tts = new SherpaOnnxTTS( - { preset: "kokoro-en-v0_19", modelDir: ttsModelDir, speakerId: 0 }, + { + modelId: "kokoro-en-v0_19", + modelDir: ttsModelDir, + engine: getLocalTtsEngine("kokoro-en-v0_19"), + speakerId: 0, + }, logger, ); const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText); @@ -206,13 +212,8 @@ downloadTest( const sttModelDir = getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8"); const engine = new SherpaOfflineRecognizerEngine( { - model: { - kind: "nemo_transducer", - encoder: `${sttModelDir}/encoder.int8.onnx`, - decoder: `${sttModelDir}/decoder.int8.onnx`, - joiner: `${sttModelDir}/joiner.int8.onnx`, - tokens: `${sttModelDir}/tokens.txt`, - }, + modelDir: sttModelDir, + engine: getLocalSttEngine("parakeet-tdt-0.6b-v2-int8"), numThreads: 2, debug: 0, }, diff --git a/packages/server/src/server/speech/providers/local/worker-process.ts b/packages/server/src/server/speech/providers/local/worker-process.ts index 957ee6aaa2..2afa3d336a 100644 --- a/packages/server/src/server/speech/providers/local/worker-process.ts +++ b/packages/server/src/server/speech/providers/local/worker-process.ts @@ -3,6 +3,7 @@ import pino from "pino"; import type { StreamingTranscriptionSession } from "../../speech-provider.js"; import type { TurnDetectionSession } from "../../turn-detection-provider.js"; import { getLocalSpeechModelDir, type LocalSttModelId, type LocalTtsModelId } from "./models.js"; +import { getLocalSttEngine, getLocalTtsEngine } from "./sherpa/model-catalog.js"; import { SherpaOfflineRecognizerEngine } from "./sherpa/sherpa-offline-recognizer.js"; import { SherpaOnnxParakeetSTT } from "./sherpa/sherpa-parakeet-stt.js"; import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-parakeet-realtime-session.js"; @@ -85,13 +86,8 @@ function getSttEngine( const modelDir = getLocalSpeechModelDir(config.modelsDir, modelId); const created = new SherpaOfflineRecognizerEngine( { - model: { - kind: "nemo_transducer", - encoder: `${modelDir}/encoder.int8.onnx`, - decoder: `${modelDir}/decoder.int8.onnx`, - joiner: `${modelDir}/joiner.int8.onnx`, - tokens: `${modelDir}/tokens.txt`, - }, + modelDir, + engine: getLocalSttEngine(modelId), numThreads: 2, debug: 0, }, @@ -125,8 +121,9 @@ function getTtsProvider(config: LocalSpeechWorkerConfig): SherpaOnnxTTS { const modelDir = getLocalSpeechModelDir(config.modelsDir, ttsModelId(config)); const created = new SherpaOnnxTTS( { - preset: ttsModelId(config), + modelId: ttsModelId(config), modelDir, + engine: getLocalTtsEngine(ttsModelId(config)), speakerId: config.voiceTtsSpeakerId, speed: config.voiceTtsSpeed, }, diff --git a/packages/server/src/server/speech/providers/openai/config.test.ts b/packages/server/src/server/speech/providers/openai/config.test.ts index 1f3433a347..dd465dd2f9 100644 --- a/packages/server/src/server/speech/providers/openai/config.test.ts +++ b/packages/server/src/server/speech/providers/openai/config.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "vitest"; - import { PersistedConfigSchema } from "../../../persisted-config.js"; import { resolveOpenAiSpeechConfig } from "./config.js"; import type { RequestedSpeechProviders } from "../../speech-types.js"; @@ -10,233 +9,139 @@ const ALL_OPENAI: RequestedSpeechProviders = { voiceStt: { provider: "openai", explicit: true }, voiceTts: { provider: "openai", explicit: true }, }; +function resolve(input: unknown, env: NodeJS.ProcessEnv = {}) { + return resolveOpenAiSpeechConfig({ + persisted: PersistedConfigSchema.parse(input), + env, + providers: ALL_OPENAI, + }); +} describe("resolveOpenAiSpeechConfig", () => { - test("treats empty OPENAI_API_KEY as unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ - env, - persisted, - providers: { - ...ALL_OPENAI, - dictationStt: { provider: "local", explicit: false }, - voiceStt: { provider: "local", explicit: false }, - voiceTts: { provider: "local", explicit: false }, + test("treats empty credentials as unset", () => { + expect(resolve({}, { OPENAI_API_KEY: "", OPENAI_STT_API_KEY: " " })).toBeUndefined(); + }); + test("uses endpoint env before global env and ignores empty fallbacks", () => { + const result = resolve( + {}, + { + OPENAI_API_KEY: " global ", + OPENAI_BASE_URL: " https://global/v1 ", + OPENAI_STT_API_KEY: " stt ", + OPENAI_STT_BASE_URL: " https://stt/v1 ", + OPENAI_TTS_API_KEY: " ", + OPENAI_TTS_BASE_URL: "", }, + ); + expect(result?.dictationStt).toEqual({ apiKey: "stt", baseUrl: "https://stt/v1" }); + expect(result?.stt).toEqual({ apiKey: "stt", baseUrl: "https://stt/v1" }); + expect(result?.tts).toEqual({ + apiKey: "global", + baseUrl: "https://global/v1", + model: "tts-1", + voice: "alloy", + responseFormat: "pcm", }); - - expect(resolved).toBeUndefined(); }); - - test("applies trimmed OPENAI_API_KEY to both STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: " sk-test ", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("sk-test"); - expect(resolved?.tts?.apiKey).toBe("sk-test"); - }); - - test("resolves distinct endpoints for STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { - apiKey: "stt-key", - baseUrl: " https://stt.example.com/v1 ", + test("each site wins over endpoint and global env credentials and URL", () => { + const result = resolve( + { + features: { + dictation: { + stt: { + provider: "openai", + apiKey: "dictation", + baseUrl: "https://dictation/v1", + model: "dictation-model", + confidenceThreshold: -1, + }, }, - tts: { - apiKey: "tts-key", - baseUrl: " https://tts.example.com/v1 ", + voiceMode: { + stt: { + provider: "openai", + apiKey: "voice", + baseUrl: "https://voice/v1", + model: "voice-model", + confidenceThreshold: -2, + }, + tts: { + provider: "openai", + apiKey: "tts", + baseUrl: "https://tts/v1", + model: "kokoro-v1:Q8_0", + voice: "Aiden", + }, }, }, }, + { + OPENAI_API_KEY: "global", + OPENAI_BASE_URL: "https://global/v1", + OPENAI_STT_API_KEY: "endpoint", + OPENAI_STT_BASE_URL: "https://endpoint/v1", + OPENAI_TTS_API_KEY: "endpoint", + OPENAI_TTS_BASE_URL: "https://endpoint/v1", + }, + ); + expect(result?.dictationStt).toEqual({ + apiKey: "dictation", + baseUrl: "https://dictation/v1", + model: "dictation-model", + confidenceThreshold: -1, }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, + expect(result?.stt).toEqual({ + apiKey: "voice", + baseUrl: "https://voice/v1", + model: "voice-model", + confidenceThreshold: -2, + }); + expect(result?.tts).toEqual({ + apiKey: "tts", + baseUrl: "https://tts/v1", + model: "kokoro-v1:Q8_0", + voice: "Aiden", + responseFormat: "pcm", }); - - expect(resolved?.stt?.apiKey).toBe("stt-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1"); }); - - test("prefers nested STT/TTS config over env and global fallbacks", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - apiKey: "fallback-config-key", - baseUrl: "https://global-config.example.com/v1", - stt: { apiKey: "stt-config-key", baseUrl: " https://stt.example.com/v1 " }, - tts: { apiKey: "tts-config-key", baseUrl: " https://tts.example.com/v1 " }, + test("env overrides model, voice and threshold at every OpenAI site", () => { + const result = resolve( + { + features: { + dictation: { stt: { provider: "openai", model: "dictation", confidenceThreshold: -1 } }, + voiceMode: { + stt: { provider: "openai", model: "voice", confidenceThreshold: -2 }, + tts: { provider: "openai", model: "configured", voice: "Aiden" }, + }, }, }, - }); - const env = { - OPENAI_API_KEY: "env-key", - OPENAI_STT_API_KEY: "stt-env-key", - OPENAI_STT_BASE_URL: "https://stt-env.example.com/v1", - OPENAI_TTS_API_KEY: "tts-env-key", - OPENAI_TTS_BASE_URL: "https://tts-env.example.com/v1", - OPENAI_BASE_URL: "https://env.example.com/v1", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-config-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-config-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1"); - }); - - test("uses STT/TTS env config when nested config is unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "sk-test", - OPENAI_STT_API_KEY: "stt-env-key", - OPENAI_STT_BASE_URL: " https://stt-env.example.com/v1 ", - OPENAI_TTS_API_KEY: "tts-env-key", - OPENAI_TTS_BASE_URL: " https://tts-env.example.com/v1 ", - OPENAI_BASE_URL: "https://env.example.com/v1", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-env-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt-env.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-env-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts-env.example.com/v1"); - }); - - test("falls back to global OpenAI config for both STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - apiKey: "fallback-config-key", - baseUrl: " https://global-config.example.com/v1 ", - }, + { + OPENAI_API_KEY: "key", + STT_MODEL: "override", + TTS_MODEL: "tts-override", + TTS_VOICE: "Nova", + STT_CONFIDENCE_THRESHOLD: "-3", }, + ); + expect(result?.dictationStt).toEqual({ + apiKey: "key", + model: "override", + confidenceThreshold: -3, }); - const env = {} as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("fallback-config-key"); - expect(resolved?.stt?.baseUrl).toBe("https://global-config.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("fallback-config-key"); - expect(resolved?.tts?.baseUrl).toBe("https://global-config.example.com/v1"); + expect(result?.stt).toEqual({ apiKey: "key", model: "override", confidenceThreshold: -3 }); + expect(result?.tts).toMatchObject({ model: "tts-override", voice: "Nova" }); }); - - test("falls back to global OpenAI env config when feature inputs are unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "env-key", - OPENAI_BASE_URL: " https://env.example.com/v1 ", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("env-key"); - expect(resolved?.stt?.baseUrl).toBe("https://env.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("env-key"); - expect(resolved?.tts?.baseUrl).toBe("https://env.example.com/v1"); + test("only configures endpoints with credentials and ignores unused TTS options", () => { + const result = resolve( + { features: { dictation: { stt: { provider: "openai", apiKey: "dictation" } } } }, + { TTS_VOICE: "arbitrary", TTS_MODEL: "arbitrary" }, + ); + expect(result).toEqual({ dictationStt: { apiKey: "dictation" } }); }); - - test("ignores empty endpoint env vars and falls back to OPENAI_API_KEY", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "global-key", - OPENAI_STT_API_KEY: "", - OPENAI_STT_BASE_URL: " ", - OPENAI_TTS_API_KEY: "", - OPENAI_TTS_BASE_URL: "", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("global-key"); - expect(resolved?.tts?.apiKey).toBe("global-key"); - }); - - test("omits TTS when only an STT key is configured", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { apiKey: "stt-only-key" }, - }, - }, + test("does not borrow another site's key", () => { + const result = resolve({ + features: { voiceMode: { stt: { provider: "openai", apiKey: "voice-only" } } }, }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, - }); - - expect(resolved?.stt?.apiKey).toBe("stt-only-key"); - expect(resolved?.tts).toBeUndefined(); - }); - - test("resolves STT even when an unused TTS env var is invalid", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { apiKey: "stt-only-key" }, - }, - }, - }); - const env = { TTS_VOICE: "not-a-real-voice", TTS_MODEL: "bogus-model" } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-only-key"); - expect(resolved?.tts).toBeUndefined(); - }); - - test("sends the model and voice an OpenAI-compatible server serves, as written", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { tts: { apiKey: "tts-key", baseUrl: "https://tts.example.com/v1" } }, - }, - features: { - voiceMode: { tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "Aiden" } }, - }, - }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, - }); - - expect(resolved?.tts?.model).toBe("kokoro-v1:Q8_0"); - expect(resolved?.tts?.voice).toBe("Aiden"); - }); - - test("accepts OpenAI's own gpt-4o-mini-tts model", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { openai: { tts: { apiKey: "tts-key" } } }, - features: { voiceMode: { tts: { provider: "openai", model: "gpt-4o-mini-tts" } } }, - }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, - }); - - expect(resolved?.tts?.model).toBe("gpt-4o-mini-tts"); - expect(resolved?.tts?.voice).toBe("alloy"); + expect(result?.dictationStt).toBeUndefined(); + expect(result?.stt).toEqual({ apiKey: "voice-only" }); }); }); diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index efb25bffaf..37813571f4 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -6,147 +6,61 @@ import type { STTConfig } from "./stt.js"; import type { TTSConfig } from "./tts.js"; export const DEFAULT_OPENAI_TTS_MODEL = "tts-1"; +export type OpenAiSttFeature = "dictation" | "voiceMode"; export interface OpenAiSpeechProviderConfig { - stt?: Partial & { apiKey?: string }; - tts?: Partial & { apiKey?: string }; + dictationStt?: Partial; + stt?: Partial; + tts?: Partial; } -const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]); +const OptionalFiniteNumberSchema = z + .union([z.number(), z.string().trim().min(1)]) + .pipe(z.coerce.number().finite()) + .optional(); -const OptionalFiniteNumberSchema = NumberLikeSchema.pipe( - z.coerce.number().finite(), -).optional(); - -const OptionalTrimmedStringSchema = z - .string() - .trim() - .optional() - .transform((value) => (value && value.length > 0 ? value : undefined)); - -// Endpoint credentials only — plain trimmed strings, so this never throws on a -// malformed value. The STT/TTS option groups parse separately and only for the -// endpoint that is actually configured, so a stale env var for an unused endpoint -// (e.g. a leftover TTS_VOICE in an STT-only setup) can't break the other one. -const OpenAiEndpointKeysSchema = z.object({ - sttApiKey: OptionalTrimmedStringSchema, - sttBaseUrl: OptionalTrimmedStringSchema, - ttsApiKey: OptionalTrimmedStringSchema, - ttsBaseUrl: OptionalTrimmedStringSchema, -}); - -const OpenAiSttOptionsSchema = z.object({ - sttConfidenceThreshold: OptionalFiniteNumberSchema, - sttModel: OptionalTrimmedStringSchema, -}); - -// Voice and model are free-form so a self-hosted server can use its own ids; the -// OpenAI catalog is only the default. Both are sent as written, because the -// server owns those ids and their case (rosters use ids like kokoro-v1:Q8_0). -const OpenAiTtsOptionsSchema = z.object({ - ttsVoice: z.string().trim().default("alloy"), - ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL), -}); - -function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean { - return provider.enabled !== false && provider.provider === "openai"; +function firstString(...values: (string | undefined)[]): string | undefined { + return values.map((value) => value?.trim()).find((value) => Boolean(value)); } -function pickIfOpenAi( - provider: { enabled?: boolean; provider: string }, - value: T | undefined, -): T | undefined { - return isOpenAiProviderActive(provider) ? value : undefined; -} +type Features = NonNullable; +type SttSite = NonNullable["stt"]>; +type TtsSite = NonNullable["tts"]>; -function firstDefined(values: Array): T | undefined { - for (const value of values) { - if (value === undefined || value === null) { - continue; - } - // Empty/whitespace env vars (e.g. a copied .env.example with OPENAI_STT_API_KEY=) - // must not shadow a later fallback such as OPENAI_API_KEY. - if (typeof value === "string" && value.trim().length === 0) { - continue; - } - return value; - } - return undefined; -} - -function buildOpenAiSttInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env, persisted, providers } = params; +function resolveStt( + site: SttSite | undefined, + env: NodeJS.ProcessEnv, +): Partial | undefined { + const config = site?.provider === "openai" ? site : undefined; + const apiKey = firstString(config?.apiKey, env.OPENAI_STT_API_KEY, env.OPENAI_API_KEY); + if (!apiKey) return undefined; + const baseUrl = firstString(config?.baseUrl, env.OPENAI_STT_BASE_URL, env.OPENAI_BASE_URL); + const model = firstString(env.STT_MODEL, config?.model); + const confidenceThreshold = OptionalFiniteNumberSchema.parse( + firstString(env.STT_CONFIDENCE_THRESHOLD) ?? config?.confidenceThreshold, + ); return { - sttConfidenceThreshold: firstDefined([ - env.STT_CONFIDENCE_THRESHOLD, - persisted.features?.dictation?.stt?.confidenceThreshold, - ]), - sttModel: firstDefined([ - env.STT_MODEL, - pickIfOpenAi(providers.voiceStt, persisted.features?.voiceMode?.stt?.model), - pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model), - ]), + apiKey, + ...(baseUrl ? { baseUrl } : {}), + ...(model ? { model } : {}), + ...(confidenceThreshold !== undefined ? { confidenceThreshold } : {}), }; } -function buildOpenAiTtsInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env, persisted, providers } = params; +function resolveTts( + site: TtsSite | undefined, + env: NodeJS.ProcessEnv, +): Partial | undefined { + const config = site?.provider === "openai" ? site : undefined; + const apiKey = firstString(config?.apiKey, env.OPENAI_TTS_API_KEY, env.OPENAI_API_KEY); + if (!apiKey) return undefined; + const baseUrl = firstString(config?.baseUrl, env.OPENAI_TTS_BASE_URL, env.OPENAI_BASE_URL); return { - ttsVoice: firstDefined([ - env.TTS_VOICE, - pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.voice), - "alloy", - ]), - ttsModel: firstDefined([ - env.TTS_MODEL, - pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.model), - DEFAULT_OPENAI_TTS_MODEL, - ]), - }; -} - -function buildOpenAiResolutionInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env } = params; - const openai = params.persisted.providers?.openai; - return { - sttApiKey: firstDefined([ - openai?.stt?.apiKey, - env.OPENAI_STT_API_KEY, - openai?.apiKey, - env.OPENAI_API_KEY, - ]), - sttBaseUrl: firstDefined([ - openai?.stt?.baseUrl, - env.OPENAI_STT_BASE_URL, - openai?.baseUrl, - env.OPENAI_BASE_URL, - ]), - ttsApiKey: firstDefined([ - openai?.tts?.apiKey, - env.OPENAI_TTS_API_KEY, - openai?.apiKey, - env.OPENAI_API_KEY, - ]), - ttsBaseUrl: firstDefined([ - openai?.tts?.baseUrl, - env.OPENAI_TTS_BASE_URL, - openai?.baseUrl, - env.OPENAI_BASE_URL, - ]), - ...buildOpenAiSttInput(params), - ...buildOpenAiTtsInput(params), + apiKey, + ...(baseUrl ? { baseUrl } : {}), + model: firstString(env.TTS_MODEL, config?.model) ?? DEFAULT_OPENAI_TTS_MODEL, + voice: firstString(env.TTS_VOICE, config?.voice) ?? "alloy", + responseFormat: "pcm", }; } @@ -155,46 +69,14 @@ export function resolveOpenAiSpeechConfig(params: { persisted: PersistedConfig; providers: RequestedSpeechProviders; }): OpenAiSpeechProviderConfig | undefined { - const input = buildOpenAiResolutionInput(params); - const keys = OpenAiEndpointKeysSchema.parse(input); - - if (!keys.sttApiKey && !keys.ttsApiKey) { - return undefined; - } - + const { env, persisted } = params; + const dictationStt = resolveStt(persisted.features?.dictation?.stt, env); + const stt = resolveStt(persisted.features?.voiceMode?.stt, env); + const tts = resolveTts(persisted.features?.voiceMode?.tts, env); + if (!dictationStt && !stt && !tts) return undefined; return { - ...(keys.sttApiKey ? { stt: buildSttConfig(keys.sttApiKey, keys.sttBaseUrl, input) } : {}), - ...(keys.ttsApiKey ? { tts: buildTtsConfig(keys.ttsApiKey, keys.ttsBaseUrl, input) } : {}), - }; -} - -function buildSttConfig( - apiKey: string, - baseUrl: string | undefined, - input: Record, -): OpenAiSpeechProviderConfig["stt"] { - const options = OpenAiSttOptionsSchema.parse(input); - return { - apiKey, - ...(baseUrl ? { baseUrl } : {}), - ...(options.sttConfidenceThreshold !== undefined - ? { confidenceThreshold: options.sttConfidenceThreshold } - : {}), - ...(options.sttModel ? { model: options.sttModel } : {}), - }; -} - -function buildTtsConfig( - apiKey: string, - baseUrl: string | undefined, - input: Record, -): OpenAiSpeechProviderConfig["tts"] { - const options = OpenAiTtsOptionsSchema.parse(input); - return { - apiKey, - ...(baseUrl ? { baseUrl } : {}), - voice: options.ttsVoice, - model: options.ttsModel, - responseFormat: "pcm", + ...(dictationStt ? { dictationStt } : {}), + ...(stt ? { stt } : {}), + ...(tts ? { tts } : {}), }; } diff --git a/packages/server/src/server/speech/providers/openai/runtime.test.ts b/packages/server/src/server/speech/providers/openai/runtime.test.ts index c947d86600..81b14ecaa0 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.test.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.test.ts @@ -1,6 +1,12 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; import pino from "pino"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; +import { mergeSpeechSettingsPatch } from "../../speech-settings.js"; +import { PersistedConfigSchema } from "../../../persisted-config.js"; +import type { SpeechToTextProvider } from "../../speech-provider.js"; +import { resolveOpenAiSpeechConfig } from "./config.js"; import { initializeOpenAiSpeechServices } from "./runtime.js"; import { OpenAISTT } from "./stt.js"; import { OpenAITTS } from "./tts.js"; @@ -16,6 +22,7 @@ describe("initializeOpenAiSpeechServices", () => { }, openaiConfig: { stt: { apiKey: "sk-test" }, + dictationStt: { apiKey: "sk-test" }, tts: { apiKey: "sk-test" }, }, existing: { @@ -32,3 +39,116 @@ describe("initializeOpenAiSpeechServices", () => { expect(services.ttsService).toBeInstanceOf(OpenAITTS); }); }); + +// An OpenAI-compatible transcription endpoint that records the model each request asks for. +async function startTranscriptionServer(): Promise<{ + server: Server; + baseUrl: string; + models: string[]; + keys: (string | undefined)[]; + paths: (string | undefined)[]; +}> { + const models: string[] = []; + const keys: (string | undefined)[] = []; + const paths: (string | undefined)[] = []; + const server = createServer((request, response) => { + let body = ""; + request.on("data", (chunk: Buffer) => { + body += chunk.toString("latin1"); + }); + request.on("end", () => { + const model = /name="model"\r\n\r\n([^\r]*)/.exec(body)?.[1]; + models.push(model ?? ""); + keys.push(request.headers.authorization); + paths.push(request.url); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ text: "hello" })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { server, baseUrl: `http://127.0.0.1:${port}/v1`, models, keys, paths }; +} + +async function transcribeOnce(stt: SpeechToTextProvider | null): Promise { + if (!stt) throw new Error("STT service was not created"); + const session = stt.createSession({ logger: pino({ level: "silent" }), language: "en" }); + const transcript = new Promise((resolve, reject) => { + session.on("transcript", (event) => { + if (event.isFinal) resolve(); + }); + session.on("error", (error) => + reject(error instanceof Error ? error : new Error(String(error))), + ); + }); + await session.connect(); + session.appendPcm16(Buffer.alloc(3200)); + session.commit(); + await transcript; + session.close(); +} + +describe("OpenAI speech-to-text models", () => { + let server: Server | null = null; + + afterEach(async () => { + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())); + server = null; + }); + + test("dictation and voice mode each transcribe with the model chosen for them", async () => { + const endpoint = await startTranscriptionServer(); + server = endpoint.server; + let persisted = PersistedConfigSchema.parse({ + features: { + dictation: { + stt: { + provider: "openai", + apiKey: "dictation-key", + baseUrl: `${endpoint.baseUrl}/dictation`, + model: "gpt-4o-mini-transcribe", + }, + }, + voiceMode: { + enabled: true, + stt: { + provider: "openai", + apiKey: "listening-key", + baseUrl: `${endpoint.baseUrl}/listening`, + model: "whisper-1", + }, + }, + }, + }); + persisted = mergeSpeechSettingsPatch(persisted, { + voiceMode: { stt: { provider: "openai", model: "whisper-1" } }, + }); + const providers = { + dictationStt: { provider: "openai", explicit: true }, + voiceTurnDetection: { provider: "local", explicit: false, enabled: false }, + voiceStt: { provider: "openai", explicit: true }, + voiceTts: { provider: "local", explicit: false }, + } as const; + + const services = initializeOpenAiSpeechServices({ + providers, + openaiConfig: resolveOpenAiSpeechConfig({ env: {}, persisted, providers }), + existing: { + turnDetectionService: null, + sttService: null, + ttsService: null, + dictationSttService: null, + }, + logger: pino({ level: "silent" }), + }); + await transcribeOnce(services.dictationSttService); + await transcribeOnce(services.sttService); + + expect(endpoint.models).toEqual(["gpt-4o-mini-transcribe", "whisper-1"]); + expect(endpoint.keys).toEqual(["Bearer dictation-key", "Bearer listening-key"]); + expect(endpoint.paths).toEqual([ + "/v1/dictation/audio/transcriptions", + "/v1/listening/audio/transcriptions", + ]); + }); +}); diff --git a/packages/server/src/server/speech/providers/openai/runtime.ts b/packages/server/src/server/speech/providers/openai/runtime.ts index a1231ddbe6..ce11c8879c 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.ts @@ -3,7 +3,11 @@ import type { Logger } from "pino"; import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech-provider.js"; import type { RequestedSpeechProviders } from "../../speech-types.js"; import type { TurnDetectionProvider } from "../../turn-detection-provider.js"; -import { DEFAULT_OPENAI_TTS_MODEL, type OpenAiSpeechProviderConfig } from "./config.js"; +import { + DEFAULT_OPENAI_TTS_MODEL, + type OpenAiSpeechProviderConfig, + type OpenAiSttFeature, +} from "./config.js"; import { OpenAISTT } from "./stt.js"; import { OpenAITTS } from "./tts.js"; @@ -33,7 +37,7 @@ function resolveOpenAiCredentials( return { openaiSttApiKey: sttApiKey, openaiTtsApiKey: openaiConfig?.tts?.apiKey, - openaiDictationApiKey: sttApiKey, + openaiDictationApiKey: openaiConfig?.dictationStt?.apiKey, }; } @@ -94,13 +98,16 @@ export function validateOpenAiCredentialRequirements(params: { } } -function createOpenAiStt( - apiKey: string, - openaiConfig: OpenAiSpeechProviderConfig | undefined, - logger: Logger, -): SpeechToTextProvider { - const { apiKey: _sttApiKey, ...sttConfig } = openaiConfig?.stt ?? {}; - return new OpenAISTT({ apiKey, ...sttConfig }, logger); +function createOpenAiStt(params: { + apiKey: string; + openaiConfig: OpenAiSpeechProviderConfig | undefined; + feature: OpenAiSttFeature; + logger: Logger; +}): SpeechToTextProvider { + const site = + params.feature === "dictation" ? params.openaiConfig?.dictationStt : params.openaiConfig?.stt; + const { apiKey: _sttApiKey, ...sttConfig } = site ?? {}; + return new OpenAISTT({ apiKey: params.apiKey, ...sttConfig }, params.logger); } function createOpenAiTts( @@ -154,7 +161,12 @@ export function initializeOpenAiSpeechServices(params: { logger.info("OpenAI speech provider initialized"); if (needsOpenAiStt && openAiCredentials.openaiSttApiKey) { - sttService = createOpenAiStt(openAiCredentials.openaiSttApiKey, openaiConfig, logger); + sttService = createOpenAiStt({ + apiKey: openAiCredentials.openaiSttApiKey, + openaiConfig, + feature: "voiceMode", + logger, + }); } if (needsOpenAiTts && openAiCredentials.openaiTtsApiKey) { @@ -162,11 +174,12 @@ export function initializeOpenAiSpeechServices(params: { } if (needsOpenAiDictation && openAiCredentials.openaiDictationApiKey) { - dictationSttService = createOpenAiStt( - openAiCredentials.openaiDictationApiKey, + dictationSttService = createOpenAiStt({ + apiKey: openAiCredentials.openaiDictationApiKey, openaiConfig, + feature: "dictation", logger, - ); + }); } } else if (needsAnyOpenAi) { // validateOpenAiCredentialRequirements already warned about missing credentials diff --git a/packages/server/src/server/speech/speech-config-resolver.test.ts b/packages/server/src/server/speech/speech-config-resolver.test.ts index 0ee9df888a..c83d2c45fa 100644 --- a/packages/server/src/server/speech/speech-config-resolver.test.ts +++ b/packages/server/src/server/speech/speech-config-resolver.test.ts @@ -6,7 +6,7 @@ import { PersistedConfigSchema } from "../persisted-config.js"; import { resolveSpeechConfig } from "./speech-config-resolver.js"; describe("resolveSpeechConfig", () => { - test("resolves local-first defaults without env overrides", () => { + test("resolves local-first defaults with voice mode off until turned on", () => { const paseoHome = "/tmp/paseo-home"; const persisted = PersistedConfigSchema.parse({}); const env = {} as NodeJS.ProcessEnv; @@ -26,17 +26,17 @@ describe("resolveSpeechConfig", () => { expect(result.speech.providers.voiceTurnDetection).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.providers.voiceStt).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.providers.voiceTts).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.local).toEqual({ modelsDir: path.join(paseoHome, "models", "local-speech"), @@ -61,13 +61,11 @@ describe("resolveSpeechConfig", () => { const persisted = PersistedConfigSchema.parse({ features: { voiceMode: { + enabled: true, turnDetection: { provider: "local" }, - stt: { provider: "openai", model: "gpt-4o-transcribe" }, + stt: { provider: "openai", apiKey: "persisted-key", model: "gpt-4o-transcribe" }, }, }, - providers: { - openai: { apiKey: "persisted-key" }, - }, }); const env = { PASEO_DICTATION_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8", @@ -130,40 +128,45 @@ describe("resolveSpeechConfig", () => { voice: "pt", }); expect(result.openai?.stt?.apiKey).toBe("persisted-key"); - expect(result.openai?.tts?.apiKey).toBe("persisted-key"); + expect(result.openai?.tts?.apiKey).toBe("env-key"); expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe"); }); - test("resolves STT language from env, settings, and voice-to-dictation fallback", () => { - const persisted = PersistedConfigSchema.parse({ - features: { - dictation: { - stt: { - language: "fr", + test.each(["local", "openai"])( + "resolves STT language from env, settings, and voice-to-dictation fallback (%s)", + (provider) => { + const persisted = PersistedConfigSchema.parse({ + features: { + dictation: { + stt: { + provider, + language: "fr", + }, + }, + voiceMode: { + stt: { + provider, + language: "de", + }, }, }, - voiceMode: { - stt: { - language: "de", - }, - }, - }, - }); + }); - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: { - PASEO_DICTATION_LANGUAGE: "es", - PASEO_VOICE_LANGUAGE: " ", - } as NodeJS.ProcessEnv, - persisted, - }); + const result = resolveSpeechConfig({ + paseoHome: "/tmp/paseo-home", + env: { + PASEO_DICTATION_LANGUAGE: "es", + PASEO_VOICE_LANGUAGE: " ", + } as NodeJS.ProcessEnv, + persisted, + }); - expect(result.speech.sttLanguages).toEqual({ - dictation: "es", - voice: "es", - }); - }); + expect(result.speech.sttLanguages).toEqual({ + dictation: "es", + voice: "es", + }); + }, + ); test("respects disabled dictation and voice mode feature flags", () => { const persisted = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/speech/speech-config-resolver.ts b/packages/server/src/server/speech/speech-config-resolver.ts index 95c78707a5..1257f46d4d 100644 --- a/packages/server/src/server/speech/speech-config-resolver.ts +++ b/packages/server/src/server/speech/speech-config-resolver.ts @@ -43,8 +43,13 @@ const RequestedSpeechProvidersSchema = z.object({ voiceTts: OptionalSpeechProviderSchema.default("local"), }); -function resolveOptionalBooleanFlag(value: unknown): boolean { - return OptionalBooleanFlagSchema.parse(value) ?? true; +// Dictation is on unless turned off. Voice mode is opt-in: it downloads its own models and +// runs a hidden agent, so a host only pays for it once someone turns it on. +export const DICTATION_ENABLED_BY_DEFAULT = true; +export const VOICE_MODE_ENABLED_BY_DEFAULT = false; + +function resolveOptionalBooleanFlag(value: unknown, fallback: boolean): boolean { + return OptionalBooleanFlagSchema.parse(value) ?? fallback; } interface FeatureProviderInputs { @@ -70,6 +75,7 @@ function buildFeatureProviderInputs(params: { params.env.PASEO_VOICE_MODE_ENABLED, params.persisted.features?.voiceMode?.enabled, ]), + VOICE_MODE_ENABLED_BY_DEFAULT, ); return { dictationStt: { @@ -82,6 +88,7 @@ function buildFeatureProviderInputs(params: { params.env.PASEO_DICTATION_ENABLED, params.persisted.features?.dictation?.enabled, ]), + DICTATION_ENABLED_BY_DEFAULT, ), }, voiceTurnDetection: { @@ -119,7 +126,7 @@ function buildRequestedFeatureProvider( }; } -function resolveRequestedSpeechProviders(params: { +export function resolveRequestedSpeechProviders(params: { env: NodeJS.ProcessEnv; persisted: PersistedConfig; }): RequestedSpeechProviders { @@ -173,6 +180,7 @@ export function resolveSpeechConfig(params: { openai, speech: { providers, + localModelsDir: local.modelsDir, sttLanguages: local.sttLanguages, ...(local.local ? { local: local.local } : {}), }, diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index c9c21b3e89..cd3580e64a 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -2,13 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import pino from "pino"; import type { PaseoSpeechConfig } from "../bootstrap.js"; +import type { LocalSpeechModelId } from "./providers/local/models.js"; import type { InitializedLocalSpeech } from "./providers/local/runtime.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js"; import type { TurnDetectionProvider } from "./turn-detection-provider.js"; -import { createSpeechService } from "./speech-runtime.js"; +import { createSpeechService, LocalSpeechModelInUseError } from "./speech-runtime.js"; -const { ensureLocalSpeechModelsMock, initializeLocalSpeechServicesMock } = vi.hoisted(() => ({ - ensureLocalSpeechModelsMock: vi.fn(async () => ({})), +const { ensureSherpaOnnxModelMock, initializeLocalSpeechServicesMock } = vi.hoisted(() => ({ + ensureSherpaOnnxModelMock: vi.fn(async (_options: { signal?: AbortSignal }) => ""), initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise>(), })); @@ -34,9 +35,10 @@ vi.mock("./providers/openai/runtime.js", () => ({ validateOpenAiCredentialRequirements: () => {}, })); -vi.mock("./providers/local/models.js", () => ({ - ensureLocalSpeechModels: ensureLocalSpeechModelsMock, - listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds), +vi.mock("./providers/local/sherpa/model-downloader.js", () => ({ + ensureSherpaOnnxModel: ensureSherpaOnnxModelMock, + isSherpaOnnxModelInstalled: vi.fn(async () => false), + deleteSherpaOnnxModel: vi.fn(async () => {}), })); function createStubStt(id: string): SpeechToTextProvider { @@ -75,6 +77,7 @@ function rejectWhenAborted(signal?: AbortSignal): Promise { function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpeechConfig { return { providers, + localModelsDir: "/tmp/missing-local-speech-models", sttLanguages: { dictation: "en", voice: "en", @@ -84,8 +87,8 @@ function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpe describe("createSpeechService readiness", () => { beforeEach(() => { - ensureLocalSpeechModelsMock.mockReset(); - ensureLocalSpeechModelsMock.mockResolvedValue({}); + ensureSherpaOnnxModelMock.mockReset(); + ensureSherpaOnnxModelMock.mockResolvedValue(""); initializeLocalSpeechServicesMock.mockReset(); }); @@ -169,7 +172,7 @@ describe("createSpeechService readiness", () => { it("aborts and joins an in-flight model download when stopped", async () => { let downloadSignal: AbortSignal | undefined; - ensureLocalSpeechModelsMock.mockImplementation(({ signal }: { signal?: AbortSignal }) => { + ensureSherpaOnnxModelMock.mockImplementation(({ signal }: { signal?: AbortSignal }) => { downloadSignal = signal; return rejectWhenAborted(signal); }); @@ -201,10 +204,104 @@ describe("createSpeechService readiness", () => { }); runtime.start(); await runtime.ready; - expect(ensureLocalSpeechModelsMock).toHaveBeenCalledOnce(); + expect(ensureSherpaOnnxModelMock).toHaveBeenCalledOnce(); await runtime.stop(); expect(downloadSignal?.aborted).toBe(true); }); }); + +describe("createSpeechService settings changes", () => { + const dictationOnly = createSpeechConfig({ + dictationStt: { provider: "local", enabled: true, explicit: true }, + voiceTurnDetection: { provider: "local", enabled: false, explicit: true }, + voiceStt: { provider: "local", enabled: false, explicit: true }, + voiceTts: { provider: "local", enabled: false, explicit: true }, + }); + const nothingEnabled = createSpeechConfig({ + dictationStt: { provider: "local", enabled: false, explicit: true }, + voiceTurnDetection: { provider: "local", enabled: false, explicit: true }, + voiceStt: { provider: "local", enabled: false, explicit: true }, + voiceTts: { provider: "local", enabled: false, explicit: true }, + }); + + function localSpeech(input: { + dictationStt: SpeechToTextProvider | null; + defaultModelIds: LocalSpeechModelId[]; + }): InitializedLocalSpeech { + return { + turnDetectionService: null, + sttService: null, + ttsService: null, + dictationSttService: input.dictationStt, + localVoiceTtsProvider: null, + localModelConfig: { + modelsDir: "/tmp/missing-local-speech-models", + defaultModelIds: input.defaultModelIds, + }, + availability: { configured: true, modelsDir: "/tmp/missing-local-speech-models" }, + cleanup: () => {}, + }; + } + + beforeEach(() => { + ensureSherpaOnnxModelMock.mockReset(); + ensureSherpaOnnxModelMock.mockResolvedValue(""); + initializeLocalSpeechServicesMock.mockReset(); + }); + + it("applies new providers without a restart", async () => { + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ dictationStt: createStubStt("dictation-local"), defaultModelIds: [] }), + ); + const runtime = createSpeechService({ + logger: pino({ level: "silent" }), + speechConfig: dictationOnly, + }); + runtime.start(); + await runtime.ready; + expect(runtime.getReadiness().dictation.available).toBe(true); + + const disabled = new Promise((resolve) => { + runtime.onReadinessChange((snapshot) => { + if (snapshot.dictation.reasonCode === "disabled") resolve(); + }); + }); + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ dictationStt: null, defaultModelIds: [] }), + ); + runtime.reconfigure({ speechConfig: nothingEnabled }); + await disabled; + + expect(runtime.resolveDictationStt()).toBeNull(); + await runtime.stop(); + }); + + it("refuses to delete a model an enabled feature uses", async () => { + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ + dictationStt: createStubStt("dictation-local"), + defaultModelIds: ["parakeet-tdt-0.6b-v2-int8"], + }), + ); + ensureSherpaOnnxModelMock.mockImplementation(({ signal }) => rejectWhenAborted(signal)); + const runtime = createSpeechService({ + logger: pino({ level: "silent" }), + speechConfig: dictationOnly, + }); + runtime.start(); + await runtime.ready; + + const models = await runtime.listLocalModels(); + expect(models.filter((model) => model.inUse).map((model) => model.spec.id)).toEqual([ + "parakeet-tdt-0.6b-v2-int8", + ]); + await expect(runtime.deleteLocalModel("parakeet-tdt-0.6b-v2-int8")).rejects.toBeInstanceOf( + LocalSpeechModelInUseError, + ); + await expect(runtime.deleteLocalModel("kokoro-int8-en-v0_19")).resolves.toBeUndefined(); + + await runtime.stop(); + }); +}); diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 932f87c077..270b13b03c 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -2,7 +2,10 @@ import type { Logger } from "pino"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { LocalSpeechModelId } from "./providers/local/config.js"; -import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js"; +import { + LocalSpeechModelLibrary, + type LocalSpeechModelEntry, +} from "./providers/local/model-library.js"; import { initializeLocalSpeechServices } from "./providers/local/runtime.js"; import { getOpenAiSpeechAvailability, @@ -92,7 +95,7 @@ function buildRealtimeVoiceReadiness(params: { enabled: false, available: false, reasonCode: "disabled", - message: "Realtime voice is disabled in daemon config.", + message: "Voice mode is off. Turn it on in Settings > Voice.", retryable: false, missingModelIds: [], }; @@ -147,7 +150,7 @@ function buildDictationReadiness(params: { enabled: false, available: false, reasonCode: "disabled", - message: "Dictation is disabled in daemon config.", + message: "Dictation is off. Turn it on in Settings > Voice.", retryable: false, missingModelIds: [], }; @@ -292,6 +295,23 @@ function resolveEffectiveProviderIds(params: { }; } +export interface LocalSpeechModelStatus extends LocalSpeechModelEntry { + // Selected by an enabled feature, so the runtime keeps it installed. + inUse: boolean; +} + +export class LocalSpeechModelInUseError extends Error { + constructor(public readonly modelId: LocalSpeechModelId) { + super(`${modelId} is selected for dictation or voice mode. Choose another model first.`); + this.name = "LocalSpeechModelInUseError"; + } +} + +export interface SpeechServiceConfig { + openaiConfig?: PaseoOpenAIConfig; + speechConfig?: PaseoSpeechConfig; +} + export interface SpeechService { resolveStt: () => SpeechToTextProvider | null; resolveSttLanguage: () => string; @@ -301,37 +321,49 @@ export interface SpeechService { resolveDictationSttLanguage: () => string; getReadiness: () => SpeechReadinessSnapshot; onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void; + // Applies new provider/model settings without restarting the daemon. + reconfigure: (config: SpeechServiceConfig) => void; + listLocalModels: () => Promise; + downloadLocalModel: (modelId: LocalSpeechModelId) => void; + deleteLocalModel: (modelId: LocalSpeechModelId) => Promise; start: () => void; stop: () => Promise; ready: Promise; } -export function createSpeechService(params: { - logger: Logger; - openaiConfig?: PaseoOpenAIConfig; - speechConfig?: PaseoSpeechConfig; -}): SpeechService { +export function createSpeechService( + params: SpeechServiceConfig & { + logger: Logger; + }, +): SpeechService { const logger = params.logger.child({ module: "speech-runtime" }); - const speechConfig = params.speechConfig ?? null; - const openaiConfig = params.openaiConfig; - const providers = resolveRequestedSpeechProviders(speechConfig); - const requestedProviders = describeRequestedProviders(providers); + let speechConfig = params.speechConfig ?? null; + let openaiConfig = params.openaiConfig; + let providers = resolveRequestedSpeechProviders(speechConfig); + let requestedProviders = describeRequestedProviders(providers); + // The models directory is fixed for the daemon's lifetime; settings changes only pick + // which of its models are used. + const modelLibrary = speechConfig + ? new LocalSpeechModelLibrary(speechConfig.localModelsDir, logger) + : null; - validateOpenAiCredentialRequirements({ - providers, - openaiConfig, - logger, - }); - - logger.info( - { - requestedProviders, - availability: { - openai: getOpenAiSpeechAvailability(openaiConfig), + const logReconciliationStart = (): void => { + validateOpenAiCredentialRequirements({ + providers, + openaiConfig, + logger, + }); + logger.info( + { + requestedProviders, + availability: { + openai: getOpenAiSpeechAvailability(openaiConfig), + }, }, - }, - "Speech provider reconciliation started", - ); + "Speech provider reconciliation started", + ); + }; + logReconciliationStart(); let sttService: SpeechToTextProvider | null = null; let ttsService: TextToSpeechProvider | null = null; @@ -346,12 +378,11 @@ export function createSpeechService(params: { let missingLocalModelIds: LocalSpeechModelId[] = []; let backgroundDownloadInProgress = false; - let backgroundDownloadAbortController: AbortController | null = null; let backgroundDownloadPromise: Promise | null = null; let backgroundDownloadError: string | null = null; let stopped = false; let monitorTimeout: ReturnType | null = null; - let reconcileInFlight: Promise | null = null; + let reconcileQueue: Promise = Promise.resolve(); const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>(); let lastReadinessFingerprint: string | null = null; let lastPublishedReadinessSnapshot: SpeechReadinessSnapshot | null = null; @@ -445,12 +476,10 @@ export function createSpeechService(params: { }; const refreshMissingLocalModels = async (): Promise => { - missingLocalModelIds = localModelConfig - ? await listMissingLocalSpeechModels({ - modelsDir: localModelConfig.modelsDir, - modelIds: localModelConfig.defaultModelIds, - }) - : []; + missingLocalModelIds = + localModelConfig && modelLibrary + ? await modelLibrary.listMissing(localModelConfig.defaultModelIds) + : []; }; const reconcileServices = async (): Promise => { @@ -520,16 +549,12 @@ export function createSpeechService(params: { } }; + // Reconciles run one after another, and each starts after it was requested, so a + // reconfigure that lands mid-reconcile is applied instead of joining the stale run. const runReconcile = async (): Promise => { - if (reconcileInFlight) { - await reconcileInFlight; - publishReadinessIfChanged(); - return; - } - reconcileInFlight = reconcileServices().finally(() => { - reconcileInFlight = null; - }); - await reconcileInFlight; + const next = reconcileQueue.catch(() => undefined).then(reconcileServices); + reconcileQueue = next; + await next; publishReadinessIfChanged(); }; @@ -544,12 +569,12 @@ export function createSpeechService(params: { }; const startBackgroundDownload = (): void => { - if (stopped || backgroundDownloadInProgress) { + if (stopped || backgroundDownloadInProgress || !modelLibrary) { return; } - const modelsDir = localModelConfig?.modelsDir ?? null; + const modelsDir = modelLibrary.getModelsDir(); const modelIds = [...missingLocalModelIds]; - if (!modelsDir || modelIds.length === 0) { + if (modelIds.length === 0) { return; } @@ -565,16 +590,9 @@ export function createSpeechService(params: { "Starting background download for missing local speech models", ); - const abortController = new AbortController(); - backgroundDownloadAbortController = abortController; backgroundDownloadPromise = (async () => { try { - await ensureLocalSpeechModels({ - modelsDir, - modelIds, - logger, - signal: abortController.signal, - }); + await Promise.all(modelIds.map((modelId) => modelLibrary.download(modelId))); await runReconcile(); backgroundDownloadError = null; } catch (error) { @@ -588,7 +606,6 @@ export function createSpeechService(params: { "Background local speech model download failed", ); } finally { - backgroundDownloadAbortController = null; backgroundDownloadInProgress = false; await refreshMissingLocalModels().catch((error) => { logger.warn({ err: error }, "Failed to refresh local speech model status after download"); @@ -630,6 +647,62 @@ export function createSpeechService(params: { } }; + const reconcileAndFetchMissingModels = async (): Promise => { + await runReconcile(); + const snapshot = computeReadinessSnapshot(); + if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { + if (missingLocalModelIds.length > 0) { + startBackgroundDownload(); + } + scheduleMonitor(); + } + }; + + const reconfigure = (next: SpeechServiceConfig): void => { + speechConfig = next.speechConfig ?? null; + openaiConfig = next.openaiConfig; + providers = resolveRequestedSpeechProviders(speechConfig); + requestedProviders = describeRequestedProviders(providers); + // A failure for the previous selection must not block downloading the new one. + backgroundDownloadError = null; + logReconciliationStart(); + if (!started || stopped) { + return; + } + void reconcileAndFetchMissingModels().catch((error) => { + logger.error({ err: error }, "Speech runtime failed to apply new configuration"); + }); + }; + + const requireModelLibrary = (): LocalSpeechModelLibrary => { + if (!modelLibrary) { + throw new Error("Local speech models are unavailable on this daemon"); + } + return modelLibrary; + }; + + const listLocalModels = async (): Promise => { + const inUse = new Set(localModelConfig?.defaultModelIds ?? []); + const entries = await requireModelLibrary().list(); + return entries.map(({ spec, state }) => ({ spec, state, inUse: inUse.has(spec.id) })); + }; + + const downloadLocalModel = (modelId: LocalSpeechModelId): void => { + void requireModelLibrary() + .download(modelId) + .then(() => runMonitorTick()) + .catch((error) => { + logger.warn({ err: error, modelId }, "Local speech model download failed"); + }); + }; + + const deleteLocalModel = async (modelId: LocalSpeechModelId): Promise => { + if (localModelConfig?.defaultModelIds.includes(modelId)) { + throw new LocalSpeechModelInUseError(modelId); + } + await requireModelLibrary().delete(modelId); + }; + const start = (): void => { if (started || stopped) { return; @@ -637,14 +710,7 @@ export function createSpeechService(params: { started = true; void (async () => { try { - await runReconcile(); - const snapshot = computeReadinessSnapshot(); - if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { - if (missingLocalModelIds.length > 0) { - startBackgroundDownload(); - } - scheduleMonitor(); - } + await reconcileAndFetchMissingModels(); if (!readySettled) { readySettled = true; resolveReady(); @@ -666,7 +732,7 @@ export function createSpeechService(params: { monitorTimeout = null; } localCleanup(); - backgroundDownloadAbortController?.abort(); + await modelLibrary?.stop(); await backgroundDownloadPromise?.catch(() => undefined); backgroundDownloadPromise = null; }; @@ -680,6 +746,10 @@ export function createSpeechService(params: { resolveDictationSttLanguage: () => speechConfig?.sttLanguages?.dictation ?? "en", getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(), onReadinessChange: subscribeSpeechReadiness, + reconfigure, + listLocalModels, + downloadLocalModel, + deleteLocalModel, start, stop, ready, diff --git a/packages/server/src/server/speech/speech-settings.ts b/packages/server/src/server/speech/speech-settings.ts new file mode 100644 index 0000000000..31995c46f6 --- /dev/null +++ b/packages/server/src/server/speech/speech-settings.ts @@ -0,0 +1,202 @@ +import type { SpeechSettings, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; + +import { + PersistedConfigSchema, + SpeechSttConfigSchema, + SpeechTtsConfigSchema, + type PersistedConfig, +} from "../persisted-config.js"; +import { resolveRequestedSpeechProviders, resolveSpeechConfig } from "./speech-config-resolver.js"; +import type { SpeechService } from "./speech-runtime.js"; +import { LocalSttModelIdSchema, LocalTtsModelIdSchema } from "./providers/local/models.js"; + +interface SpeechRead { + features: SpeechSettings; + envOverrides: Record; +} + +// Owns the per-site wire boundary: credential redaction, env provenance and patch +// semantics. The store never assembles or merges provider objects itself. +export interface SpeechSettingsOwner { + read(persisted: PersistedConfig): SpeechRead; + apply(persisted: PersistedConfig): void; +} + +export function createSpeechSettingsOwner(params: { + paseoHome: string; + env: NodeJS.ProcessEnv; + speechService: Pick; +}): SpeechSettingsOwner { + const { paseoHome, env, speechService } = params; + return { + read: (persisted) => readSpeechSettings(persisted, env), + apply: (persisted) => { + const resolved = resolveSpeechConfig({ paseoHome, env, persisted }); + speechService.reconfigure({ openaiConfig: resolved.openai, speechConfig: resolved.speech }); + }, + }; +} + +export function readSpeechSettings(persisted: PersistedConfig, env: NodeJS.ProcessEnv): SpeechRead { + const dictation = persisted.features?.dictation; + const voiceMode = persisted.features?.voiceMode; + const providers = resolveRequestedSpeechProviders({ persisted, env }); + const envOverrides: Record = {}; + const recordOverride = (path: string, names: string[], fallback?: unknown) => { + if (fallback !== undefined) return; + const name = names.find((candidate) => env[candidate]?.trim()); + if (name) envOverrides[path] = name; + }; + recordOverride("features.dictation.enabled", ["PASEO_DICTATION_ENABLED"]); + recordOverride("features.voiceMode.enabled", ["PASEO_VOICE_MODE_ENABLED"]); + recordOverride("features.voiceMode.turnDetection.provider", [ + "PASEO_VOICE_TURN_DETECTION_PROVIDER", + ]); + + function readSite( + site: Record | undefined, + provider: string, + path: string, + kind: "stt" | "tts", + providerEnv: string, + localModelEnv: string, + ) { + recordOverride(`${path}.provider`, [providerEnv]); + const configured = site?.provider === provider ? site : undefined; + const { apiKey, ...fields } = configured ?? {}; + const openaiModelEnv = kind === "stt" ? "STT_MODEL" : "TTS_MODEL"; + recordOverride(`${path}.model`, [provider === "openai" ? openaiModelEnv : localModelEnv]); + if (kind === "stt") { + const languageVars = + path === "features.dictation.stt" + ? ["PASEO_DICTATION_LANGUAGE"] + : ["PASEO_VOICE_LANGUAGE", "PASEO_DICTATION_LANGUAGE"]; + recordOverride(`${path}.language`, languageVars); + } + if (provider === "openai") { + for (const [field, suffix] of [ + ["apiKey", "API_KEY"], + ["baseUrl", "BASE_URL"], + ] as const) { + recordOverride( + `${path}.${field}`, + [`OPENAI_${kind.toUpperCase()}_${suffix}`, `OPENAI_${suffix}`], + configured?.[field], + ); + } + if (kind === "stt") { + recordOverride(`${path}.confidenceThreshold`, ["STT_CONFIDENCE_THRESHOLD"]); + } else recordOverride(`${path}.voice`, ["TTS_VOICE"]); + } else if (kind === "tts") { + recordOverride(`${path}.speakerId`, ["PASEO_VOICE_LOCAL_TTS_SPEAKER_ID"]); + recordOverride(`${path}.speed`, ["PASEO_VOICE_LOCAL_TTS_SPEED"]); + } + return { + ...fields, + provider, + ...(provider === "openai" + ? { hasApiKey: typeof apiKey === "string" && apiKey.trim().length > 0 } + : {}), + }; + } + + return { + features: { + dictation: { + enabled: providers.dictationStt.enabled !== false, + stt: readSite( + dictation?.stt, + providers.dictationStt.provider, + "features.dictation.stt", + "stt", + "PASEO_DICTATION_STT_PROVIDER", + "PASEO_DICTATION_LOCAL_STT_MODEL", + ), + }, + voiceMode: { + enabled: providers.voiceStt.enabled !== false, + ...(voiceMode?.llm ? { llm: voiceMode.llm } : {}), + ...(voiceMode?.turnDetection + ? { + turnDetection: { + ...voiceMode.turnDetection, + provider: providers.voiceTurnDetection.provider, + }, + } + : {}), + stt: readSite( + voiceMode?.stt, + providers.voiceStt.provider, + "features.voiceMode.stt", + "stt", + "PASEO_VOICE_STT_PROVIDER", + "PASEO_VOICE_LOCAL_STT_MODEL", + ), + tts: readSite( + voiceMode?.tts, + providers.voiceTts.provider, + "features.voiceMode.tts", + "tts", + "PASEO_VOICE_TTS_PROVIDER", + "PASEO_VOICE_LOCAL_TTS_MODEL", + ), + }, + }, + envOverrides, + }; +} + +function mergeFields(current: Record | undefined, patch: Record) { + const next = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + if (value === null || (typeof value === "string" && !value.trim())) delete next[key]; + else next[key] = typeof value === "string" ? value.trim() : value; + } + return next; +} + +function mergeSite( + current: Record | undefined, + patch: Record, + kind: "stt" | "tts", +) { + const providerChanged = + patch.provider !== undefined && patch.provider !== (current?.provider ?? "local"); + const next = mergeFields(providerChanged ? undefined : current, patch); + next.provider ??= "local"; + const site = (kind === "stt" ? SpeechSttConfigSchema : SpeechTtsConfigSchema).parse(next); + if (site.provider === "local" && site.model !== undefined) { + const schema = kind === "stt" ? LocalSttModelIdSchema : LocalTtsModelIdSchema; + if (!schema.safeParse(site.model).success) { + throw new Error( + `Unknown local ${kind === "stt" ? "speech-to-text" : "voice"} model: ${site.model}`, + ); + } + } + return site; +} + +export function mergeSpeechSettingsPatch( + persisted: PersistedConfig, + patch: SpeechSettingsPatch, +): PersistedConfig { + const features = { ...persisted.features }; + if (patch.dictation) { + const { stt, ...rest } = patch.dictation; + const next = mergeFields(features.dictation, rest); + if (stt) next.stt = mergeSite(features.dictation?.stt, stt, "stt"); + features.dictation = PersistedConfigSchema.shape.features.unwrap().shape.dictation.parse(next); + } + if (patch.voiceMode) { + const { stt, tts, llm, turnDetection, ...rest } = patch.voiceMode; + const next = mergeFields(features.voiceMode, rest); + if (stt) next.stt = mergeSite(features.voiceMode?.stt, stt, "stt"); + if (tts) next.tts = mergeSite(features.voiceMode?.tts, tts, "tts"); + if (llm) next.llm = mergeFields(features.voiceMode?.llm, llm); + if (turnDetection) + next.turnDetection = mergeFields(features.voiceMode?.turnDetection, turnDetection); + features.voiceMode = PersistedConfigSchema.shape.features.unwrap().shape.voiceMode.parse(next); + } + return { ...persisted, features }; +} diff --git a/packages/server/src/server/terminal-activity-route.test.ts b/packages/server/src/server/terminal-activity-route.test.ts index afc97d77e8..e047c56ab9 100644 --- a/packages/server/src/server/terminal-activity-route.test.ts +++ b/packages/server/src/server/terminal-activity-route.test.ts @@ -95,7 +95,13 @@ it("accepts terminalId and token reports through the route into the tracker", as command: process.execPath, args: [ "-e", - `require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`, + ` + const fs = require("node:fs"); + const envPath = ${JSON.stringify(envPath)}; + fs.writeFileSync(envPath + ".tmp", JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); + fs.renameSync(envPath + ".tmp", envPath); + setInterval(() => {}, 1000); + `, ], }); await waitForCondition(() => existsSync(envPath), 10000); diff --git a/packages/server/src/server/test-fixtures/reconciliation-registries.ts b/packages/server/src/server/test-fixtures/reconciliation-registries.ts new file mode 100644 index 0000000000..2d020ec23f --- /dev/null +++ b/packages/server/src/server/test-fixtures/reconciliation-registries.ts @@ -0,0 +1,117 @@ +import type pino from "pino"; +import { createPersistedProjectRecord } from "../workspace-registry.js"; +import type { + PersistedProjectRecord, + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from "../workspace-registry.js"; + +export function createTestRegistries() { + let archiveGate: { + started: ReturnType; + release: ReturnType; + } | null = null; + const holdNextArchive = () => { + archiveGate = { started: deferred(), release: deferred() }; + return archiveGate; + }; + const projects = new Map(); + const workspaces = new Map(); + + const projectRegistry: ProjectRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(projects.values()), + get: async (id: string) => projects.get(id) ?? null, + getOrCreateActiveByRoot: async (input) => { + const existing = Array.from(projects.values()).find( + (project) => !project.archivedAt && project.rootPath === input.rootPath, + ); + if (existing) return existing; + const record = createPersistedProjectRecord({ + projectId: `prj_${projects.size}`, + rootPath: input.rootPath, + kind: input.kind, + displayName: input.displayName, + createdAt: input.timestamp, + updatedAt: input.timestamp, + }); + projects.set(record.projectId, record); + return record; + }, + update: async (id, updater) => { + const current = projects.get(id); + if (!current) return null; + const updated = updater(current); + projects.set(id, updated); + return updated; + }, + upsert: async (record: PersistedProjectRecord) => { + projects.set(record.projectId, record); + }, + archive: async (id: string, archivedAt: string) => { + const existing = projects.get(id); + if (existing) { + projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); + } + }, + remove: async (id: string) => { + projects.delete(id); + }, + }; + + const workspaceRegistry: WorkspaceRegistry = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(workspaces.values()), + get: async (id: string) => workspaces.get(id) ?? null, + update: async (id, updater) => { + const existing = workspaces.get(id); + if (!existing) return null; + const updated = updater(existing); + workspaces.set(id, updated); + return updated; + }, + upsert: async (record: PersistedWorkspaceRecord) => { + workspaces.set(record.workspaceId, record); + }, + archive: async (id: string, archivedAt: string) => { + const gate = archiveGate; + archiveGate = null; + if (gate) { + gate.started.resolve(); + await gate.release.promise; + } + const existing = workspaces.get(id); + if (!existing || existing.archivedAt) return false; + workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); + return true; + }, + remove: async (id: string) => { + workspaces.delete(id); + }, + }; + + return { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive }; +} + +export function createTestLogger() { + const logger = { + child: () => logger, + trace: () => undefined, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }; + return logger as unknown as pino.Logger; +} + +export function deferred(): { promise: Promise; resolve(): void } { + let resolve!: () => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} diff --git a/packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts b/packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts new file mode 100644 index 0000000000..dd73ce38a8 --- /dev/null +++ b/packages/server/src/server/test-fixtures/reconciliation-responsiveness.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { createHook, executionAsyncResource } from "node:async_hooks"; +import { MessagePort } from "node:worker_threads"; +import path from "node:path"; +import { stat } from "node:fs/promises"; +import { setImmediate as nextTurn } from "node:timers/promises"; +import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; +import { createTestRegistries, createTestLogger } from "./reconciliation-registries.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, +} from "../workspace-registry.js"; +import { WorkspaceReconciliationService } from "../workspace-reconciliation-service.js"; +import { deriveProjectKey } from "../project-key.js"; + +const rootPath = process.argv[2]; +if (!rootPath) throw new Error("Project root is required"); +const timestamp = "2025-01-01T00:00:00.000Z"; +const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); +projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: deriveProjectKey({ + rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), +); +for (let i = 0; i < 20000; i++) { + const workspaceId = `w${i}`; + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); +} +const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), +}); + +// Compile before launch and measure in plain Node, outside the test runner's +// thread and TypeScript loader. Keep the first real reconciliation cold. +// Initialize async filesystem support using the bundle, leaving every project +// and workspace path untouched until the first reconciliation. +await stat(process.argv[1]); +// Collect fixture allocations before the measured run; allocations made by +// reconciliation itself remain part of its budget. +assert.ok(global.gc, "Launch the probe with --expose-gc"); +global.gc(); +await nextTurn(); +let workerMessages = 0; +const activity = createHook({ + before() { + if (executionAsyncResource() instanceof MessagePort) workerMessages++; + }, +}).enable(); +try { + const measured = await measureMainThreadWork("workspace reconciliation main-thread work", () => + service.runOnce(), + ); + activity.disable(); + process.stdout.write( + JSON.stringify({ + blockingMs: measured.blockingMs, + filesystemRequests: measured.filesystemRequests, + yields: measured.yields, + workerMessages, + changesApplied: measured.result.changesApplied.length, + allArchived: [...workspaces.values()].every((workspace) => workspace.archivedAt !== null), + }), + ); +} finally { + activity.disable(); + service.dispose(); +} 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 1c7127d144..4304a767e1 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -60,14 +60,19 @@ interface FakeAgentSessionOptions { closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise; + steerActiveTurn?: AgentSession["steerActiveTurn"]; + onInterrupt?: () => void; historyEvents?: readonly AgentStreamEvent[]; } export interface TestAgentClientOptions { beforeCreateSession?: (config: AgentSessionConfig) => Promise; + beforeResumeSession?: (handle: AgentPersistenceHandle) => Promise; closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise; + steerActiveTurn?: AgentSession["steerActiveTurn"]; + onInterrupt?: () => void; supportsMcpServers?: boolean; historyEvents?: readonly AgentStreamEvent[]; } @@ -340,6 +345,8 @@ class FakeAgentSession implements AgentSession { private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; + readonly steerActiveTurn: AgentSession["steerActiveTurn"]; + private readonly onInterrupt: (() => void) | undefined; private readonly closeSession: (() => Promise) | undefined; private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise) | undefined; @@ -354,6 +361,8 @@ class FakeAgentSession implements AgentSession { this.config = options.config; this.id = options.sessionId ?? randomUUID(); this.memoryMarker = options.memoryMarker ?? null; + this.steerActiveTurn = options.steerActiveTurn; + this.onInterrupt = options.onInterrupt; this.closeSession = options.closeSession; this.onStartTurn = options.onStartTurn; this.beforeTurnComplete = options.beforeTurnComplete; @@ -896,6 +905,7 @@ class FakeAgentSession implements AgentSession { } async interrupt(): Promise { + this.onInterrupt?.(); this.interruptSignal.resolve(); } @@ -1234,6 +1244,8 @@ class FakeAgentClient implements AgentClient { closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, beforeTurnComplete: this.options.beforeTurnComplete, + steerActiveTurn: this.options.steerActiveTurn, + onInterrupt: this.options.onInterrupt, historyEvents: this.options.historyEvents, }); } @@ -1243,6 +1255,7 @@ class FakeAgentClient implements AgentClient { overrides?: Partial, _launchContext?: AgentLaunchContext, ): Promise { + await this.options.beforeResumeSession?.(handle); const cfg: AgentSessionConfig = { provider: this.provider, cwd: overrides?.cwd ?? process.cwd(), @@ -1261,6 +1274,8 @@ class FakeAgentClient implements AgentClient { closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, beforeTurnComplete: this.options.beforeTurnComplete, + steerActiveTurn: this.options.steerActiveTurn, + onInterrupt: this.options.onInterrupt, historyEvents: this.options.historyEvents, }); } diff --git a/packages/server/src/server/test-utils/finish-subscription-daemon.ts b/packages/server/src/server/test-utils/finish-subscription-daemon.ts index d35bc55ec8..eafe11e74a 100644 --- a/packages/server/src/server/test-utils/finish-subscription-daemon.ts +++ b/packages/server/src/server/test-utils/finish-subscription-daemon.ts @@ -9,18 +9,24 @@ const root = process.argv[2]; if (!root || !process.send) throw new Error("Expected isolated test home and IPC parent"); await mkdir(join(root, "static"), { recursive: true }); const held = Promise.withResolvers(); +const fixture = JSON.parse(process.argv[4] ?? "{}") as { holdResume?: boolean }; +const resumeReleased = Promise.withResolvers(); +if (!fixture.holdResume) resumeReleased.resolve(); const daemon = await createTestPaseoDaemon({ paseoHomeRoot: root, + ...JSON.parse(process.argv[3] ?? "{}"), staticDir: join(root, "static"), logger: pino({ level: "warn" }, pino.destination(join(root, "fixture.log"))), cleanup: false, mcpEnabled: true, agentClients: createTestAgentClients({ - onStartTurn: (prompt) => { + onStartTurn: (prompt, config) => { + void appendFile(join(root, "all-prompts.jsonl"), `${JSON.stringify({ prompt, config })}\n`); if (String(prompt).includes('kind="agent-notification"')) { void appendFile(join(root, "parent-prompts.jsonl"), `${JSON.stringify(prompt)}\n`); } }, + beforeResumeSession: () => resumeReleased.promise, beforeTurnComplete: (prompt) => String(prompt).includes("HOLD_CHILD") ? held.promise : Promise.resolve(), }), @@ -28,6 +34,7 @@ const daemon = await createTestPaseoDaemon({ process.send({ port: daemon.port, paseoHome: daemon.paseoHome, cwd: daemon.staticDir }); process.on("message", (message) => { if (message === "finish") held.resolve(); + if (message === "release-resume") resumeReleased.resolve(); if (message === "stop") { void daemon.close().then( () => process.exit(0), diff --git a/packages/server/src/server/test-utils/paseo-daemon.test.ts b/packages/server/src/server/test-utils/paseo-daemon.test.ts index 4ee417c221..3fbb0b5232 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.test.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.test.ts @@ -45,7 +45,15 @@ describe("createTestPaseoDaemon", () => { createTestPaseoDaemon({ listenPort: takenPort, cleanup: false, mcpEnabled: false }), ).rejects.toMatchObject({ code: "EADDRINUSE" }); - expect(await readdir(scratchTmp)).toEqual([]); + // A home under this scratch dir is too long for a unix socket path, so the terminal worker + // listens in the per-user `paseo-` directory of the temp root. Like tmux's, that + // owner-only directory is shared by every worker and outlives them; it must be empty. + const sharedSocketDir = `paseo-${process.getuid?.() ?? 0}`; + const leftovers = await readdir(scratchTmp); + expect(leftovers.filter((name) => name !== sharedSocketDir)).toEqual([]); + if (leftovers.includes(sharedSocketDir)) { + expect(await readdir(path.join(scratchTmp, sharedSocketDir))).toEqual([]); + } }, 60_000); }); diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 289d556bec..766fd49919 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -39,7 +39,8 @@ interface TestPaseoDaemonOptions { staticDir?: string; cleanup?: boolean; openai?: PaseoOpenAIConfig; - speech?: PaseoSpeechConfig; + // localModelsDir defaults to the local provider's modelsDir, then to a dir under paseoHome. + speech?: Omit & { localModelsDir?: string }; voiceLlmProvider?: PaseoDaemonConfig["voiceLlmProvider"]; voiceLlmProviderExplicit?: boolean; voiceLlmModel?: string | null; @@ -51,6 +52,8 @@ interface TestPaseoDaemonOptions { trustedProxies?: PaseoDaemonConfig["trustedProxies"]; agentProfiles?: AgentProfile[]; autoArchiveAfterMerge?: boolean; + resumeOnStartup?: boolean; + resumePrompt?: string; pluginsEnabled?: PaseoDaemonConfig["pluginsEnabled"]; builtinPlugins?: BuiltinPluginLoader; plugins?: PaseoDaemonConfig["plugins"]; @@ -209,7 +212,15 @@ async function prepareTestDaemonConfig( webUi: options.webUi, trustedProxies: options.trustedProxies, openai: options.openai, - speech: options.speech, + speech: options.speech + ? { + ...options.speech, + localModelsDir: + options.speech.localModelsDir ?? + options.speech.local?.modelsDir ?? + path.join(paseoHome, "models", "local-speech"), + } + : undefined, voiceLlmProvider: options.voiceLlmProvider ?? null, voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false, voiceLlmModel: options.voiceLlmModel ?? null, @@ -217,6 +228,8 @@ async function prepareTestDaemonConfig( downloadTokenTtlMs: options.downloadTokenTtlMs, agentProfiles: options.agentProfiles, autoArchiveAfterMerge: options.autoArchiveAfterMerge, + resumeOnStartup: options.resumeOnStartup, + resumePrompt: options.resumePrompt, pluginsEnabled: options.pluginsEnabled, plugins: options.plugins, }; diff --git a/packages/server/src/server/utils/diff-highlighter.test.ts b/packages/server/src/server/utils/diff-highlighter.test.ts index fda98d6cbf..794cd00856 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -1,7 +1,11 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { setImmediate } from "node:timers/promises"; +import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; +import { SessionDelivery } from "../session/owned-subscriptions/index.js"; +import { highlightCode, highlightCodeWithBudget } from "@getpaseo/highlight"; import { parseDiff, reconstructNewFile, @@ -819,3 +823,139 @@ index 1111111..2222222 100644 } }); }); + +describe("diff highlighting budget", () => { + it.each(["supplied", "local"] as const)( + "keeps a one-line diff on a 5 MiB %s source responsive using hunk tokens", + async (source) => { + const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-budget-")); + const oldFileContent = "const value = Math.max(42, 7);\n".repeat( + Math.ceil((5 * 1024 * 1024) / 30), + ); + const newFileContent = oldFileContent.replace("42", "43"); + writeFileSync(join(cwd, "example.ts"), newFileContent); + const file = parseDiff(`diff --git a/example.ts b/example.ts +--- a/example.ts ++++ b/example.ts +@@ -1 +1 @@ +-const value = Math.max(42, 7); ++const value = Math.max(43, 7);`)[0]; + try { + const { result: highlighted, blockingMs } = await measureMainThreadWork( + `diff highlight 5MiB ${source} main-thread work`, + () => + highlightDiffWithFileContent( + file, + cwd, + source === "supplied" ? { oldFileContent } : {}, + ), + ); + expect(blockingMs).toBeLessThan(50); + expect(highlighted).toEqual(highlightDiffFromHunks(file)); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 30_000, + ); + + it("preserves byte-identical tokens below the budget, including multiline syntax", async () => { + const oldFileContent = "/*\nold comment\n*/\nconst value = 42;"; + const newFileContent = oldFileContent.replace("old", "new"); + const file = parseDiff(`diff --git a/example.ts b/example.ts +--- a/example.ts ++++ b/example.ts +@@ -2 +2 @@ +-old comment ++new comment`)[0]; + const highlighted = await highlightDiffWithFileContent(file, ".", { + oldFileContent, + newFileContent, + }); + expect(JSON.stringify(highlighted.hunks[0].lines[1].tokens)).toBe( + JSON.stringify(highlightCode(oldFileContent, file.path)[1]), + ); + expect(JSON.stringify(highlighted.hunks[0].lines[2].tokens)).toBe( + JSON.stringify(highlightCode(newFileContent, file.path)[1]), + ); + }); +}); + +it("disconnecting the requesting source stops a parser between highlight steps", async () => { + const delivery = new SessionDelivery(() => {}); + const source = {}; + delivery.attach(source, true); + const yielded = Promise.withResolvers(); + let result: Awaited> = []; + const pending = delivery.request(source, { type: "ping", requestId: "highlight" }, async () => { + result = await highlightCodeWithBudget( + "const value = Math.max(42, 7);\n".repeat(3_000), + "file.ts", + { + signal: delivery.requestSignal, + yieldToEventLoop: () => { + yielded.resolve(); + return setImmediate(); + }, + }, + ); + }); + await yielded.promise; + await delivery.detach(source); + await pending; + expect(result).toBeNull(); +}); + +it("keeps real session.ts diffs highlighted and byte-identical under the source budget", async () => { + const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-real-source-")); + try { + const oldFileContent = readFileSync(new URL("../session.ts", import.meta.url), "utf8"); + const newFileContent = oldFileContent.replace( + "WorkspaceRuntimeStore", + "WorkspaceRuntimeSnapshot", + ); + writeFileSync(join(cwd, "session.ts"), newFileContent); + const oldLine = oldFileContent.split("\n")[0]; + const newLine = newFileContent.split("\n")[0]; + const [file] = parseDiff(`diff --git a/session.ts b/session.ts +--- a/session.ts ++++ b/session.ts +@@ -1 +1 @@ +-${oldLine} ++${newLine}`); + const highlighted = await highlightDiffWithFileContent(file, cwd, { oldFileContent }); + const removed = highlighted.hunks[0].lines.find((line) => line.type === "remove"); + const added = highlighted.hunks[0].lines.find((line) => line.type === "add"); + expect(removed?.tokens).toEqual(highlightCode(oldFileContent, "session.ts")[0]); + expect(added?.tokens).toEqual(highlightCode(newFileContent, "session.ts")[0]); + expect(added?.tokens?.some((token) => token.style !== null)).toBe(true); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +it("highlights a one-line hunk from an over-budget JSON source without blocking the loop", async () => { + const cwd = mkdtempSync(join(tmpdir(), "diff-highlight-large-json-")); + const oldFileContent = `{\n${' "value": 42,\n'.repeat(100_000)} "last": true\n}`; + const newFileContent = oldFileContent.replace("42", "43"); + writeFileSync(join(cwd, "package-lock.json"), newFileContent); + const [file] = parseDiff(`diff --git a/package-lock.json b/package-lock.json +--- a/package-lock.json ++++ b/package-lock.json +@@ -2 +2 @@ +- "value": 42, ++ "value": 43,`); + try { + const { result, blockingMs } = await measureMainThreadWork( + "diff highlight large JSON main-thread work", + () => highlightDiffWithFileContent(file, cwd, { oldFileContent }), + ); + expect(blockingMs).toBeLessThan(50); + expect(result.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({ + text: "43", + style: "number", + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index 3c2df279e2..e3a0460322 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -1,6 +1,14 @@ -import { readFile } from "node:fs/promises"; +import { open } from "node:fs/promises"; +import { constants } from "node:fs"; +import { setImmediate } from "node:timers/promises"; import { resolve } from "node:path"; -import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight"; +import { + highlightCode, + highlightCodeWithBudget, + HIGHLIGHT_MAX_SOURCE_BYTES, + isLanguageSupported, + type HighlightToken, +} from "@getpaseo/highlight"; import { unquoteGitPath } from "../../utils/git-quoted-path.js"; const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000; @@ -30,11 +38,13 @@ export interface ParsedDiffFile { } interface HighlightDiffWithFileContentOptions { + signal?: AbortSignal; oldFileContent?: string | null; newFileContent?: string | null; } interface ParseAndHighlightDiffOptions { + signal?: AbortSignal; getOldFileContent?: (file: ParsedDiffFile) => Promise; getNewFileContent?: (file: ParsedDiffFile) => Promise; } @@ -269,20 +279,71 @@ function buildTokenLookup( return lookup; } -function buildFullFileTokenLookup( +async function buildFullFileTokenLookup( fileContent: string, path: string, -): Map | null { - if (hasOversizedLine(fileContent)) return null; + signal?: AbortSignal, +): Promise | null> { + const highlighted = await highlightCodeWithBudget(fileContent, path, { + yieldToEventLoop: setImmediate, + signal, + }); + if (!highlighted) return null; + return new Map(highlighted.map((tokens, index) => [index + 1, tokens])); +} - const lookup = new Map(); - const highlighted = highlightCode(fileContent, path); - - for (let i = 0; i < highlighted.length; i++) { - lookup.set(i + 1, highlighted[i]); +async function readHighlightSource(path: string): Promise { + let handle; + try { + handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size > HIGHLIGHT_MAX_SOURCE_BYTES) return null; + const buffer = Buffer.alloc(Math.min(stat.size, HIGHLIGHT_MAX_SOURCE_BYTES) + 1); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + // A full buffer includes the extra byte: the source grew after stat. + return offset === buffer.length ? null : buffer.toString("utf8", 0, offset); + } catch { + return null; + } finally { + await handle?.close(); } +} - return lookup; +function exceedsSourceBudget(content: string): boolean { + return ( + content.length > HIGHLIGHT_MAX_SOURCE_BYTES || + Buffer.byteLength(content) > HIGHLIGHT_MAX_SOURCE_BYTES + ); +} + +async function buildBudgetedReconstructedTokens(file: ParsedDiffFile, signal?: AbortSignal) { + const newLines = reconstructNewFile(file.hunks); + const oldLines = reconstructOldFile(file.hunks); + const build = async (lines: Map) => { + // Sparse hunks can be far apart. Admit the span before filling its gaps. + const numbers = Array.from(lines.keys()); + if (numbers.length && numbers[numbers.length - 1] - numbers[0] > HIGHLIGHT_MAX_SOURCE_BYTES) + return null; + let bytes = 0; + for (const line of lines.values()) { + bytes += Buffer.byteLength(line) + 1; + if (bytes > HIGHLIGHT_MAX_SOURCE_BYTES) return null; + } + const highlighted = await highlightCodeWithBudget(buildFileContent(lines), file.path, { + yieldToEventLoop: setImmediate, + signal, + }); + return highlighted ? buildTokenLookup(lines, highlighted) : null; + }; + const newTokensByLine = await build(newLines); + if (!newTokensByLine) return null; + const oldTokensByLine = await build(oldLines); + return oldTokensByLine ? { newTokensByLine, oldTokensByLine } : null; } function buildReconstructedTokenLookups(file: ParsedDiffFile): { @@ -328,36 +389,50 @@ export async function highlightDiffWithFileContent( cwd: string, options: HighlightDiffWithFileContentOptions = {}, ): Promise { - if (!isLanguageSupported(file.path) || hasOversizedDiffLine(file)) { - return file; - } + if (!isDiffHighlightEligible(file, options)) return file; - 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); - } 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. - } - } + const newContent = + typeof options.newFileContent === "string" + ? options.newFileContent + : await readHighlightSource(resolve(cwd, file.path)); + if (options.signal?.aborted) return file; - if (newTokensByLine && oldTokensByLine) - return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); - const reconstructedTokens = buildReconstructedTokenLookups(file); + const oldTokens = await highlightAvailableSource( + options.oldFileContent, + file.path, + options.signal, + ); + if (options.signal?.aborted) return file; + const newTokens = await highlightAvailableSource(newContent, file.path, options.signal); + if (options.signal?.aborted) return file; + if (oldTokens && newTokens) return applyTokensToHunks(file, newTokens, oldTokens); + const reconstructed = await buildBudgetedReconstructedTokens(file, options.signal); + if (!reconstructed || options.signal?.aborted) return file; return applyTokensToHunks( file, - newTokensByLine ?? reconstructedTokens.newTokensByLine, - oldTokensByLine ?? reconstructedTokens.oldTokensByLine, + newTokens ?? reconstructed.newTokensByLine, + oldTokens ?? reconstructed.oldTokensByLine, ); } +function isDiffHighlightEligible( + file: ParsedDiffFile, + options: HighlightDiffWithFileContentOptions, +): boolean { + return !options.signal?.aborted && isLanguageSupported(file.path) && !hasOversizedDiffLine(file); +} + +async function highlightAvailableSource( + content: string | null | undefined, + path: string, + signal?: AbortSignal, +): Promise | null> { + // Preserve the existing hunk-context fallback for oversized individual lines. + if (typeof content !== "string" || exceedsSourceBudget(content) || hasOversizedLine(content)) + return null; + return buildFullFileTokenLookup(content, path, signal); +} + function applyTokensToHunks( file: ParsedDiffFile, newTokensByLine: Map, @@ -406,20 +481,24 @@ export async function parseAndHighlightDiff( ): Promise { const files = parseDiff(diffText); - const highlightedFiles = await Promise.all( - files.map(async (file) => { - const [oldFileContent, newFileContent] = await Promise.all([ - options.getOldFileContent?.(file), - options.getNewFileContent?.(file), - ]); - - return highlightDiffWithFileContent(file, cwd, { + const highlightedFiles: ParsedDiffFile[] = []; + for (const file of files) { + if (options.signal?.aborted) { + highlightedFiles.push(file); + continue; + } + const [oldFileContent, newFileContent] = await Promise.all([ + options.getOldFileContent?.(file), + options.getNewFileContent?.(file), + ]); + highlightedFiles.push( + await highlightDiffWithFileContent(file, cwd, { oldFileContent: oldFileContent ?? undefined, newFileContent: newFileContent ?? undefined, - }); - }), - ); - + signal: options.signal, + }), + ); + } return highlightedFiles; } diff --git a/packages/server/src/server/watcher-liveness-canary.test.ts b/packages/server/src/server/watcher-liveness-canary.test.ts index 4da1481408..7e719da9f0 100644 --- a/packages/server/src/server/watcher-liveness-canary.test.ts +++ b/packages/server/src/server/watcher-liveness-canary.test.ts @@ -1,4 +1,5 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { watchFile, unwatchFile } from "node:fs"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test } from "vitest"; @@ -31,3 +32,39 @@ test("rejects when the watcher never reports the canary", async () => { await expect(canary.verify()).rejects.toThrow("did not report its liveness canary"); }); + +test("verifies a watcher that starts delivering after the initial canary write", async () => { + const watchRoot = await mkdtemp(join(tmpdir(), "paseo-watcher-canary-")); + cleanupPaths.push(watchRoot); + const canary = createWatcherLivenessCanary(watchRoot); + const verification = canary.verify(); + const verified = expect(verification).resolves.toBeUndefined(); + + // The initial write happens before the observer starts delivering changes. + await expect.poll(() => readFile(canary.path, "utf8")).toBe("paseo watcher liveness canary\n"); + watchFile(canary.path, { interval: 10 }, () => { + canary.filterEvents([{ path: canary.path, type: "update" }]); + }); + try { + await verified; + await expect(readFile(canary.path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + unwatchFile(canary.path); + } +}); + +test("aborting verification stops pulses before removing the canary", async () => { + const watchRoot = await mkdtemp(join(tmpdir(), "paseo-watcher-canary-")); + cleanupPaths.push(watchRoot); + const canary = createWatcherLivenessCanary(watchRoot); + const controller = new AbortController(); + const verification = canary.verify(controller.signal); + const cancelled = expect(verification).rejects.toThrow("stop watching"); + await expect.poll(() => readFile(canary.path, "utf8")).toBe("paseo watcher liveness canary\n"); + const initialWrite = (await stat(canary.path)).mtimeMs; + await expect.poll(async () => (await stat(canary.path)).mtimeMs).toBeGreaterThan(initialWrite); + + controller.abort(new Error("stop watching")); + await cancelled; + await expect(readFile(canary.path)).rejects.toMatchObject({ code: "ENOENT" }); +}); diff --git a/packages/server/src/server/watcher-liveness-canary.ts b/packages/server/src/server/watcher-liveness-canary.ts index a366f0229a..2fc5c5dbdf 100644 --- a/packages/server/src/server/watcher-liveness-canary.ts +++ b/packages/server/src/server/watcher-liveness-canary.ts @@ -1,9 +1,11 @@ import { randomUUID } from "node:crypto"; import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { setInterval } from "node:timers/promises"; import type { FileChange } from "./file-observer/index.js"; export const WATCHER_LIVENESS_CANARY_TIMEOUT_MS = 10_000; +const CANARY_CONTENTS = "paseo watcher liveness canary\n"; export interface WatcherLivenessCanary { readonly path: string; @@ -32,7 +34,11 @@ export function createWatcherLivenessCanary( return filtered; }, async verify(signal) { - await writeFile(canaryPath, "paseo watcher liveness canary\n", { flag: "wx" }); + await writeFile(canaryPath, CANARY_CONTENTS, { flag: "wx" }); + // Native registration can return before macOS starts its FSEvents stream. + // Keep probing until an event round-trips instead of relying on the first write. + const pulses = new AbortController(); + const pulsing = pulseCanary(canaryPath, pulses.signal); let timeout: NodeJS.Timeout | null = null; let removeAbortListener = () => {}; try { @@ -55,12 +61,24 @@ export function createWatcherLivenessCanary( signal.addEventListener("abort", rejectForAbort, { once: true }); removeAbortListener = () => signal.removeEventListener("abort", rejectForAbort); }); - await Promise.race([reported, timeoutPromise, abortPromise]); + await Promise.race([reported, timeoutPromise, abortPromise, pulsing]); } finally { if (timeout) clearTimeout(timeout); removeAbortListener(); + pulses.abort(); + await Promise.allSettled([pulsing]); await rm(canaryPath, { force: true }); } }, }; } + +async function pulseCanary(path: string, signal: AbortSignal): Promise { + try { + for await (const _ of setInterval(100, undefined, { signal })) { + await writeFile(path, CANARY_CONTENTS); + } + } catch (error) { + if (!signal.aborted) throw error; + } +} diff --git a/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts b/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts index 5131456ee9..abce664ffd 100644 --- a/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts +++ b/packages/server/src/server/websocket-server.file-transfer.e2e.test.ts @@ -90,7 +90,11 @@ test( TEST_TIMEOUT_MS, ); -async function connectSocket(port: number, clientId: string): Promise { +async function connectSocket( + port: number, + clientId: string, + capabilities?: Record, +): Promise { const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); await new Promise((resolve, reject) => { socket.once("open", resolve); @@ -98,7 +102,7 @@ async function connectSocket(port: number, clientId: string): Promise }); await sendAndWait( socket, - { type: "hello", clientId, clientType: "browser", protocolVersion: 1 }, + { type: "hello", clientId, clientType: "browser", protocolVersion: 1, capabilities }, (message) => message.type === "session" && message.message.type === "status" && @@ -166,3 +170,97 @@ function sendAndWait( socket.send(JSON.stringify(message)); return response; } + +test("an awaited timeline framing overflow closes the actual daemon socket", async () => { + const { createHostClient } = await import("@getpaseo/client/node"); + const { createPaseoApi } = await import("@getpaseo/client"); + const { default: pino } = await import("pino"); + const { Writable } = await import("node:stream"); + const messages: string[] = []; + const logger = pino( + { level: "warn" }, + new Writable({ + write(chunk, _encoding, done) { + messages.push(JSON.parse(chunk.toString()).msg); + done(); + }, + }), + ); + daemon = await createTestPaseoDaemon({ logger }); + const client = createHostClient(`127.0.0.1:${daemon.port}`, { + clientId: "framing-seed", + reconnect: { enabled: false }, + }); + await client.connect(); + const api = createPaseoApi(client); + try { + const workspace = await api.workspaces.create({ + source: { kind: "directory", path: daemon.staticDir }, + }); + const agents = []; + const text = "x".repeat(240 * 1024); + for (const [index, rows] of [350, 90].entries()) { + const agent = await workspace.agents.create({ + config: { provider: "claude/claude-test", modeId: "full-access" }, + title: `Framing overflow ${index}`, + }); + agents.push(agent.id); + for (let row = 0; row < rows; row++) + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "user_message", + text, + }); + } + const source = await connectSocket(daemon.port, "framing-overflow", { + binary_chunking_v1: true, + }); + sockets.push(source); + const closed = new Promise((resolve) => source.once("close", () => resolve())); + const firstChunk = new Promise((resolve) => { + const listener = (data: RawData, binary: boolean) => { + if (!binary || (data as Buffer)[0] !== 0x31) return; + source.off("message", listener); + source.pause(); + resolve(); + }; + source.on("message", listener); + }); + const fetch = (agentId: string, requestId: string) => + source.send( + JSON.stringify({ + type: "session", + message: { + type: "fetch_agent_timeline_request", + requestId, + agentId, + direction: "tail", + projection: "canonical", + limit: 0, + }, + }), + ); + fetch(agents[0]!, "first"); + await firstChunk; + fetch(agents[1]!, "overflow"); + await expect + .poll(() => messages.includes("Closing physical WebSocket after outbound framing failure"), { + timeout: 15000, + }) + .toBe(true); + expect(messages).not.toContain("Closing physical WebSocket at outbound high-water mark"); + source.resume(); + await closed; + } finally { + await api.dispose(); + await client.close(); + } +}, 60000); + +test("malformed chunk metadata closes the daemon socket with a protocol error", async () => { + daemon = await createTestPaseoDaemon(); + const source = await connectSocket(daemon.port, "invalid-framing", { binary_chunking_v1: true }); + sockets.push(source); + const closed = new Promise((resolve) => source.once("close", (code) => resolve(code))); + source.send(new Uint8Array([0x31, 2, 0, 3, 65])); + expect(await closed).toBe(1002); +}); 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 2d48a93c87..c99d5ac9cc 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1157,6 +1157,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.workspaceMarkUnread).toBe(true); expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true); + expect(serverInfo.features?.["terminal-grapheme-widths"]).toBe(true); expect(serverInfo.features?.agentTurnIdentity).toBeUndefined(); expect(serverInfo.permissions).toEqual(DAEMON_PERMISSIONS); await server.close(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 88fb2c327d..7089812d21 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,3 +1,7 @@ +import { + ChunkedTransport, + ChunkedTransportProtocolError, +} from "@getpaseo/protocol/chunked-transport"; import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { createAgentMessageDelivery, @@ -124,8 +128,8 @@ import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, outboundFrameByteLength, physicalSocketHasCapacity, - sendBoundedPhysicalFrame, sendBoundedPhysicalFrameAndWait, + sendBoundedPhysicalFrame, } from "./websocket/physical-socket.js"; const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; @@ -358,7 +362,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry { get: async () => null, update: async () => null, upsert: async () => {}, - archive: async () => {}, + archive: async () => false, remove: async () => {}, }; } @@ -440,6 +444,7 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe } export interface WebSocketLike { + maxFrameBytes?: number; readyState: number; bufferedAmount?: number; send: ( @@ -1273,6 +1278,67 @@ export class VoiceAssistantWebSocketServer { return { sockets: writableSockets, payload, bytes: outboundFrameByteLength(payload) }; } + private sendHelloResponse(ws: WebSocketLike, session: Session, hello: WSHelloMessage): void { + // The peer learns our support from this ordinary response before receiving chunks. + this.sendToClient(ws, this.createServerInfoMessage(session)); + this.chunkedSocket(ws).enabled = hello.capabilities?.[CLIENT_CAPS.binaryChunkingV1] === true; + } + + private readonly chunkedSockets = new WeakMap(); + + private chunkedSocket(ws: WebSocketLike): ChunkedTransport { + let transport = this.chunkedSockets.get(ws); + if (!transport) { + transport = new ChunkedTransport({ + frameBytes: ws.maxFrameBytes ?? 32 * 1024 * 1024, + onFailure: (err) => + this.closePhysicalSocket({ + ws, + logMessage: "Closing physical WebSocket after outbound framing failure", + logFields: { err }, + }), + send: (frame, waitForDrain) => { + const params = { + socket: ws, + frame, + onHighWater: () => this.closeAtOutboundHighWater(ws), + }; + if (waitForDrain) + return sendBoundedPhysicalFrameAndWait(params).then((sent) => { + if (!sent) throw new Error("Source is closed"); + return undefined; + }); + if (!sendBoundedPhysicalFrame(params)) throw new Error("Source is closed"); + }, + deliver: (frame) => + this.handleRawMessage(ws, typeof frame === "string" ? frame : Buffer.from(frame)), + }); + this.chunkedSockets.set(ws, transport); + } + return transport; + } + + private socketBufferedAmount(ws: WebSocketLike): number | null { + const queued = this.chunkedSockets.get(ws)?.queuedByteLength ?? 0; + if (typeof ws.bufferedAmount === "number") return ws.bufferedAmount + queued; + return queued > 0 ? queued : null; + } + + private sendSocketFrame( + ws: WebSocketLike, + frame: string | Uint8Array, + bytes: number, + waitForDrain = false, + ): Promise { + const transport = this.chunkedSocket(ws); + const admittedBytes = transport.isWriting + ? bytes + : Math.min(bytes, ws.maxFrameBytes ?? 32 * 1024 * 1024); + if (!this.ensureOutboundCapacity(ws, admittedBytes)) + return Promise.reject(new Error("Source is closed")); + return transport.send(frame, { waitForDrain, byteLength: bytes }); + } + private async sendMessageToClientAndWait( ws: WebSocketLike, message: WSOutboundMessage, @@ -1280,13 +1346,7 @@ export class VoiceAssistantWebSocketServer { const prepared = this.prepareJsonDelivery([ws], message, true); if (!prepared) throw new Error("Source message delivery unavailable"); try { - const sent = await sendBoundedPhysicalFrameAndWait({ - socket: ws, - frame: prepared.payload, - frameBytes: prepared.bytes, - onHighWater: () => this.closeAtOutboundHighWater(ws), - }); - if (!sent) throw new Error("Source is closed"); + await this.sendSocketFrame(ws, prepared.payload, prepared.bytes, true); this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount); } catch (err) { this.logger.warn({ err }, "ws_send_failed"); @@ -1313,14 +1373,7 @@ export class VoiceAssistantWebSocketServer { return; } try { - const sent = await sendBoundedPhysicalFrameAndWait({ - socket: ws, - frame, - onHighWater: () => this.closeAtOutboundHighWater(ws), - }); - if (!sent) { - throw new Error("Physical WebSocket is not open"); - } + await this.sendSocketFrame(ws, frame, frame.byteLength, true); this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount); } catch (err) { this.logger.warn({ err }, "ws_send_failed"); @@ -1335,13 +1388,9 @@ export class VoiceAssistantWebSocketServer { recordSent: () => void, ): void { try { - const sent = sendBoundedPhysicalFrame({ - socket: ws, - frame, - frameBytes, - onHighWater: () => this.closeAtOutboundHighWater(ws), + void this.sendSocketFrame(ws, frame, frameBytes).then(recordSent, (err) => { + this.logger.warn({ err }, "ws_send_failed"); }); - if (sent) recordSent(); } catch (err) { this.logger.warn({ err }, "ws_send_failed"); } @@ -1349,7 +1398,13 @@ export class VoiceAssistantWebSocketServer { private ensureOutboundCapacity(ws: WebSocketLike, frameBytes: number): boolean { if (ws.readyState !== 1) return false; - if (physicalSocketHasCapacity(ws, frameBytes)) return true; + if ( + physicalSocketHasCapacity( + { bufferedAmount: this.socketBufferedAmount(ws) ?? undefined }, + frameBytes, + ) + ) + return true; this.closeAtOutboundHighWater(ws); return false; @@ -1522,16 +1577,15 @@ export class VoiceAssistantWebSocketServer { if (!connection) { return null; } - if (source) return (source as WebSocketLike).bufferedAmount ?? null; + if (source) return this.socketBufferedAmount(source as WebSocketLike); // 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"; // a direct ws reports its real buffered bytes (0 when drained). let maxBuffered: number | null = null; for (const socket of connection.sockets) { - if (typeof socket.bufferedAmount === "number") { - maxBuffered = Math.max(maxBuffered ?? 0, socket.bufferedAmount); - } + const buffered = this.socketBufferedAmount(socket); + if (buffered !== null) maxBuffered = Math.max(maxBuffered ?? 0, buffered); } return maxBuffered; }, @@ -1568,8 +1622,9 @@ export class VoiceAssistantWebSocketServer { let total = 0; for (const socket of this.sessions.keys()) { if (socket instanceof VirtualHostSocket) continue; - if (typeof socket.bufferedAmount !== "number") return null; - total += socket.bufferedAmount; + const buffered = this.socketBufferedAmount(socket); + if (buffered === null) return null; + total += buffered; } return total; }, @@ -1620,6 +1675,7 @@ export class VoiceAssistantWebSocketServer { stt: () => this.speech?.resolveStt() ?? null, sttLanguage: this.speech?.resolveSttLanguage() ?? "en", tts: () => this.speech?.resolveTts() ?? null, + speechModels: this.speech, terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, hubExecutionAgents: options.hubExecutionAgents, @@ -1769,7 +1825,7 @@ export class VoiceAssistantWebSocketServer { this.externalSessionsByKey.set(sessionKey, connection); } pending.identity.sessionId = connection.session.getSessionId(); - this.sendToClient(ws, this.createServerInfoMessage(connection.session)); + this.sendHelloResponse(ws, connection.session, message); connection.connectionLogger.info( { ...toConnectionLogFields(pending.identity), @@ -1905,7 +1961,7 @@ export class VoiceAssistantWebSocketServer { existing.sockets.add(ws); this.sessions.set(ws, existing); pending.identity.sessionId = existing.session.getSessionId(); - this.sendToClient(ws, this.createServerInfoMessage(existing.session)); + this.sendHelloResponse(ws, existing.session, message); pending.connectionLogger.info( { ...toConnectionLogFields(pending.identity), @@ -1948,8 +2004,10 @@ export class VoiceAssistantWebSocketServer { hostMultiplexing: true, // COMPAT(relayProtectedSecurity): added in v0.13.0, remove gate after 2027-04-21. relayProtectedSecurity: true, + usageSetting: true, usageSources: true, ownedSubscriptions: true, + binaryChunkingV1: true, agentRequestReceipts: true, workspaceRequestReceipts: true, creationLifecycle: true, @@ -1991,6 +2049,8 @@ export class VoiceAssistantWebSocketServer { ...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}), // COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14. daemonConfigReload: true, + // COMPAT(speechSettings): added in v0.12.0, remove gate after 2027-04-18. + speechSettings: true, // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. ...(this.advertiseRelayConfig ? { relayConfig: true } : {}), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. @@ -2008,12 +2068,17 @@ export class VoiceAssistantWebSocketServer { pluginTimelineItems: true, // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: true, + // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26. + idleRuntimeCleanupSetting: true, + resumeOnStartupSetting: true, // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": true, // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. "terminal-input-mode-replay": true, // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. "terminal-size-ownership": true, + // COMPAT(terminalGraphemeWidths): added in v0.15.0, remove gate after 2027-05-01. + "terminal-grapheme-widths": true, workspaceTerminals: true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: true, @@ -2049,6 +2114,8 @@ export class VoiceAssistantWebSocketServer { daemonDiagnostics: true, // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. daemonSelfUpdate: this.daemonRuntimeConfig?.desktopManaged !== true, + // COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21. + terminalsSurviveRestart: true, // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: true, // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. @@ -2057,6 +2124,7 @@ export class VoiceAssistantWebSocketServer { providerSubagents: true, // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement. projectedSubagentTimeline: true, + wholeTurnTimeline: 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. @@ -2155,10 +2223,16 @@ export class VoiceAssistantWebSocketServer { private bindSocketHandlers(ws: WebSocketLike): void { ws.on("message", (...args: unknown[]) => { const data = args[0] as Buffer | ArrayBuffer | Buffer[] | string; - this.handleRawMessage(ws, data); + try { + this.chunkedSocket(ws).receive(typeof data === "string" ? data : bufferFromWsData(data)); + } catch (error) { + if (!(error instanceof ChunkedTransportProtocolError)) throw error; + ws.close(1002, "Invalid chunked transport"); + } }); ws.on("close", async (...args: unknown[]) => { + this.chunkedSockets.get(ws)?.close(); const code = args[0]; const reason = args[1]; await this.detachSocket(ws, { @@ -2558,7 +2632,7 @@ export class VoiceAssistantWebSocketServer { identity: JSON.stringify([connection.principalId, connection.clientId]), permissions: () => connection.session.getPermissions(), routed: () => connection.session.isRouted(), - bufferedAmount: () => ws.bufferedAmount ?? null, + bufferedAmount: () => this.socketBufferedAmount(ws), delivery: connection.session.delivery, admit: (socket, permissions) => this.attachSocket(socket, undefined, undefined, false, { diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts index f2a880f15f..3e17f0cbb4 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.test.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.test.ts @@ -140,3 +140,74 @@ test("encrypted sockets do not double-count bytes already buffered by the transp expect(socket.bufferedAmount).toBe(payload.byteLength + 40); }); + +test("50 MiB logical text and routed binary cross encrypted relay frames in both directions", async () => { + const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport"); + const { openClientSecureChannel, openDaemonSecureChannel } = await import("@getpaseo/relay/e2ee"); + const { generateKeyPair, exportPublicKey, maxSecuredPlaintextByteLength } = + await import("@getpaseo/relay"); + const { encodeHostRouteFrame, decodeHostRouteFrame } = + await import("@getpaseo/protocol/binary-frames/index"); + type Transport = import("@getpaseo/relay/e2ee").Transport; + const limit = 32 * 1024 * 1024; + const wireSizes: number[] = []; + const endpoint = (): Transport => ({ + send: () => {}, + close: () => {}, + onmessage: null, + onclose: null, + onerror: null, + }); + const left = endpoint(); + const right = endpoint(); + const wire = (destination: Transport) => (data: string | ArrayBuffer) => { + const size = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength; + expect(size).toBeLessThanOrEqual(limit); + wireSizes.push(size); + queueMicrotask(() => destination.onmessage?.({ data, isBinary: typeof data !== "string" })); + }; + left.send = wire(right); + right.send = wire(left); + const key = generateKeyPair(); + const delivered: (string | Uint8Array)[][] = [[], []]; + let receivers: InstanceType[] = []; + const incoming = (index: number) => (data: string | ArrayBuffer) => + receivers[index]!.receive(typeof data === "string" ? data : new Uint8Array(data)); + const daemonPromise = openDaemonSecureChannel(right, { + daemonKeyPair: key, + events: { onmessage: incoming(1) }, + }); + const client = await openClientSecureChannel(left, { + daemonPublicKeyB64: exportPublicKey(key.publicKey), + events: { onmessage: incoming(0) }, + }); + const daemon = await daemonPromise; + receivers = [client, daemon].map( + (channel, index) => + new ChunkedTransport({ + frameBytes: maxSecuredPlaintextByteLength(limit), + send: (frame) => channel.send(typeof frame === "string" ? frame : frame.slice().buffer), + deliver: (frame) => { + delivered[index]!.push(frame); + }, + }), + ); + for (const receiver of receivers) receiver.enabled = true; + const text = "x".repeat(50 * 1024 * 1024); + wireSizes.length = 0; + await receivers[1]!.send(text); + await new Promise((resolve) => setImmediate(resolve)); + expect(delivered[0]).toEqual([text]); + expect(wireSizes.length).toBeGreaterThan(1); + const routed = encodeHostRouteFrame("route", new Uint8Array(50 * 1024 * 1024).fill(7)); + await receivers[0]!.send(routed); + await new Promise((resolve) => setImmediate(resolve)); + expect(delivered[1]).toHaveLength(1); + const received = decodeHostRouteFrame(delivered[1]![0] as Uint8Array)!; + expect(received.routeId).toBe("route"); + expect( + Buffer.from(received.payload).equals(Buffer.from(new Uint8Array(50 * 1024 * 1024).fill(7))), + ).toBe(true); + client.close(); + daemon.close(); +}, 30_000); diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.ts b/packages/server/src/server/websocket/encrypted-relay-socket.ts index f11162761c..794789718c 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.ts @@ -1,3 +1,5 @@ +import { maxSecuredPlaintextByteLength } from "@getpaseo/relay"; +import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { EventEmitter } from "node:events"; import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES } from "./physical-socket.js"; @@ -8,6 +10,7 @@ export interface EncryptedRelayChannel { } export interface EncryptedRelaySocket { + readonly maxFrameBytes: number; readonly readyState: number; readonly bufferedAmount: number; send: (data: string | Uint8Array | ArrayBuffer) => void | Promise; @@ -43,6 +46,7 @@ export function createEncryptedRelaySocket(params: { }); return { + maxFrameBytes: maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES), get readyState() { return readyState; }, diff --git a/packages/server/src/server/websocket/physical-socket.test.ts b/packages/server/src/server/websocket/physical-socket.test.ts index c81b1410d6..6dce3e151d 100644 --- a/packages/server/src/server/websocket/physical-socket.test.ts +++ b/packages/server/src/server/websocket/physical-socket.test.ts @@ -120,3 +120,96 @@ test("the awaitable physical send rejects callback errors", async () => { }), ).rejects.toThrow("send failed"); }); + +test("ordinary frames expose a paused real socket's backlog and terminate at the physical backstop", async () => { + const { once } = await import("node:events"); + const { WebSocket, WebSocketServer } = await import("ws"); + const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport"); + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(server, "listening"); + const connected = once(server, "connection"); + const address = server.address(); + if (typeof address === "string" || !address) throw new Error("Missing server address"); + const client = new WebSocket(`ws://127.0.0.1:${address.port}`); + await once(client, "open"); + const [socket] = (await connected) as [InstanceType]; + const tcp = (socket as unknown as { _socket: import("node:net").Socket })._socket; + let terminated = false; + const transport = new ChunkedTransport({ + frameBytes: 32 * 1024 * 1024, + deliver: () => {}, + send: (frame, wait) => { + const options = { + socket, + frame, + onHighWater: () => { + terminated = true; + socket.terminate(); + }, + }; + if (wait) return sendBoundedPhysicalFrameAndWait(options).then(() => undefined); + sendBoundedPhysicalFrame(options); + }, + }); + try { + client.pause(); + tcp.cork(); + const frame = new Uint8Array(256 * 1024); + for (let count = 0; count < 200; count++) await transport.send(frame); + expect(socket.bufferedAmount).toBeGreaterThan(4 * 1024 * 1024); + expect(transport.queuedByteLength).toBe(0); + for (let count = 0; count < 100; count++) { + if (terminated) break; + await transport.send(frame); + } + expect(terminated).toBe(true); + } finally { + transport.close(); + client.terminate(); + socket.terminate(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("an awaited framing failure terminates its real socket immediately", async () => { + const { once } = await import("node:events"); + const { WebSocket, WebSocketServer } = await import("ws"); + const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport"); + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(server, "listening"); + const connected = once(server, "connection"); + const address = server.address(); + if (typeof address === "string" || !address) throw new Error("Missing server address"); + const client = new WebSocket(`ws://127.0.0.1:${address.port}`); + await once(client, "open"); + const [socket] = (await connected) as [InstanceType]; + const tcp = (socket as unknown as { _socket: import("node:net").Socket })._socket; + const transport = new ChunkedTransport({ + frameBytes: 32 * 1024 * 1024, + deliver: () => {}, + onFailure: () => socket.terminate(), + send: (frame) => + sendBoundedPhysicalFrameAndWait({ + socket, + frame, + onHighWater: () => socket.terminate(), + }).then(() => undefined), + }); + transport.enabled = true; + try { + client.pause(); + tcp.cork(); + const closed = once(socket, "close"); + const first = transport.send(new Uint8Array(100 * 1024 * 1024), { waitForDrain: true }); + const overflow = transport.send(new Uint8Array([1]), { waitForDrain: true }); + const settled = Promise.allSettled([first, overflow]); + await closed; + expect(socket.readyState).toBe(WebSocket.CLOSED); + expect((await settled).map((result) => result.status)).toEqual(["rejected", "rejected"]); + } finally { + transport.close(); + client.terminate(); + socket.terminate(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index a1a901c4d6..567c80054f 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -7,7 +7,9 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import type { ForgeService } from "../services/forge-service.js"; import { createRealpathAwarePathMatcher } from "../utils/path.js"; -import { createWorktree, type WorktreeConfig } from "../utils/worktree.js"; +import { getPaseoWorktreeMetadataPath } from "../utils/worktree-metadata.js"; +import { archiveCommand } from "./worktree/commands.js"; +import { createWorktree, listPaseoWorktrees, type WorktreeConfig } from "../utils/worktree.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; @@ -190,6 +192,54 @@ function assertArchiveResult( } describe("archiveByScope", () => { + test.each(["workspace", "worktree"] as const)( + "%s archive preserves a restored external worktree despite its marker", + async (kind) => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const recordedPath = path.join(tempDir, "external"); + execFileSync("git", ["worktree", "add", "-b", "external", recordedPath], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["worktree", "remove", recordedPath], { cwd: repoDir, stdio: "pipe" }); + const restored = await createWorktree({ + cwd: repoDir, + paseoHome, + worktreeSlug: "external", + worktreePath: recordedPath, + source: { kind: "restore", branchName: "external", baseRef: "main" }, + }); + expect(existsSync(await getPaseoWorktreeMetadataPath(restored.worktreePath))).toBe(true); + const workspaceId = "external-workspace"; + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId, + cwd: restored.worktreePath, + kind: "worktree", + worktreeRoot: restored.worktreePath, + mainRepoRoot: repoDir, + isPaseoOwnedWorktree: false, + }, + ], + }), + { + scope: + kind === "workspace" + ? { kind, workspaceId } + : { kind, targetPath: restored.worktreePath }, + requestId: "archive-external", + }, + ); + expect(result.removedDirectory).toBe(false); + expect(existsSync(restored.worktreePath)).toBe(true); + expect(result.archivedWorkspaceIds).toEqual([workspaceId]); + }, + ); + test("workspace scope archives the record and removes the directory on last reference", async () => { const { tempDir, repoDir } = createGitRepo(); const paseoHome = path.join(tempDir, ".paseo"); @@ -220,6 +270,120 @@ describe("archiveByScope", () => { expect(existsSync(worktree.worktreePath)).toBe(false); }); + test("workspace scope archives the record but leaves a plugin-placed directory whose marker is gone", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createWorktree({ + cwd: repoDir, + worktreeSlug: "unproven-workspace", + source: { kind: "branch-off", baseBranch: "main", branchName: "unproven-workspace" }, + paseoHome, + worktreePath: path.join(tempDir, "placed-worktree"), + }); + // Remove the ownership marker: no proof remains for this path. + rmSync(await getPaseoWorktreeMetadataPath(worktree.worktreePath), { force: true }); + const workspaceId = "ws-unproven"; + + const logs: Array<{ targetPath: string; msg: string }> = []; + const dependencies = createArchiveDeps({ + paseoHome, + activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }], + }); + dependencies.sessionLogger = pino( + { level: "warn" }, + { + write(message) { + logs.push(JSON.parse(message)); + }, + }, + ); + const result = await archiveByScope(dependencies, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-unproven-workspace", + }); + + assertArchiveResult(result, { archivedWorkspaceIds: [workspaceId], removedDirectory: false }); + expect(result.directoryLeftBehind?.path).toBe(worktree.worktreePath); + expect(result.directoryLeftBehind?.reason).toContain("no ownership proof"); + expect(existsSync(worktree.worktreePath)).toBe(true); + expect(logs).toContainEqual( + expect.objectContaining({ + targetPath: worktree.worktreePath, + msg: expect.stringContaining("no ownership proof"), + }), + ); + }); + + test("archives a plugin-placed worktree by its discovered directory name", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktreePath = path.join(tempDir, "sibling-feature"); + await createWorktree({ + cwd: repoDir, + paseoHome, + worktreePath, + worktreeSlug: "original-slug", + source: { kind: "branch-off", branchName: "feature", baseBranch: "main" }, + }); + const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] }); + const result = await archiveCommand( + { + ...dependencies, + workspaceGitService: { + ...dependencies.workspaceGitService, + listWorktrees: (cwd) => listPaseoWorktrees({ cwd, paseoHome }), + }, + }, + { + repoRoot: repoDir, + worktreeSlug: "sibling-feature", + scope: "worktree", + requestId: "slug-archive", + }, + ); + expect(result).toEqual({ ok: true, removedAgents: [] }); + expect(existsSync(worktreePath)).toBe(false); + }); + + test("workspace scope deletes a plugin-placed directory on registry proof even with the marker gone", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createWorktree({ + cwd: repoDir, + worktreeSlug: "registry-proven-workspace", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "registry-proven-workspace", + }, + paseoHome, + worktreePath: path.join(tempDir, "placed-worktree"), + }); + rmSync(await getPaseoWorktreeMetadataPath(worktree.worktreePath), { force: true }); + const workspaceId = "ws-registry-proven"; + + const result = await archiveByScope( + createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId, + cwd: worktree.worktreePath, + kind: "worktree", + isPaseoOwnedWorktree: true, + worktreeRoot: worktree.worktreePath, + mainRepoRoot: repoDir, + }, + ], + }), + { scope: { kind: "workspace", workspaceId }, requestId: "req-registry-proven-workspace" }, + ); + + assertArchiveResult(result, { archivedWorkspaceIds: [workspaceId], removedDirectory: true }); + expect(result.directoryLeftBehind).toBeUndefined(); + expect(existsSync(worktree.worktreePath)).toBe(false); + }); + test("workspace scope runs teardown while keeping a directory referenced by a sibling", async () => { const { tempDir, repoDir } = createGitRepo(); writeFileSync( diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index e9e58681a1..78f8c0b97d 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -69,6 +69,10 @@ export interface ArchiveResult { archivedAgentIds: string[]; archivedWorkspaceIds: string[]; removedDirectory: boolean; + // Set when the record archived but its worktree directory was left on + // disk because no ownership proof held for it. The directory is never + // deleted in that case; the path and reason surface here and in the log. + directoryLeftBehind?: { path: string; reason: string }; } export interface ArchiveByScopeRequest { @@ -92,6 +96,15 @@ export async function requireActiveWorkspaceForArchive( interface BackingDirectory { path: string; isPaseoOwnedWorktree: boolean; + // True when the directory backs a worktree workspace (or a worktree-scope + // archive), so a failed ownership proof means a Paseo worktree directory + // is being left behind rather than a user's local checkout being ignored. + expectsPaseoOwnership: boolean; + // True only when this backing came from a workspace registry record + // matched to this path (the record is itself an ownership proof, passed + // through to deletePaseoWorktree). Disk-derived backings stay false: + // they must prove ownership on disk. + registryProven: boolean; mainRepoRoot: string | null; paseoWorktreesRoot: string | null; } @@ -141,6 +154,7 @@ async function archiveByScopeWithPriority( } let removedDirectory = false; + let directoryLeftBehind: ArchiveResult["directoryLeftBehind"]; try { if (targetWorkspaceIds.length > 0) { @@ -168,18 +182,21 @@ async function archiveByScopeWithPriority( } if (target.backing !== null) { - removedDirectory = await maybeRemoveDirectory( + const removal = await maybeRemoveDirectory( dependencies, request, target, archivedWorkspaceIds, ); + removedDirectory = removal.removedDirectory; + directoryLeftBehind = removal.directoryLeftBehind; } return { archivedAgentIds: Array.from(archivedAgents), archivedWorkspaceIds, removedDirectory, + ...(directoryLeftBehind ? { directoryLeftBehind } : {}), }; } finally { if (targetWorkspaceIds.length > 0) { @@ -226,14 +243,16 @@ async function resolveArchiveTarget( }), ) ).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null); - const persistedMainRepoRoot = targetWorkspaces.find( - (workspace) => workspace.mainRepoRoot, - )?.mainRepoRoot; + const recordedId = await dependencies.findWorkspaceIdForCwd(backing.path); + const recorded = recordedId ? await dependencies.getWorkspace?.(recordedId) : null; + const record = + targetWorkspaces.find((workspace) => workspace.kind === "worktree") ?? + (recorded && matchesBackingDirectory(recorded.worktreeRoot ?? recorded.cwd) + ? recorded + : null) ?? + targetWorkspaces[0]; return { - backing: { - ...backing, - mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot, - }, + backing: record ? await resolveWorkspaceBackingDirectory(record, dependencies) : backing, teardownTargets: targetWorkspaces.length > 0 ? targetWorkspaces.map((workspace) => ({ @@ -271,10 +290,22 @@ async function resolveWorkspaceBackingDirectory( workspace: ActiveWorkspaceRef, dependencies: Pick, ): Promise { + if (workspace.isPaseoOwnedWorktree === false) { + return { + path: resolve(workspace.worktreeRoot ?? workspace.cwd), + isPaseoOwnedWorktree: false, + expectsPaseoOwnership: false, + registryProven: false, + mainRepoRoot: workspace.mainRepoRoot ?? null, + paseoWorktreesRoot: null, + }; + } if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) { return { path: resolve(workspace.worktreeRoot), isPaseoOwnedWorktree: true, + expectsPaseoOwnership: true, + registryProven: true, mainRepoRoot: workspace.mainRepoRoot, paseoWorktreesRoot: null, }; @@ -283,6 +314,8 @@ async function resolveWorkspaceBackingDirectory( return { path: resolve(workspace.cwd), isPaseoOwnedWorktree: false, + expectsPaseoOwnership: false, + registryProven: false, mainRepoRoot: workspace.mainRepoRoot ?? null, paseoWorktreesRoot: null, }; @@ -309,6 +342,8 @@ async function resolveBackingDirectory( return { path: resolve(ownership.allowed && ownership.worktreePath ? ownership.worktreePath : cwd), isPaseoOwnedWorktree: ownership.allowed, + expectsPaseoOwnership: true, + registryProven: false, mainRepoRoot: ownership.repoRoot ?? null, paseoWorktreesRoot: ownership.worktreeRoot ?? null, }; @@ -352,10 +387,28 @@ async function maybeRemoveDirectory( request: Pick, target: ArchiveTarget, archivedWorkspaceIds: string[], -): Promise { +): Promise<{ + removedDirectory: boolean; + directoryLeftBehind?: { path: string; reason: string }; +}> { const backing = target.backing; if (!backing?.isPaseoOwnedWorktree) { - return false; + // Missing ownership proof for a worktree-backed directory: the record + // still archives, but the directory is left in place and surfaced. + // Never delete an unproven directory. + if (backing?.expectsPaseoOwnership) { + const reason = + "no ownership proof (not under the Paseo worktrees root, no paseo/worktree.json marker, no registry record)"; + dependencies.sessionLogger?.warn( + { targetPath: backing.path, requestId: request.requestId }, + `Worktree directory left in place during archive: ${reason}`, + ); + return { + removedDirectory: false, + directoryLeftBehind: { path: backing.path, reason }, + }; + } + return { removedDirectory: false }; } const archivedWorkspaceIdSet = new Set(archivedWorkspaceIds); @@ -382,7 +435,7 @@ async function maybeRemoveDirectory( { err: error, targetPath: backing.path, requestId: request.requestId }, "Worktree teardown failed during archive; workspace already archived", ); - return false; + return { removedDirectory: false }; } throw error; } @@ -396,7 +449,7 @@ async function maybeRemoveDirectory( dependencies, )) ) { - return false; + return { removedDirectory: false }; } try { @@ -407,16 +460,17 @@ async function maybeRemoveDirectory( worktreesRoot: backing.paseoWorktreesRoot ?? undefined, paseoHome: dependencies.paseoHome, worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, + registryProven: backing.registryProven, }); dependencies.workspaceGitService.invalidateWorktrees(); dependencies.github.invalidate({ cwd: backing.path }); - return true; + return { removedDirectory: true }; } catch (error) { dependencies.sessionLogger?.warn( { err: error, targetPath: backing.path, requestId: request.requestId }, "Worktree disk removal failed during archive; workspace already archived", ); - return false; + return { removedDirectory: false }; } } diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index fc9efe1684..cd9ec71d83 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -493,6 +493,7 @@ async function createRestorableWorkspace(baseRef: string) { commitFile(cwd, "feature.txt", "feature change\n"); const head = runGit(cwd, "rev-parse", "HEAD"); await client.checkoutRefresh(cwd); + expect(await client.getCheckoutStatus(cwd)).toMatchObject({ isPaseoOwnedWorktree: true }); const before = await client.getCheckoutDiff(cwd, { mode: "base", baseRef: "main" }); expect(before.error).toBeNull(); expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]); diff --git a/packages/server/src/server/workspace-git-fetch.test.ts b/packages/server/src/server/workspace-git-fetch.test.ts index 29a95b64d8..de9e69959e 100644 --- a/packages/server/src/server/workspace-git-fetch.test.ts +++ b/packages/server/src/server/workspace-git-fetch.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from "vitest"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, writeFile, rm, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { diffWorkspaceGitRefs, @@ -6,14 +10,21 @@ import { fetchWorkspaceGitRemote, parseWorkspaceGitRefs, } from "./workspace-git-fetch.js"; -import type { RunGitCommand } from "../utils/run-git-command.js"; +import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js"; describe("workspace Git remote refs", () => { test("runs the complete fetch through the supplied Git runner", async () => { const commands: string[][] = []; let refRead = 0; - const runGitCommand: RunGitCommand = async (args) => { + const run: RunGitCommand = async (args, options) => { commands.push(args); + if (args[0] === "fetch") { + expect(options.envOverlay).toEqual({ + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: "", + GCM_INTERACTIVE: "0", + }); + } const stdout = args[0] === "for-each-ref" ? `refs/remotes/origin/main\0${refRead++ === 0 ? "aaaaaaaa" : "bbbbbbbb"}` @@ -22,7 +33,7 @@ describe("workspace Git remote refs", () => { }; await expect( - fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, runGitCommand), + fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, run), ).resolves.toMatchObject({ changes: [ { @@ -109,3 +120,130 @@ describe("workspace Git remote refs", () => { ]); }); }); + +// Serve a real bare repository over Git's dumb HTTP transport, behind Basic authentication. +// Both the authentication challenge and successful fetch exercise Git itself. +async function withAuthenticatedRemote( + verify: (fixture: { + cwd: string; + run: RunGitCommand; + root: string; + origin: string; + expectedOid: string; + }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), "paseo-fetch-auth-")); + const cwd = join(root, "checkout"); + const remote = join(root, "remote.git"); + const emptyConfig = join(root, "gitconfig"); + const askpass = join(root, "askpass.sh"); + const marker = join(root, "prompts"); + await writeFile(emptyConfig, ""); + await writeFile( + askpass, + `#!/bin/sh\nprintf 'prompt\\n' >> '${marker.replaceAll("'", "'\\''")}'\nexit 1\n`, + ); + await chmod(askpass, 0o755); + const askpassCommand = askpass; + const run: RunGitCommand = (args, options) => + runGitCommand(args, { + ...options, + env: { + ...options.env, + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_NOSYSTEM: "1", + GIT_ASKPASS: askpassCommand, + SSH_ASKPASS: askpassCommand, + }, + }); + const server = createServer(async (request, response) => { + if ( + request.headers.authorization !== `Basic ${Buffer.from("fixture:secret").toString("base64")}` + ) { + response.writeHead(401, { "WWW-Authenticate": 'Basic realm="git-test"' }); + response.end(); + return; + } + const pathname = new URL(request.url!, "http://localhost").pathname; + try { + const contents = await readFile(join(root, pathname)); + response.writeHead(200, { "Content-Type": "application/octet-stream" }); + response.end(contents); + } catch { + response.writeHead(404); + response.end(); + } + }); + try { + await run(["init", "--initial-branch=main", cwd], { cwd: root }); + await run( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd }, + ); + await run(["clone", "--bare", cwd, remote], { cwd: root }); + await run(["update-server-info"], { cwd: remote }); + await run(["config", "credential.helper", ""], { cwd }); + await run(["config", "core.askPass", askpassCommand], { cwd }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP server"); + const origin = `http://127.0.0.1:${address.port}/remote.git`; + await run(["remote", "add", "origin", origin], { cwd }); + const expectedOid = (await run(["rev-parse", "HEAD"], { cwd })).stdout.trim(); + await verify({ cwd, run, root, origin, expectedOid }); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +test.skipIf(process.platform === "win32")( + "background fetches never invoke askpass when credentials are missing", + async () => { + await withAuthenticatedRemote(async ({ cwd, run, root }) => { + for (let attempt = 0; attempt < 2; attempt++) { + const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run); + expect(String(result.error)).toContain("terminal prompts disabled"); + } + await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }, +); + +test.skipIf(process.platform === "win32")( + "background fetches still use stored credentials", + async () => { + await withAuthenticatedRemote(async ({ cwd, run, root, origin, expectedOid }) => { + const credentials = join(root, "credentials"); + await writeFile(credentials, origin.replace("http://", "http://fixture:secret@") + "\n"); + await run(["config", "credential.helper", `store --file="${credentials}"`], { cwd }); + + const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run); + + expect(result.error).toBeNull(); + expect(result.changes).toContainEqual({ + kind: "added", + ref: "origin/main", + afterOid: expectedOid, + }); + expect((await run(["rev-parse", "refs/remotes/origin/main"], { cwd })).stdout.trim()).toBe( + expectedOid, + ); + await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }, +); diff --git a/packages/server/src/server/workspace-git-fetch.ts b/packages/server/src/server/workspace-git-fetch.ts index 72f5d407da..5673600d23 100644 --- a/packages/server/src/server/workspace-git-fetch.ts +++ b/packages/server/src/server/workspace-git-fetch.ts @@ -33,7 +33,9 @@ export async function fetchWorkspaceGitRemote( try { await run(["fetch", "origin", "--prune"], { cwd, - envOverlay: { GIT_TERMINAL_PROMPT: "0" }, + // Terminal prompting and graphical credential prompting have separate controls. + // Keep cached credentials available, but never start a login from a passive fetch. + envOverlay: { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", GCM_INTERACTIVE: "0" }, timeout: 120_000, }); } catch (caught) { diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index a8a1f11dfb..371502748d 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -12,12 +12,7 @@ import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js"; function createLogger(): pino.Logger { - const logger = { - child: () => logger, - debug: vi.fn(), - warn: vi.fn(), - }; - return logger as unknown as pino.Logger; + return pino({ level: "warn" }); } function createFacts(cwd: string): CheckoutSnapshotFacts { @@ -232,7 +227,14 @@ test("recursive observation updates tracked state and prunes ignored storms", as await vi.waitFor( () => { - expect(activeWatcherCount).toBe(2); + expect( + activeWatcherCount, + JSON.stringify({ + observer: observer.getDiagnostics(), + service: service.getMetrics(), + deliveredEvents, + }), + ).toBe(2); expect(workspaceRuntimeStore.get(repoDir)).not.toBeNull(); expect(service.getMetrics()).toMatchObject({ workspaceObservationSetupInFlightCount: 0, @@ -526,3 +528,205 @@ test("a late Git-ignored tree is pruned while tracked changes still notify consu rmSync(tempDir, { recursive: true, force: true }); } }, 15_000); + +test("Git refresh, command tracing and 10,000 ignored alias events do no synchronous IO", async () => { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const { fileURLToPath } = await import("node:url"); + const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url)); + const source = ` + import assert from 'node:assert/strict'; + import { write } from 'node:fs'; + import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + import { setImmediate as tick } from 'node:timers/promises'; + import { createHook } from 'node:async_hooks'; + import pino from 'pino'; + import { WorkspaceGitServiceImpl } from './packages/server/src/server/workspace-git-service.ts'; + import { runGitCommand } from './packages/server/src/utils/run-git-command.ts'; + import { writePaseoWorktreeMetadata } from './packages/server/src/utils/worktree-metadata.ts'; + const root = await mkdtemp(join(tmpdir(), 'git-async-contract-')); + const home = join(root, 'home'); + const repo = join(root, 'repo'); + const worktree = join(home, 'worktrees', 'project', 'topic'); + const alias = join(root, 'alias'); + const logger = pino({ level: 'trace' }, { write() {} }); + // Async descriptor writes avoid Windows stderr's synchronous stream API. + const marker = (label) => new Promise((resolve, reject) => { + write(2, label + '\\n', (error) => error ? reject(error) : resolve()); + }); + let callback; + let notifications = 0; + const service = new WorkspaceGitServiceImpl({ logger, paseoHome: home, deps: { + subscribe: async (_root, onChange) => { + callback = onChange; + return { updateIgnore: async () => {}, unsubscribe: async () => {} }; + } + } }); + try { + await mkdir(repo); + await runGitCommand(['init', '-b', 'main'], { cwd: repo }); + await runGitCommand(['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--allow-empty', '-m', 'base'], { cwd: repo }); + await mkdir(join(home, 'worktrees', 'project'), { recursive: true }); + await runGitCommand(['worktree', 'add', '-b', 'topic', worktree], { cwd: repo }); + await writePaseoWorktreeMetadata(worktree, { baseRefName: 'main' }); + await writeFile(join(worktree, '.gitignore'), 'ignored/\\n'); + await mkdir(join(worktree, 'ignored')); + await writeFile(join(worktree, 'ignored', 'noise'), 'noise'); + await symlink(worktree, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const subscription = await service.requestWorkingTreeWatch(alias, () => { notifications++; }); + const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') })); + let fsCalls = 0; + const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } }); + // Collect repository setup and event-fixture garbage before measuring + // the cold filter. Allocations made by the filter still count. + global.gc(); + await tick(); + await marker('FILTER_START'); + // Schedule the control only after marker IO settles, so it cannot run + // and allocate garbage before the cold filter starts. No await may + // separate this scheduling from the synchronous measurement. + const threadCpuUsage = Reflect.get(process, 'threadCpuUsage'); + let unrelatedWorkStarted = false; + const unrelatedWork = tick().then(() => { + unrelatedWorkStarted = true; + const started = threadCpuUsage(); + while (true) { + const cpu = threadCpuUsage(started); + if (cpu.user + cpu.system >= 80000) break; + } + }); + assert.equal(unrelatedWorkStarted, false, 'control work must not contaminate the cold filter'); + hook.enable(); + let blockingMs; + try { + // This callback is synchronous: measure its own span, without waiting + // for marker IO, loader messages or other event-loop callbacks. + const startedAt = performance.now(); + const startedCpu = threadCpuUsage(); + callback(null, events); + const cpu = threadCpuUsage(startedCpu); + // Cap coarse Windows CPU accounting by elapsed time; exclude descheduling. + blockingMs = Math.min(performance.now() - startedAt, (cpu.user + cpu.system) / 1000); + } finally { + hook.disable(); + await marker('FILTER_END'); + } + await unrelatedWork; + assert.equal(unrelatedWorkStarted, true, 'control work must run outside the filter span'); + assert.equal(fsCalls, 0, "filesystem calls"); + assert.equal(notifications, 0, "ignored event notification"); + assert.ok(blockingMs < 20, 'blocking filter work: ' + blockingMs); + console.log(JSON.stringify({ blockingMs, fsCalls })); + // Alias identity remains useful after deletion: no per-event realpath can recover this file. + callback(null, [{ type: 'delete', path: join(worktree, 'tracked.txt') }]); + assert.equal(notifications, 1); + await marker('REFRESH_START'); + const snapshot = await service.getSnapshot(worktree); + assert.equal(snapshot.git.isGit, true); + assert.equal(snapshot.git.isPaseoOwnedWorktree, true); + await runGitCommand(['status', '--porcelain'], { cwd: alias, logger }); + await marker('REFRESH_END'); + subscription.unsubscribe(); + } finally { + await service.dispose(); + await rm(root, { recursive: true, force: true }); + } + `; + for (const tracing of [false, true]) { + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + [ + "--expose-gc", + "--import", + "tsx", + ...(tracing ? ["--trace-sync-io"] : []), + "--input-type=module", + "--eval", + source, + ], + { + cwd: repositoryRoot, + env: { ...process.env, GIT_IO_TRACE: tracing ? "1" : "" }, + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }, + ).catch((error) => { + throw new Error(String(error.stderr).replaceAll(/at .*/g, "[child stack omitted]")); + }); + for (const phase of ["FILTER", "REFRESH"]) { + const output = stderr.split(`${phase}_START\n`)[1]?.split(`${phase}_END\n`)[0]; + expect(output, `${phase}: synchronous filesystem IO`).toBe(""); + } + const measurement = JSON.parse(stdout.trim().split("\n")[0]); + console.info("Git filter budget", { tracing, ...measurement }); + expect(measurement).toMatchObject({ fsCalls: 0 }); + } +}, 60_000); + +test("Git event identity preserves Windows namespace, case and subtree boundaries", async () => { + const { GitObservationPaths } = await import("./git-observation-paths.js"); + const paths = await GitObservationPaths.create("C:\\paseo-observation-contract"); + const accept = paths.createEventFilter(["C:\\paseo-observation-contract\\Build"]); + expect(paths.relative("\\\\?\\C:\\PASEO-OBSERVATION-CONTRACT\\refs\\heads\\MixedCase")).toBe( + "refs/heads/MixedCase", + ); + expect(accept("\\\\?\\C:\\paseo-observation-contract\\build\\noise")).toBe(false); + expect(accept("C:\\paseo-observation-contract\\BuildSibling\\file")).toBe(true); +}); + +test("concurrent alias requests establish one working-tree subscription", async () => { + const { symlink } = await import("node:fs/promises"); + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-concurrent-"))); + const repo = path.join(root, "repo"); + const alias = path.join(root, "alias"); + execFileSync("git", ["init", "-b", "main", repo], { stdio: "pipe" }); + await symlink(repo, alias, process.platform === "win32" ? "junction" : "dir"); + let subscriptions = 0; + const service = new WorkspaceGitServiceImpl({ + logger: pino({ enabled: false }), + paseoHome: path.join(root, "home"), + deps: { + subscribe: async () => { + subscriptions++; + return { updateIgnore: async () => {}, unsubscribe: async () => {} }; + }, + }, + }); + try { + const watches = await Promise.all([ + service.requestWorkingTreeWatch(repo, () => {}), + service.requestWorkingTreeWatch(alias, () => {}), + ]); + expect(subscriptions).toBe(1); + for (const watch of watches) watch.unsubscribe(); + } finally { + await service.dispose(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("Git event identity retains a symlink's literal target before canonicalizing its parent", async () => { + const { mkdir, symlink } = await import("node:fs/promises"); + const { GitObservationPaths } = await import("./git-observation-paths.js"); + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-literal-target-"))); + const repository = path.join(root, "repository"); + const parentAlias = path.join(root, "parent-alias"); + const workspaceAlias = path.join(root, "workspace-alias"); + const literalTarget = path.join(parentAlias, "repository"); + const linkType = process.platform === "win32" ? "junction" : "dir"; + try { + await mkdir(repository); + await symlink(root, parentAlias, linkType); + await symlink(literalTarget, workspaceAlias, linkType); + const paths = await GitObservationPaths.create(workspaceAlias); + const accept = paths.createEventFilter([path.join(workspaceAlias, "ignored")]); + for (const spelling of [workspaceAlias, literalTarget, repository]) { + expect(paths.relative(path.join(spelling, "deleted.txt"))).toBe("deleted.txt"); + expect(accept(path.join(spelling, "ignored", "deleted.txt"))).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); 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 f91a96e464..ab93558793 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -1189,7 +1189,7 @@ describe("WorkspaceGitService checkout observation", () => { ]); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { - expect(getCalledCwds(getCheckoutStatus)).toEqual([WORKTREE_A, WORKTREE_B]); + expect(getCalledCwds(getCheckoutStatus).sort()).toEqual([WORKTREE_A, WORKTREE_B].sort()); }); first.unsubscribe(); @@ -1624,6 +1624,12 @@ describe("WorkspaceGitService checkout observation", () => { const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { + expect(watcher.subscribe).toHaveBeenCalledWith( + GIT_DIR, + expect.any(Function), + expect.any(Object), + ); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); @@ -1642,14 +1648,17 @@ describe("WorkspaceGitService checkout observation", () => { [60_000, 6], ] as const) { await vi.advanceTimersByTimeAsync(elapsedMs); - expect(pollCount()).toBe(expectedPolls); + await vi.waitFor(() => { + expect(pollCount()).toBe(expectedPolls); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); } isDirty = true; await vi.advanceTimersByTimeAsync(60_000); - expect(pollCount()).toBe(7); + await vi.waitFor(() => expect(pollCount()).toBe(7)); await vi.advanceTimersByTimeAsync(5_000); - expect(pollCount()).toBe(8); + await vi.waitFor(() => expect(pollCount()).toBe(8)); subscription.unsubscribe(); service.dispose(); @@ -1860,8 +1869,10 @@ describe("WorkspaceGitService checkout observation", () => { expect(watcher.subscribe).toHaveBeenCalledTimes(1); }); await vi.advanceTimersByTimeAsync(35_000); - expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0); - expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0); + await vi.waitFor(() => { + expect(service.getMetrics().workingTreeWatchSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceObservationSetupAdmissionActiveCount).toBe(0); + }); expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1); openedSubscription.resolve({ diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 9a3599740c..1b43026c7d 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -991,7 +991,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await vi.advanceTimersByTimeAsync(60_000); await flushPromises(); - expect(subscribe).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2)); expect(getCheckoutStatus).toHaveBeenCalledTimes(1); subscription.unsubscribe(); @@ -2221,7 +2221,7 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); - service.dispose(); + await service.dispose(); }); test("onWorkspaceStateMayHaveChanged preserves includeForge when a file watcher fires within the debounce window", async () => { diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 4b0b13b7a2..4e6acff9cd 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -796,15 +796,21 @@ describe("WorkspaceGitServiceImpl", () => { const service = createService({ getCheckoutStatus, subscribe, runGitCommand }); cwds.forEach((cwd) => service.registerWorkspace({ cwd }, vi.fn())); - await vi.waitFor(() => { - expect(service.getMetrics()).toMatchObject({ - workspaceRefreshAdmissionActiveCount: WORKSPACE_GIT_REFRESH_CONCURRENCY, - workspaceRefreshAdmissionPendingCount: 1, - workspaceObservationSetupAdmissionActiveCount: WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY, - workspaceObservationSetupAdmissionPendingCount: - cwds.length - WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY, - }); - }); + await vi.waitFor( + () => { + expect(service.getMetrics()).toMatchObject({ + workspaceRefreshAdmissionActiveCount: WORKSPACE_GIT_REFRESH_CONCURRENCY, + workspaceRefreshAdmissionPendingCount: 1, + workspaceObservationSetupAdmissionActiveCount: + WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY, + workspaceObservationSetupAdmissionPendingCount: + cwds.length - WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY, + }); + // Admission precedes asynchronous path setup; dispose only once watchers are pending. + expect(subscribe).toHaveBeenCalledTimes(WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY); + }, + { timeout: 10_000 }, + ); const queuedRefreshSettlement = service.getSnapshot(cwds.at(-1)!).then( () => ({ status: "fulfilled" as const, error: null }), (error: unknown) => ({ status: "rejected" as const, error }), @@ -1105,12 +1111,23 @@ describe("WorkspaceGitServiceImpl", () => { { cwd: join(REPO_CWD, "packages", "server") }, vi.fn(), ); - await vi.waitFor(() => { - expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); - }); + await vi.waitFor( + () => { + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); + expect(service.getMetrics()).toMatchObject({ + repositoryWorkspaceLinkCount: 2, + workspaceObservationSetupInFlightCount: 0, + workspaceRefreshInFlightCount: 0, + fetchInFlightCount: 1, + }); + }, + { timeout: 10_000 }, + ); releaseFetch.resolve(); - await flushPromises(); + await vi.waitFor(() => { + expect(service.getMetrics().fetchInFlightCount).toBe(0); + }); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(4); @@ -1154,10 +1171,14 @@ describe("WorkspaceGitServiceImpl", () => { const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); await vi.waitFor(() => { expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + expect(service.getMetrics().fetchInFlightCount).toBe(1); }); releaseFetch.resolve(); - await flushPromises(); + // The refresh debounce starts when the fetch result is applied, not when it is released. + await vi.waitFor(() => { + expect(service.getMetrics().fetchInFlightCount).toBe(0); + }); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index dcd2ead214..b5672846bd 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -42,18 +42,13 @@ import { type ForgeResolver, } from "../services/forge-resolver.js"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; -import { - createRealpathAwarePathMatcher, - getRealpathAwareRelativePath, - isPathInsideRoot, - isRealpathInsideRoot, - looksLikeDefiniteWindowsPath, -} from "../utils/path.js"; +import { isPathInsideRoot, looksLikeDefiniteWindowsPath } from "../utils/path.js"; import { createRunGitCommand, runGitCommand, type RunGitCommand, } from "../utils/run-git-command.js"; +import { GitObservationPaths } from "./git-observation-paths.js"; import { branchNameFromRef } from "../utils/worktree-metadata.js"; import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; @@ -417,6 +412,7 @@ class WorkspaceGitWatcherSubscriptionTimeoutError extends Error { } interface WorkspaceGitTarget { + gitDirPaths: GitObservationPaths | null; cwd: string; listeners: Set; workingTreeWatchTarget: WorkingTreeWatchTarget | null; @@ -443,6 +439,7 @@ interface WorkspaceGitTarget { } interface RepoGitTarget { + paths: GitObservationPaths; repoGitRoot: string; cwd: string; workspaceKeys: Set; @@ -470,6 +467,8 @@ interface RepoMetadataWorkspaceRefresh { } interface WorkingTreeWatchTarget { + paths: GitObservationPaths; + acceptsEvent: (candidate: string) => boolean; cwd: string; watchPath: string; repoRoot: string | null; @@ -815,11 +814,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { cwd: normalizedCwd, compare: normalizedOptions, ...readOptions, - load: () => - this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - }), + load: (signal) => + this.deps.getCheckoutDiff( + normalizedCwd, + normalizedOptions, + { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }, + signal, + ), }); } @@ -1165,6 +1169,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private async resolveWorkingTreeWatchTarget(cwd: string): Promise { const repoRoot = await this.resolveCheckoutWatchRoot(cwd); + const cwdPaths = repoRoot ? await GitObservationPaths.create(cwd) : null; + this.assertNotDisposed(); const targetCwd = repoRoot ?? cwd; const existingTarget = this.workingTreeWatchTargets.get(targetCwd); if (existingTarget) { @@ -1179,7 +1185,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return target; } - const watchPath = repoRoot && createRealpathAwarePathMatcher(repoRoot)(cwd) ? cwd : targetCwd; + const watchPath = repoRoot && cwdPaths?.matches(repoRoot) ? cwd : targetCwd; const setup = this.createWorkingTreeWatchTarget(targetCwd, watchPath, repoRoot).finally(() => { if (this.workingTreeWatchSetups.get(targetCwd) === setup) { this.workingTreeWatchSetups.delete(targetCwd); @@ -1198,6 +1204,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private createWorkspaceTarget(cwd: string): WorkspaceGitTarget { const target: WorkspaceGitTarget = { + gitDirPaths: null, cwd, listeners: new Set(), workingTreeWatchTarget: null, @@ -1299,6 +1306,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } await this.promoteWorkingTreeWatchTarget(workingTreeTarget, facts.worktreeRoot); const gitDir = facts.absoluteGitDir; + const gitDirPaths = target.gitDirPaths?.matches(gitDir) + ? target.gitDirPaths + : await GitObservationPaths.create(gitDir); + if (!this.isActiveObservedWorkspaceTarget(target)) return; + target.gitDirPaths = gitDirPaths; const repoGitRoot = facts.gitCommonDir ?? (await this.resolveWorkspaceGitRefsRoot(gitDir)); if (!this.isActiveObservedWorkspaceTarget(target)) { return; @@ -1331,7 +1343,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const promise = this.deps .getCheckoutSnapshotFacts(target.cwd, context) .then((facts) => { - target.latestFacts = facts; + if (!target.closed) target.latestFacts = facts; return facts; }) .finally(() => { @@ -1359,7 +1371,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const ignoredDirectories = repoRoot ? (await this.loadIgnoredDirs(watchPath)).ignored : new Set(); + const paths = await GitObservationPaths.create(watchPath); + this.assertNotDisposed(); const target: WorkingTreeWatchTarget = { + paths, + acceptsEvent: paths.createEventFilter([join(watchPath, ".git"), ...ignoredDirectories]), cwd, watchPath, repoRoot, @@ -1523,15 +1539,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (watcherErrored) { return; } + events = this.relevantWorkingTreeEvents(target, events); + if (events.length === 0) return; const discovered = this.noteWorkingTreeDirectories(target, events); if (events.some((event) => basename(event.path) === ".gitignore")) { void this.refreshWorkingTreeIgnoredDirectories(target); } else if (discovered) { this.scheduleWorkingTreeIgnoreRefresh(target); } - if (!this.hasRelevantWorkingTreeEvent(target, events)) { - return; - } this.notifyWorkingTreeChanged(target, "working-tree-watch"); }, { ignore }, @@ -1763,20 +1778,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } this.removeDeletedKnownDirectories(target, events); const gitDir = join(target.watchPath, ".git"); - // `matchesWatchPath` is realpath-aware because it compares an event-reported - // directory against `target.watchPath` itself — a config-time root that can - // legitimately differ from the watcher's reported form (e.g. a symlinked - // temp dir). It is now gated behind the plain Set lookup below, so it only - // runs for a directory this target has never seen before, not on every - // event. - const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); + // Event paths have already been mapped into this subscription's established namespace. + const matchesWatchPath = target.paths.matches; // One new directory is enough: the scheduled Git query inventories every // ignored directory that exists by then. Stop before a large event batch // multiplies containment checks across all its new directories. for (const event of events) { const directory = dirname(event.path); // Cheap Set lookup first — the steady-state case where the directory is - // already known must never reach the realpath-aware matcher below. + // already known do not need another root comparison. if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) { continue; } @@ -1955,28 +1965,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.pendingKnownDirectories.clear(); } - private hasRelevantWorkingTreeEvent( + private relevantWorkingTreeEvents( target: WorkingTreeWatchTarget, events: FileChange[], - ): boolean { - const gitDir = join(target.watchPath, ".git"); - const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); - return events.some((event) => { - // Directory metadata changes at the watch root do not change Git state. - // FSEvents may emit these when every changed descendant was ignored. - if (matchesWatchPath(event.path)) { - return false; + ): FileChange[] { + const relevant: FileChange[] = []; + // Watch backends may report the same path more than once in a batch. + const acceptedPaths = new Map(); + for (const event of events) { + let accepted = acceptedPaths.get(event.path); + if (accepted === undefined) { + accepted = target.acceptsEvent(event.path); + acceptedPaths.set(event.path, accepted); } - if (isRealpathInsideRoot(gitDir, event.path)) { - return false; - } - for (const ignoredDirectory of target.ignoredDirectories) { - if (isRealpathInsideRoot(ignoredDirectory, event.path)) { - return false; - } - } - return true; - }); + if (accepted) relevant.push({ ...event, path: target.paths.toWatchPath(event.path) }); + } + return relevant; } private refreshWorkingTreeIgnoredDirectories(target: WorkingTreeWatchTarget): Promise { @@ -2031,6 +2035,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } if (!this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) { target.ignoredDirectories = ignoredDirectories; + target.acceptsEvent = target.paths.createEventFilter(this.workingTreeIgnoreList(target)); this.pruneKnownDirectories(target); } if (target.fallbackPolling) { @@ -2152,7 +2157,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } + const paths = await GitObservationPaths.create(repoGitRoot); + if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) return; + const establishedTarget = this.repoTargets.get(repoGitRoot); + if (establishedTarget) { + establishedTarget.workspaceKeys.add(workspaceTarget.cwd); + return; + } const repoTarget: RepoGitTarget = { + paths, repoGitRoot, cwd: workspaceTarget.cwd, workspaceKeys: new Set([workspaceTarget.cwd]), @@ -2205,10 +2218,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target: RepoGitTarget, options?: { replaceFallback?: boolean }, ): Promise { + const paths = target.paths; const ignore = getPrunedGitMetadataPaths("common").map((path) => join(target.repoGitRoot, path), ); - const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + const matchesRepoGitRoot = paths.matches; const canary = this.deps.createWatcherLivenessCanary(target.repoGitRoot); let openedSubscription: FileObserverSubscription | null = null; let watcherErrored = false; @@ -2223,7 +2237,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const subscription = await this.subscribeWithDeadline( target.repoGitRoot, (error, events) => { - const liveEvents = canary.filterEvents(events); + const liveEvents = canary.filterEvents( + events.map((event) => ({ ...event, path: paths.toWatchPath(event.path) })), + ); if (error) { if (watcherErrored) { return; @@ -2249,7 +2265,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const relevantEvents = liveEvents.filter( (event) => !matchesRepoGitRoot(event.path) && - ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)), + ignore.every((ignoredPath) => !isPathInsideRoot(ignoredPath, event.path)), ); if (relevantEvents.length > 0) { const immediateEvents = target.fetchInFlight @@ -2362,7 +2378,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { events: FileChange[], ): Map | null { const refreshes = new Map(); - const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + const matchesRepoGitRoot = target.paths.matches; for (const event of events) { if (!this.routeRepoMetadataEvent(target, event, matchesRepoGitRoot, refreshes)) return null; @@ -2377,10 +2393,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): void { const workspaceKeys = new Set(); for (const event of events) { - const commonRelativePath = getRealpathAwareRelativePath( - target.repoGitRoot, - event.path, - )?.replaceAll("\\", "/"); + const commonRelativePath = target.paths.relative(event.path)?.replaceAll("\\", "/"); if (commonRelativePath === "config" || commonRelativePath === "info/exclude") { for (const workspaceKey of target.workspaceKeys) workspaceKeys.add(workspaceKey); continue; @@ -2390,8 +2403,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if ( facts?.isGit && facts.absoluteGitDir && - getRealpathAwareRelativePath(facts.absoluteGitDir, event.path)?.replaceAll("\\", "/") === - "config.worktree" + this.workspaceTargets + .get(workspaceKey) + ?.gitDirPaths?.relative(event.path) + ?.replaceAll("\\", "/") === "config.worktree" ) { workspaceKeys.add(workspaceKey); } @@ -2407,7 +2422,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean { - const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path); + const relativePath = target.paths.relative(event.path); const effect = classifyGitMetadataPath("common", relativePath ?? ""); return ( (effect.kind === "ref" && effect.namespace === "remote") || @@ -2419,12 +2434,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routeRepoMetadataEvent( target: RepoGitTarget, event: FileChange, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshes: Map, ): boolean { if (this.routePrivateGitDirEvent(target, event, matchesRepoGitRoot, refreshes)) return true; - const commonRelativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path); + const commonRelativePath = target.paths.relative(event.path); const effect = classifyGitMetadataPath("common", commonRelativePath ?? ""); switch (effect.kind) { case "ignore": @@ -2476,7 +2491,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routePrivateGitDirEvent( target: RepoGitTarget, event: FileChange, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshes: Map, ): boolean { let matched = false; @@ -2485,8 +2500,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!facts?.isGit || !facts.absoluteGitDir || matchesRepoGitRoot(facts.absoluteGitDir)) { continue; } - const relativePath = getRealpathAwareRelativePath(facts.absoluteGitDir, event.path); - if (relativePath === null) continue; + const relativePath = this.workspaceTargets + .get(workspaceKey) + ?.gitDirPaths?.relative(event.path); + if (relativePath == null) continue; matched = true; const effect = classifyGitMetadataPath("worktree", relativePath); if (effect.kind === "owner") { @@ -2500,7 +2517,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private routeMainCheckoutMetadata( target: RepoGitTarget, - matchesRepoGitRoot: ReturnType, + matchesRepoGitRoot: (candidate: string) => boolean, refreshBase: boolean, refreshes: Map, ): void { diff --git a/packages/server/src/server/workspace-reconciliation-observation.test.ts b/packages/server/src/server/workspace-reconciliation-observation.test.ts index 8977641bab..44bb9de08e 100644 --- a/packages/server/src/server/workspace-reconciliation-observation.test.ts +++ b/packages/server/src/server/workspace-reconciliation-observation.test.ts @@ -495,6 +495,10 @@ describe("observed workspace placement", () => { expect((await observed.placement("workspace-one"))?.archivedAt).toEqual(expect.any(String)); expect(observed.workspaceBatches).toEqual([["workspace-one"]]); + const archived = await observed.placement("workspace-one"); + await observed.advanceBy(RESCAN_INTERVAL_MS); + expect(await observed.placement("workspace-one")).toEqual(archived); + expect(observed.workspaceBatches).toEqual([["workspace-one"]]); observed.dispose(); }); diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 52c7d41065..b818804912 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -1,26 +1,45 @@ -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { build } from "esbuild"; +import { + createTestRegistries, + createTestLogger, + deferred, +} from "./test-fixtures/reconciliation-registries.js"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type pino from "pino"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterAll, beforeAll, afterEach, describe, expect, test } from "vitest"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, } from "./workspace-registry.js"; -import type { - PersistedProjectRecord, - PersistedWorkspaceRecord, - ProjectRegistry, - WorkspaceRegistry, -} from "./workspace-registry.js"; import { type ReconciliationChange, WorkspaceReconciliationService, } from "./workspace-reconciliation-service.js"; import { deriveProjectKey } from "./project-key.js"; +let probeDirectory: string; +let probePath: string; +beforeAll(async () => { + probeDirectory = mkdtempSync(path.join(tmpdir(), "reconciliation-probe-")); + probePath = path.join(probeDirectory, "reconciliation.mjs"); + await build({ + entryPoints: [ + fileURLToPath(new URL("./test-fixtures/reconciliation-responsiveness.ts", import.meta.url)), + ], + outfile: probePath, + bundle: true, + platform: "node", + format: "esm", + }); +}); +afterAll(() => rmSync(probeDirectory, { recursive: true, force: true })); + function canonicalLocalProjectKey(rootPath: string): string { return deriveProjectKey({ rootPath, @@ -30,86 +49,6 @@ function canonicalLocalProjectKey(rootPath: string): string { }); } -function createTestRegistries() { - const projects = new Map(); - const workspaces = new Map(); - - const projectRegistry: ProjectRegistry = { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => Array.from(projects.values()), - get: async (id: string) => projects.get(id) ?? null, - getOrCreateActiveByRoot: async (input) => { - const existing = Array.from(projects.values()).find( - (project) => !project.archivedAt && project.rootPath === input.rootPath, - ); - if (existing) return existing; - const record = createPersistedProjectRecord({ - projectId: `prj_${projects.size}`, - rootPath: input.rootPath, - kind: input.kind, - displayName: input.displayName, - createdAt: input.timestamp, - updatedAt: input.timestamp, - }); - projects.set(record.projectId, record); - return record; - }, - upsert: async (record: PersistedProjectRecord) => { - projects.set(record.projectId, record); - }, - archive: async (id: string, archivedAt: string) => { - const existing = projects.get(id); - if (existing) { - projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); - } - }, - remove: async (id: string) => { - projects.delete(id); - }, - }; - - const workspaceRegistry: WorkspaceRegistry = { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => Array.from(workspaces.values()), - get: async (id: string) => workspaces.get(id) ?? null, - update: async (id, updater) => { - const existing = workspaces.get(id); - if (!existing) return null; - const updated = updater(existing); - workspaces.set(id, updated); - return updated; - }, - upsert: async (record: PersistedWorkspaceRecord) => { - workspaces.set(record.workspaceId, record); - }, - archive: async (id: string, archivedAt: string) => { - const existing = workspaces.get(id); - if (existing) { - workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt }); - } - }, - remove: async (id: string) => { - workspaces.delete(id); - }, - }; - - return { projects, workspaces, projectRegistry, workspaceRegistry }; -} - -function createTestLogger() { - const logger = { - child: () => logger, - trace: () => undefined, - debug: () => undefined, - info: () => undefined, - warn: () => undefined, - error: () => undefined, - }; - return logger as unknown as pino.Logger; -} - interface CapturedLogRecord { message: string; payload: unknown; @@ -117,6 +56,7 @@ interface CapturedLogRecord { function createCapturingLogger() { const infoRecords: CapturedLogRecord[] = []; + const warnRecords: CapturedLogRecord[] = []; const logger = { child: () => logger, trace: () => undefined, @@ -124,10 +64,12 @@ function createCapturingLogger() { info: (payload: unknown, message?: string) => { infoRecords.push({ payload, message: message ?? "" }); }, - warn: () => undefined, + warn: (payload: unknown, message?: string) => { + warnRecords.push({ payload, message: message ?? "" }); + }, error: () => undefined, }; - return { logger: logger as unknown as pino.Logger, infoRecords }; + return { logger: logger as unknown as pino.Logger, infoRecords, warnRecords }; } function createWorkspaceGitServiceStub( @@ -185,14 +127,6 @@ function createCheckout( }; } -function deferred(): { promise: Promise; resolve(): void } { - let resolve!: () => void; - const promise = new Promise((accept) => { - resolve = accept; - }); - return { promise, resolve }; -} - class TestCheckouts { readonly reads: string[] = []; private readonly checkouts = new Map(); @@ -241,6 +175,416 @@ describe("WorkspaceReconciliationService", () => { tempDirs.length = 0; }); + test("does not emit a duplicate archive when user archival commits before reconciliation's archive", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-archive-commit-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry, holdNextArchive } = + createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: path.join(rootPath, "missing"), + kind: "directory", + displayName: "workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const callbacks: string[] = []; + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + onWorkspaceArchived: (id) => { + callbacks.push(id); + }, + }); + const gate = holdNextArchive(); + try { + const scan = service.runOnce(); + await gate.started.promise; + expect(await workspaceRegistry.archive("w1", timestamp)).toBe(true); + gate.release.resolve(); + expect((await scan).changesApplied).toEqual([]); + expect(callbacks).toEqual([]); + expect(workspaces.get("w1")?.archivedAt).toBe(timestamp); + } finally { + gate.release.resolve(); + service.dispose(); + } + }); + + test("preserves paths while two inspections saturate capacity, logs once, and recovers after they drain", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-saturated-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + const { logger, warnRecords, infoRecords } = createCapturingLogger(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const ids = ["hung1", "hung2", "healthy"]; + for (const workspaceId of ids) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const release = deferred(); + const calls: string[] = []; + let recovered = false; + let ticks = 0; + const probe = setInterval(() => ticks++, 1); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger, + inspectionTimeoutMs: 30, + inspectDirectory: async (targetPath) => { + calls.push(targetPath); + if (!recovered && path.basename(targetPath).startsWith("hung")) await release.promise; + return "directory"; + }, + }); + try { + const result = await service.runOnce(); + expect(ticks).toBeGreaterThan(0); + expect(result.durationMs).toBeLessThan(1000); + expect(result.changesApplied).toEqual([]); + expect(calls).toEqual(ids.slice(0, 2).map((id) => path.join(rootPath, id))); + expect([...workspaces.values()].map((workspace) => workspace.archivedAt)).toEqual([ + null, + null, + null, + ]); + expect(warnRecords.filter((record) => record.message.includes("saturated"))).toEqual([ + { + message: "Workspace directory inspection saturated; preserving uninspected workspaces", + payload: { paths: ids.slice(0, 2).map((id) => path.join(rootPath, id)) }, + }, + ]); + recovered = true; + release.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + expect(infoRecords).toEqual([ + { + message: + "Timed-out workspace directory inspections drained; reconciliation capacity restored", + payload: {}, + }, + ]); + calls.length = 0; + await service.runOnce(); + expect(calls).toEqual([...ids.map((id) => path.join(rootPath, id)), rootPath]); + expect(warnRecords.filter((record) => record.message.includes("saturated"))).toHaveLength(1); + } finally { + release.resolve(); + clearInterval(probe); + service.dispose(); + } + }); + + test("inspects every healthy path when one hung inspection times out during a longer scan", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-long-hung-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const healthyIds = Array.from({ length: 12 }, (_, i) => `healthy${i}`); + for (const workspaceId of ["hung", ...healthyIds]) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const calls: string[] = []; + let outstanding = 0; + let maximumOutstanding = 0; + const { logger, warnRecords } = createCapturingLogger(); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger, + inspectionTimeoutMs: 100, + inspectDirectory: async (targetPath) => { + calls.push(targetPath); + maximumOutstanding = Math.max(maximumOutstanding, ++outstanding); + if (path.basename(targetPath) === "hung") return new Promise(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + outstanding--; + return "directory"; + }, + }); + try { + const result = await service.runOnce(); + expect(result.durationMs).toBeGreaterThan(100); + expect(calls).toEqual( + ["hung", ...healthyIds].map((id) => path.join(rootPath, id)).concat(rootPath), + ); + expect(maximumOutstanding).toBe(2); + expect(outstanding).toBe(1); + expect(workspaces.get("hung")?.archivedAt).toBeNull(); + expect(result.changesApplied).toEqual([]); + expect(warnRecords.filter((record) => record.message.includes("saturated"))).toEqual([]); + } finally { + service.dispose(); + } + }); + + test("times out a hung inspection without archiving it or blocking healthy paths, and retains its IO slot", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-hung-"))); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + for (const workspaceId of ["hung", "healthy", "healthy2", "healthy3"]) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: "p1", + cwd: path.join(rootPath, workspaceId), + kind: "directory", + displayName: workspaceId, + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + const calls: string[] = []; + let outstanding = 0; + let maximumOutstanding = 0; + const healthyInspected = deferred(); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + inspectionTimeoutMs: 30, + inspectDirectory: async (targetPath) => { + calls.push(targetPath); + maximumOutstanding = Math.max(maximumOutstanding, ++outstanding); + if (path.basename(targetPath) === "hung") return new Promise(() => {}); + healthyInspected.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + outstanding--; + return "directory"; + }, + }); + try { + const scan = service.runOnce(); + await Promise.race([ + healthyInspected.promise, + scan.then(() => { + throw new Error("Reconciliation did not use its directory inspection capability"); + }), + ]); + expect(outstanding).toBeLessThanOrEqual(2); + expect(workspaces.get("hung")?.archivedAt).toBeNull(); + const result = await scan; + expect(result.changesApplied).toEqual([]); + await service.runOnce(); + expect(calls.filter((cwd) => path.basename(cwd) === "hung")).toHaveLength(1); + expect(calls.filter((cwd) => path.basename(cwd) === "healthy3")).toHaveLength(2); + expect(maximumOutstanding).toBe(2); + expect([...workspaces.values()].map((workspace) => workspace.archivedAt)).toEqual([ + null, + null, + null, + null, + ]); + } finally { + service.dispose(); + } + }); + + test.each(["missing", "directory"] as const)( + "does not process or archive a workspace archived during %s inspection", + async (state) => { + const rootPath = realpathSync( + mkdtempSync(path.join(tmpdir(), "reconcile-inspection-archive-")), + ); + tempDirs.push(rootPath); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath, + projectKey: canonicalLocalProjectKey(rootPath), + kind: "non_git", + displayName: "project", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const cwd = path.join(rootPath, "missing"); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd, + kind: "directory", + displayName: "workspace", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + const started = deferred(); + const release = deferred(); + const archivedCallbacks: string[] = []; + const checkouts = new TestCheckouts(); + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: checkouts, + onWorkspaceArchived: (id) => { + archivedCallbacks.push(id); + }, + inspectDirectory: async (targetPath) => { + if (targetPath === cwd) { + started.resolve(); + await release.promise; + return state; + } + return "directory"; + }, + }); + try { + const scan = service.runOnce(); + await Promise.race([ + started.promise, + scan.then(() => { + throw new Error("Reconciliation did not use its directory inspection capability"); + }), + ]); + await workspaceRegistry.archive("w1", "2026-01-01T00:00:00.000Z"); + const archived = workspaces.get("w1"); + release.resolve(); + const result = await scan; + expect(workspaces.get("w1")).toEqual(archived); + expect(archivedCallbacks).toEqual([]); + expect(checkouts.reads).toEqual([rootPath]); + expect(result.changesApplied).toEqual([]); + } finally { + release.resolve(); + service.dispose(); + } + }, + ); + + test("keeps the event loop responsive while inspecting 20000 real workspace paths", async () => { + const rootPath = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-many-"))); + tempDirs.push(rootPath); + // This CPU belongs to the runner, not to reconciliation. Keep it present + // throughout the read so a later loop turn cannot contaminate the budget. + let running = true; + const unrelatedWork = () => { + const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( + process, + "threadCpuUsage", + ); + const start = threadCpuUsage(); + let elapsed: NodeJS.CpuUsage; + do { + elapsed = threadCpuUsage(start); + } while (elapsed.user + elapsed.system < 60_000); + if (running) unrelated = setImmediate(unrelatedWork); + }; + let unrelated = setImmediate(unrelatedWork); + try { + const { stdout, stderr } = await promisify(execFile)( + process.execPath, + ["--expose-gc", probePath, rootPath], + { timeout: 25_000 }, + ); + process.stderr.write(stderr); + const { + changesApplied, + blockingMs, + filesystemRequests, + yields, + allArchived, + workerMessages, + } = JSON.parse(stdout) as { + changesApplied: number; + blockingMs: number; + filesystemRequests: number; + yields: number; + allArchived: boolean; + workerMessages: number; + }; + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBe(20001); + expect(changesApplied).toBe(20000); + expect(allArchived).toBe(true); + expect(yields).toBeGreaterThan(0); + expect(workerMessages).toBe(0); + } finally { + running = false; + clearImmediate(unrelated); + } + }, 30000); + test("preserves workspace archival that lands during boot reconciliation", async () => { const workspaceRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-archive-race-"))); tempDirs.push(workspaceRoot); diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index eb79dbacd2..5333da8f83 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -1,4 +1,6 @@ -import { statSync, watch as watchPath } from "node:fs"; +import { watch as watchPath } from "node:fs"; +import { stat } from "node:fs/promises"; +import { setImmediate as yieldToLoop } from "node:timers/promises"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type pino from "pino"; import type { @@ -19,6 +21,8 @@ import { deriveProjectKey } from "./project-key.js"; const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000; const DEFAULT_DEBOUNCE_MS = 100; +const INSPECTION_CONCURRENCY = 2; +const DEFAULT_INSPECTION_TIMEOUT_MS = 5000; export type ProjectUpdate = | { kind: "upsert"; project: PersistedProjectRecord } @@ -95,6 +99,8 @@ export interface WorkspaceReconciliationServiceOptions { clock?: ReconciliationClock; rescanIntervalMs?: number; debounceMs?: number; + inspectDirectory?: (targetPath: string) => Promise; + inspectionTimeoutMs?: number; } interface ProjectReconciliationInput { @@ -110,9 +116,17 @@ interface CachedCheckoutRead { checkout: Promise; } -type DirectoryState = "directory" | "missing" | "unreadable"; +export type DirectoryState = "directory" | "missing" | "unreadable"; export class WorkspaceReconciliationService { + // Timed-out fs operations retain their slot until the OS returns. Never use + // the deadline to admit more work into libuv's shared threadpool. + private readonly timedOutPaths = new Set(); + private inspectionSaturated = false; + private readonly inspectionWaiters = new Set<() => void>(); + private readonly pendingInspections = new Map>(); + private readonly directoryInspection: (targetPath: string) => Promise; + private readonly inspectionTimeoutMs: number; private readonly serverId: string | undefined; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; @@ -136,6 +150,10 @@ export class WorkspaceReconciliationService { private reconcileQueuedMode: "metadata" | "full" | null = null; constructor(options: WorkspaceReconciliationServiceOptions) { + this.directoryInspection = + options.inspectDirectory ?? + (async (targetPath) => ((await stat(targetPath)).isDirectory() ? "directory" : "missing")); + this.inspectionTimeoutMs = options.inspectionTimeoutMs ?? DEFAULT_INSPECTION_TIMEOUT_MS; this.serverId = options.serverId; this.projectRegistry = options.projectRegistry; this.workspaceRegistry = options.workspaceRegistry; @@ -180,6 +198,7 @@ export class WorkspaceReconciliationService { dispose(): void { this.disposed = true; + this.wakeInspectionWaiters(); this.unsubscribeRegistry?.(); this.unsubscribeRegistry = null; if (this.rescanTimer) this.clock.clearInterval(this.rescanTimer); @@ -197,16 +216,23 @@ export class WorkspaceReconciliationService { this.workspaceRegistry.list(), ]); const workspacesByProject = new Map(); - for (const workspace of workspaces) { - if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue; + const workspaceStates = await mapBounded( + workspaces.filter((w) => !w.archivedAt), + async (workspace) => ({ workspace, state: await this.inspectDirectory(workspace.cwd) }), + ); + for (const { workspace, state } of workspaceStates) { + if (state !== "directory") continue; + if ((await this.workspaceRegistry.get(workspace.workspaceId))?.archivedAt !== null) continue; const siblings = workspacesByProject.get(workspace.projectId) ?? []; siblings.push(workspace); workspacesByProject.set(workspace.projectId, siblings); } + const projectStates = await mapBounded( + projects.filter((p) => !p.archivedAt), + async (project) => ({ project, state: await this.inspectDirectory(project.rootPath) }), + ); await this.reconcileGitMetadataForProjects( - projects.filter( - (project) => !project.archivedAt && this.inspectDirectory(project.rootPath) === "directory", - ), + projectStates.filter(({ state }) => state === "directory").map(({ project }) => project), workspacesByProject, changes, ); @@ -222,23 +248,30 @@ export class WorkspaceReconciliationService { const allWorkspaces = await this.workspaceRegistry.list(); const activeProjects = allProjects.filter((p) => !p.archivedAt); + const activeProjectsById = new Map( + activeProjects.map((project) => [project.projectId, project]), + ); const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt); - const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({ + const workspaceDirectoryStates = await mapBounded(activeWorkspaces, async (workspace) => ({ workspace, - state: this.inspectDirectory(workspace.cwd), + state: await this.inspectDirectory(workspace.cwd), + })); + // Read project roots after workspaces: losing a volume can withhold an + // archive, never turn a workspace's absence into proof of deletion. + const projectStates = await mapBounded(activeProjects, async (project) => ({ + project, + state: await this.inspectDirectory(project.rootPath), })); - // Project roots are read after the workspace directories, so a volume that - // goes away mid-pass leaves its project unreachable rather than its workspaces - // alone. The skew can only withhold an archive, never produce one. const reachableProjectIds = new Set( - activeProjects - .filter((project) => this.inspectDirectory(project.rootPath) === "directory") - .map((project) => project.projectId), + projectStates + .filter(({ state }) => state === "directory") + .map(({ project }) => project.projectId), ); const workspacesByProject = new Map(); for (const { workspace, state } of workspaceDirectoryStates) { if (state !== "directory") continue; + if ((await this.workspaceRegistry.get(workspace.workspaceId))?.archivedAt !== null) continue; const list = workspacesByProject.get(workspace.projectId) ?? []; list.push(workspace); workspacesByProject.set(workspace.projectId, list); @@ -255,26 +288,29 @@ export class WorkspaceReconciliationService { state === "missing" && reachableProjectIds.has(workspace.projectId), ) .map(({ workspace }) => workspace); - await Promise.all( - missingWorkspaces.map(async (workspace) => { - const timestamp = new Date().toISOString(); - await this.workspaceRegistry.archive(workspace.workspaceId, timestamp); - await this.onWorkspaceArchived?.(workspace.workspaceId); - changes.push({ - kind: "workspace_archived", - workspaceId: workspace.workspaceId, - directory: workspace.cwd, - reason: "directory_missing", - }); - - // Update the in-memory list for the project orphan check below - const siblings = workspacesByProject.get(workspace.projectId); - if (siblings) { - const updated = siblings.filter((w) => w.workspaceId !== workspace.workspaceId); - workspacesByProject.set(workspace.projectId, updated); - } - }), - ); + await mapBounded(missingWorkspaces, async (workspace) => { + const current = await this.workspaceRegistry.get(workspace.workspaceId); + const project = await this.projectRegistry.get(workspace.projectId); + if ( + !current || + current.archivedAt || + current.cwd !== workspace.cwd || + current.projectId !== workspace.projectId || + !project || + project.archivedAt || + project.rootPath !== activeProjectsById.get(project.projectId)?.rootPath + ) + return; + const timestamp = new Date().toISOString(); + if (!(await this.workspaceRegistry.archive(workspace.workspaceId, timestamp))) return; + await this.onWorkspaceArchived?.(workspace.workspaceId); + changes.push({ + kind: "workspace_archived", + workspaceId: workspace.workspaceId, + directory: workspace.cwd, + reason: "directory_missing", + }); + }); // 2. Reconcile mutable git metadata without changing identity or membership. // Projects persist until explicitly removed, even when they currently have @@ -351,13 +387,17 @@ export class WorkspaceReconciliationService { } private async reconcileProject(input: ProjectReconciliationInput): Promise { - const { project, siblings, currentGit, readCheckout, changes } = input; + const { siblings, currentGit, readCheckout, changes } = input; const workspaceCheckouts = await Promise.all( - siblings.map(async (workspace) => ({ - workspace, - checkout: await readCheckout(workspace.cwd), - })), + siblings.map(async (workspace) => { + const current = await this.workspaceRegistry.get(workspace.workspaceId); + if (!current || current.cwd !== workspace.cwd || current.projectId !== workspace.projectId) + return null; + return { workspace: current, checkout: await readCheckout(current.cwd) }; + }), ); + const project = await this.projectRegistry.get(input.project.projectId); + if (!project || project.archivedAt || project.rootPath !== input.project.rootPath) return; const projectUpdates: Partial> = {}; const mappedKind = deriveProjectKind(currentGit); const projectKey = deriveProjectKey({ @@ -377,40 +417,47 @@ export class WorkspaceReconciliationService { if (Object.keys(projectUpdates).length > 0) { const timestamp = new Date().toISOString(); - await this.projectRegistry.upsert({ - ...project, - ...projectUpdates, - updatedAt: timestamp, - }); - changes.push({ - kind: "project_updated", - projectId: project.projectId, - directory: project.rootPath, - fields: projectUpdates, + let applied = false; + await this.projectRegistry.update(project.projectId, (current) => { + if (current.archivedAt || current.rootPath !== project.rootPath) return current; + applied = true; + return { ...current, ...projectUpdates, updatedAt: timestamp }; }); + if (applied) + changes.push({ + kind: "project_updated", + projectId: project.projectId, + directory: project.rootPath, + fields: projectUpdates, + }); } await Promise.all( - workspaceCheckouts.map(async ({ workspace, checkout: wsGit }) => { + workspaceCheckouts.map(async (entry) => { + if (!entry) return; + const { workspace, checkout: wsGit } = entry; const timestamp = new Date().toISOString(); - const update = reconcileWorkspacePlacement({ - workspace, - checkout: wsGit, - updatedAt: timestamp, + if (!reconcileWorkspacePlacement({ workspace, checkout: wsGit, updatedAt: timestamp })) + return; + let fields: Partial | null = null; + await this.workspaceRegistry.update(workspace.workspaceId, (current) => { + if (current.cwd !== workspace.cwd || current.projectId !== workspace.projectId) + return current; + const update = reconcileWorkspacePlacement({ + workspace: current, + checkout: wsGit, + updatedAt: timestamp, + }); + if (!update) return current; + fields = update.fields; + return { ...current, ...fields, updatedAt: timestamp }; }); - if (!update) return; - - const updated = await this.workspaceRegistry.update(workspace.workspaceId, (current) => ({ - ...current, - ...update.fields, - updatedAt: timestamp, - })); - if (!updated) return; + if (!fields) return; changes.push({ kind: "workspace_updated", workspaceId: workspace.workspaceId, directory: workspace.cwd, - fields: update.fields, + fields, }); }), ); @@ -537,16 +584,74 @@ export class WorkspaceReconciliationService { return this.workspaceGitService.getCheckout(cwd); } - private inspectDirectory(targetPath: string): DirectoryState { + private wakeInspectionWaiters(): void { + for (const wake of this.inspectionWaiters) wake(); + this.inspectionWaiters.clear(); + } + + private async inspectDirectory(targetPath: string): Promise { + // A healthy in-flight inspection owns capacity temporarily. Wait for it; + // only unresolved operations that have all timed out justify skipping paths. + while ( + !this.disposed && + !this.pendingInspections.has(targetPath) && + this.pendingInspections.size >= INSPECTION_CONCURRENCY + ) { + if (this.timedOutPaths.size === INSPECTION_CONCURRENCY) return "unreadable"; + await new Promise((resolve) => this.inspectionWaiters.add(resolve)); + } + if (this.disposed) return "unreadable"; + let pending = this.pendingInspections.get(targetPath); + if (!pending) { + pending = Promise.resolve() + .then(() => this.directoryInspection(targetPath)) + .catch((error) => { + if (isMissingPathError(error)) return "missing" as const; + this.logger.warn( + { err: error, targetPath }, + "Skipped workspace reconciliation after directory inspection failed", + ); + return "unreadable" as const; + }) + .finally(() => { + this.pendingInspections.delete(targetPath); + this.timedOutPaths.delete(targetPath); + this.wakeInspectionWaiters(); + if (this.inspectionSaturated && this.timedOutPaths.size === 0) { + this.inspectionSaturated = false; + this.logger.info( + {}, + "Timed-out workspace directory inspections drained; reconciliation capacity restored", + ); + } + }); + this.pendingInspections.set(targetPath, pending); + } + let timer: ReturnType | undefined; try { - return statSync(targetPath).isDirectory() ? "directory" : "missing"; - } catch (error) { - if (isMissingPathError(error)) return "missing"; - this.logger.warn( - { err: error, targetPath }, - "Skipped workspace reconciliation after directory inspection failed", - ); - return "unreadable"; + return await Promise.race([ + pending, + new Promise((resolve) => { + timer = setTimeout(() => { + this.timedOutPaths.add(targetPath); + this.wakeInspectionWaiters(); + if (!this.inspectionSaturated && this.timedOutPaths.size === INSPECTION_CONCURRENCY) { + this.inspectionSaturated = true; + this.logger.warn( + { paths: [...this.timedOutPaths] }, + "Workspace directory inspection saturated; preserving uninspected workspaces", + ); + } + this.logger.warn( + { targetPath }, + "Skipped workspace reconciliation after directory inspection timed out", + ); + resolve("unreadable"); + }, this.inspectionTimeoutMs); + }), + ]); + } finally { + clearTimeout(timer); } } } @@ -555,3 +660,21 @@ function isMissingPathError(error: unknown): boolean { if (typeof error !== "object" || error === null || !("code" in error)) return false; return error.code === "ENOENT" || error.code === "ENOTDIR"; } + +// Two workers bound admission; yielding also bounds passes whose adapters settle +// immediately (including unavailable paths after both IO slots time out). +async function mapBounded(items: T[], visit: (item: T) => Promise): Promise { + const results: R[] = []; + let next = 0; + await Promise.all( + Array.from({ length: Math.min(INSPECTION_CONCURRENCY, items.length) }, async () => { + let count = 0; + while (next < items.length) { + const index = next++; + results[index] = await visit(items[index]!); + if (++count % 32 === 0) await yieldToLoop(); + } + }), + ); + return results; +} diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index cf192a44b6..817853dad9 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -74,7 +74,7 @@ export async function bootstrapWorkspaceRegistries(options: { continue; } try { - pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch); + await pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch); } catch (error) { options.logger.warn( { err: error, workspaceId: workspace.workspaceId }, diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 6c27027edc..15031e7b69 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -116,6 +116,35 @@ describe("workspace registries", () => { expect(await projectRegistry.list()).toEqual([]); }); + test("writes once per mutation and skips missing-record mutations", async () => { + let writes = 0; + const registry = new FileBackedProjectRegistry( + path.join(tmpDir, "projects", "write-count.json"), + logger, + { + writeRecords: async (filePath, records) => { + writes++; + await writeJsonFileAtomic(filePath, records); + }, + }, + ); + const project = createPersistedProjectRecord({ + projectId: "counted", + rootPath: tmpDir, + kind: "git", + displayName: "Counted", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + await registry.upsert(project); + expect(writes).toBe(1); + await registry.update("missing", (current) => current); + await registry.remove("missing"); + expect(writes).toBe(1); + await registry.update(project.projectId, (current) => ({ ...current, customName: "Changed" })); + expect(writes).toBe(2); + }); + test("preserves a concurrent project update when archiving", async () => { let pauseNextWrite = false; let releaseWrite!: () => void; @@ -474,7 +503,7 @@ describe("workspace registries", () => { expect(await workspaceRegistry.list()).toEqual([]); }); - test("refreshes workspace archive timestamps when an archive is repeated", async () => { + test("preserves the first workspace archive and reports whether a queued archive applied", async () => { await workspaceRegistry.initialize(); await workspaceRegistry.upsert( createPersistedWorkspaceRecord({ @@ -488,12 +517,24 @@ describe("workspace registries", () => { }), ); - await workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z"); - await workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z"); - - expect(await workspaceRegistry.get("workspace-one")).toMatchObject({ - archivedAt: "2026-03-03T00:00:00.000Z", - updatedAt: "2026-03-03T00:00:00.000Z", + const events: WorkspaceMutation[] = []; + workspaceRegistry.subscribeToMutations((event) => { + events.push(event); + }); + expect( + await Promise.all([ + workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z"), + workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z"), + ]), + ).toEqual([true, false]); + expect(events.map((event) => event.kind)).toEqual(["archive"]); + const reloaded = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + expect(await reloaded.get("workspace-one")).toMatchObject({ + archivedAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", }); }); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 0e0fb4e62c..61fcb4f720 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -167,7 +167,7 @@ export interface WorkspaceRegistry { workspaceId: string, archivedAt: string, context?: WorkspaceArchiveContext, - ): Promise; + ): Promise; remove(workspaceId: string): Promise; /** Central lifecycle seam for daemon-global workspace observers. */ subscribeToMutations?( @@ -247,20 +247,6 @@ class FileBackedRegistry { }); } - async archive(id: string, archivedAt: string): Promise { - await this.archiveIfPresent(id, archivedAt); - } - - protected async archiveIfPresent(id: string, archivedAt: string): Promise { - return this.mutateCache((records) => { - const existing = records.get(id); - if (!existing) return null; - const next = this.schema.parse({ ...existing, updatedAt: archivedAt, archivedAt }); - records.set(id, next); - return next; - }); - } - protected async archiveIfActive(id: string, archivedAt: string): Promise { return this.mutateCache((records) => { const existing = records.get(id); @@ -485,7 +471,7 @@ export class FileBackedProjectRegistry return project; } - override async archive(projectId: string, archivedAt: string): Promise { + async archive(projectId: string, archivedAt: string): Promise { const project = await this.archiveIfActive(projectId, archivedAt); if (!project) return; await this.notifyMutation({ kind: "archive", projectId, project }); @@ -565,21 +551,28 @@ export class FileBackedWorkspaceRegistry }); } - override async archive( + async archive( workspaceId: string, archivedAt: string, context?: WorkspaceArchiveContext, - ): Promise { - const workspace = await super.update(workspaceId, (existing) => ({ - ...existing, - updatedAt: archivedAt, - archivedAt, - ...(context?.autoArchivedChangeRequestUrl - ? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl } - : {}), - })); - if (!workspace) return; + ): Promise { + const [workspace] = await this.mutateMany((records) => { + const existing = records.get(workspaceId); + if (!existing || existing.archivedAt) return []; + return [ + { + ...existing, + updatedAt: archivedAt, + archivedAt, + ...(context?.autoArchivedChangeRequestUrl + ? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl } + : {}), + }, + ]; + }); + if (!workspace) return false; await this.notifyMutation({ kind: "archive", workspaceId, workspace }); + return true; } override async remove(workspaceId: string): Promise { diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 96da6e648b..ed43e02388 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -209,13 +209,8 @@ describe("runWorktreeAutoTerminals", () => { getSize: () => ({ rows: 0, cols: 0 }), getTitle: () => undefined, getExitInfo: () => null, - getState: () => ({ - rows: 0, - cols: 0, - grid: [], - scrollback: [], - cursor: { row: 0, col: 0 }, - }), + hasOutput: () => false, + kill: () => {}, killAndWait: async () => {}, }; @@ -312,13 +307,8 @@ describe("runWorktreeAutoTerminals", () => { } }; }, - getState: () => ({ - rows: 1, - cols: 1, - grid: [[{ char: "$" }]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }), + hasOutput: () => true, + kill: () => {}, onTitleChange: () => () => {}, onActivityChange: () => () => {}, diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 6e138e8ab4..1e1ba58f8e 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -269,9 +269,9 @@ export function buildWorktreeSetupDetail(input: { } async function waitForTerminalBootstrapReadiness( - terminal: Pick, + terminal: Pick, ): Promise { - if (terminalHasOutput(terminal.getState())) { + if (terminal.hasOutput()) { return; } @@ -304,7 +304,7 @@ async function waitForTerminalBootstrapReadiness( finish(); }); - if (terminalHasOutput(terminal.getState())) { + if (terminal.hasOutput()) { finish(); return; } @@ -313,17 +313,6 @@ async function waitForTerminalBootstrapReadiness( }); } -function terminalHasOutput(state: ReturnType): boolean { - for (const row of [...state.scrollback, ...state.grid]) { - for (const cell of row) { - if (cell.char.trim().length > 0) { - return true; - } - } - } - return false; -} - export async function runWorktreeAutoTerminals(options: { workspaceId: string; worktree: WorktreeConfig; @@ -335,7 +324,7 @@ export async function runWorktreeAutoTerminals(options: { if (!options.terminalManager) return; const terminalManager = options.terminalManager; await Promise.all( - getWorktreeTerminalSpecs(options.workspaceCwd).map(async (spec) => { + (await getWorktreeTerminalSpecs(options.workspaceCwd)).map(async (spec) => { try { const terminal = await terminalManager.createTerminal({ cwd: options.workspaceCwd, @@ -545,7 +534,7 @@ export async function spawnWorkspaceScript( logger, onLifecycleChanged, } = options; - const configResult = readPaseoConfig(repoRoot); + const configResult = await readPaseoConfig(repoRoot); if (!configResult.ok) { throw paseoConfigParseError(configResult); } diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts index 8eb1625265..186dcbc0b1 100644 --- a/packages/server/src/server/worktree-branch-name-generator.test.ts +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -343,8 +343,8 @@ describe("generateBranchNameFromFirstAgentContext", () => { const repoRoot = createTempDir("paseo-branch-config-"); const worktreeRoot = createTempDir("paseo-branch-worktree-"); mkdirSync(path.join(worktreeRoot, ".git")); - writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" }); - writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, { + await writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" }); + await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, { placeholderBranchName: "dazzling-yak", }); writeConfig(repoRoot, { diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 9d02d0a2ec..7a7a342732 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -24,6 +24,7 @@ import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata, } from "../utils/worktree-metadata.js"; +import { validateBeforeRequest, validateBeforeResult } from "./plugins/lifecycle/index.js"; import { UnknownBranchError } from "../utils/worktree.js"; import { createWorktreeCore as createCoreWorktree } from "./worktree-core.js"; import { isPlatform } from "../test-utils/platform.js"; @@ -419,6 +420,21 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(existsSync(result.worktree.worktreePath)).toBe(true); }); + test("keeps default placement suffixes when the default destination already exists", async () => { + const { tempDir, repoDir, paseoHome } = createGitRepo(); + cleanupPaths.push(tempDir); + const first = await createCoreWorktree( + { cwd: repoDir, paseoHome, branchName: "first", worktreeSlug: "same" }, + createCoreDeps(), + ); + const second = await createCoreWorktree( + { cwd: repoDir, paseoHome, branchName: "second", worktreeSlug: "same" }, + createCoreDeps(), + ); + expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath + "-1"); + expect(existsSync(first.worktree.worktreePath)).toBe(true); + }); + test("creates branch-off worktrees from origin main without tracking origin main", async () => { const { tempDir, repoDir, paseoHome } = createGitRepoWithOriginMain(); cleanupPaths.push(tempDir); @@ -488,6 +504,42 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(result.worktree.branchName).toBe("feature/review-pr"); }); + test("round trips every resolved change-request field through an unchanged before-hook result", async () => { + const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); + cleanupPaths.push(tempDir); + const deps = createCoreDeps(); + const requests: unknown[] = []; + const result = await createCoreWorktree( + { cwd: repoDir, githubPrNumber: 123, refName: "feature/review-pr", paseoHome }, + { + ...deps, + lifecycle: { + before: async (name, request) => { + requests.push(request); + return validateBeforeResult(name, validateBeforeRequest(name, request), request); + }, + }, + }, + ); + expect(requests).toEqual([ + { + kind: "checkout-change-request", + forge: "github", + changeRequestNumber: 123, + headRef: "feature/review-pr", + baseRef: "main", + branch: "feature/review-pr", + checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/123/head" }], + trackOriginHead: true, + repoRoot: repoDir, + sourceCheckout: repoDir, + slug: "feature-review-pr", + path: result.worktree.worktreePath, + }, + ]); + expect(result.worktree.branchName).toBe("feature/review-pr"); + }); + test("uses the PR head ref as the default slug when no slug is supplied", async () => { const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); cleanupPaths.push(tempDir); @@ -854,7 +906,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { second.worktree.worktreePath, "remote.paseo-pr-14.url", ); - const metadata = readPaseoWorktreeMetadata(second.worktree.worktreePath); + const metadata = await readPaseoWorktreeMetadata(second.worktree.worktreePath); const facts = await getCheckoutSnapshotFacts(second.worktree.worktreePath, { paseoHome }); expect(second.worktree.branchName).toBe("feature/gitlab-mr-1"); @@ -1113,7 +1165,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { createCoreDeps({ github }), ); writeFileSync( - getPaseoWorktreeMetadataPath(second.worktree.worktreePath), + await getPaseoWorktreeMetadataPath(second.worktree.worktreePath), `${JSON.stringify({ version: 1, baseRefName: "main" }, null, 2)}\n`, "utf8", ); diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index b0310d6e1f..cc6c9fdbd6 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -1,8 +1,11 @@ +import type { PluginWorktreeCreateRequest } from "@getpaseo/plugin/server"; +import type { PluginLifecycle } from "./plugins/lifecycle/index.js"; import { createNameId } from "mnemonic-id"; import type { ForgeService } from "../services/forge-service.js"; import { createWorktree, + resolveDefaultWorktreePath, slugify, validateBranchSlug, type CreatedWorktree, @@ -15,7 +18,7 @@ import { } from "./resolve-worktree-creation-intent.js"; import type { ChangeRequestCheckoutSource, FirstAgentContext } from "@getpaseo/protocol/messages"; import type { WorkspaceGitService } from "./workspace-git-service.js"; -import { runWithGitCommandPriority } from "../utils/run-git-command.js"; +import { runGitCommand, runWithGitCommandPriority } from "../utils/run-git-command.js"; export interface CreateWorktreeCoreInput { cwd: string; @@ -31,6 +34,7 @@ export interface CreateWorktreeCoreInput { } export interface CreateWorktreeCoreDeps { + lifecycle?: Pick; github: ForgeService; workspaceGitService?: Pick< WorkspaceGitService, @@ -90,7 +94,7 @@ async function createWorktreeCoreWithPriority( } const forge = await resolveForge(repoRoot, deps, intentInput); - const intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, { + let intent = await resolveWorktreeCreationIntent(intentInput, repoRoot, { forge: forge.forge, forgeService: forge.service, resolveDefaultBranch: (root) => resolveDefaultBranch(root, deps), @@ -114,10 +118,33 @@ async function createWorktreeCoreWithPriority( } } + const defaultPath = await resolveDefaultWorktreePath({ + cwd: repoRoot, + paseoHome: input.paseoHome, + worktreesRoot: input.worktreesRoot, + worktreeSlug: normalizedSlug, + }); + const { stdout: sourceRoot } = await runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: input.cwd, + }); + const sourceCheckout = sourceRoot.trim(); + const request = describeCreationRequest(intent, { + repoRoot, + sourceCheckout, + slug: normalizedSlug, + path: defaultPath, + }); + const transformed = deps.lifecycle + ? await deps.lifecycle.before("worktree.create.before", request) + : request; + normalizedSlug = validateWorktreeSlug(transformed.slug); + intent = creationIntentFromRequest(transformed, intent); + return { worktree: await createWorktree({ cwd: repoRoot, worktreeSlug: normalizedSlug, + worktreePath: transformed.path, source: intent, paseoHome: input.paseoHome, worktreesRoot: input.worktreesRoot, @@ -128,6 +155,61 @@ async function createWorktreeCoreWithPriority( }; } +function describeCreationRequest( + intent: WorktreeCreationIntent, + placement: Pick, +): PluginWorktreeCreateRequest { + if (intent.kind === "branch-off") + return { + ...placement, + kind: intent.kind, + branch: intent.branchName, + baseRef: intent.baseBranch, + }; + if (intent.kind === "checkout-branch") + return { ...placement, kind: intent.kind, branch: intent.branchName }; + const { localBranchName, baseRefName, ...facts } = intent; + return { + ...placement, + ...facts, + branch: localBranchName ?? intent.headRef, + baseRef: baseRefName, + }; +} + +function creationIntentFromRequest( + request: PluginWorktreeCreateRequest, + original: WorktreeCreationIntent, +): WorktreeCreationIntent { + if (request.kind === "branch-off") { + return { kind: request.kind, branchName: request.branch, baseBranch: request.baseRef }; + } + if (request.kind === "checkout-branch") { + return { + kind: request.kind, + branchName: request.branch, + }; + } + const { + repoRoot: _repoRoot, + sourceCheckout: _checkout, + slug: _slug, + path: _path, + branch, + baseRef, + ...facts + } = request; + const keepLocalBranchName = + branch !== request.headRef || + ((original.kind === "checkout-change-request" || original.kind === "checkout-github-pr") && + original.localBranchName !== undefined); + return { + ...facts, + ...(keepLocalBranchName ? { localBranchName: branch } : {}), + baseRefName: baseRef, + }; +} + async function resolveForge( repoRoot: string, deps: CreateWorktreeCoreDeps, diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index d462a2605a..7117aa2501 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -212,13 +212,7 @@ function createTerminalManagerStub(options?: { id: `terminal-${terminals.length + 1}`, name: input.name ?? "Terminal", cwd: input.cwd, - getState: () => ({ - rows: 1, - cols: 1, - scrollback: [[{ char: "$" }]], - grid: [], - cursor: { row: 0, col: 0 }, - }), + hasOutput: () => true, subscribe: () => () => {}, onExit: () => () => {}, onCommandFinished: () => () => {}, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 4ba1010bd3..1cbad931eb 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -554,7 +554,7 @@ export async function runWorktreeSetupInBackground( emitSetupProgress("completed", null); } else { const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; - const setupCommands = getWorktreeSetupCommands(workspaceCwd); + const setupCommands = await getWorktreeSetupCommands(workspaceCwd); const runtimeEnv = await resolveWorktreeRuntimeEnv({ worktreePath: worktree.worktreePath, branchName: worktree.branchName, diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index 0aab346984..4382e46633 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -1,6 +1,6 @@ -import { join } from "node:path"; +import { basename } from "node:path"; -import { getPaseoWorktreesRoot, isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; +import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; import { archiveByScope, resolveWorkspaceIdAtPath, @@ -208,10 +208,18 @@ async function resolveWorktreeSlugPath( repoRoot: string, worktreeSlug: string, ): Promise { - const worktreesRoot = await getPaseoWorktreesRoot( - repoRoot, - dependencies.paseoHome, - dependencies.paseoWorktreesBaseRoot, - ); - return join(worktreesRoot, worktreeSlug); + // Worktrees can live anywhere (legacy root or wherever + // a plugin placed them), so a slug resolves against the + // discovered worktree list for the repo, never a guessed path. + const worktrees = await dependencies.workspaceGitService.listWorktrees(repoRoot); + const match = worktrees.find((entry) => basename(entry.path) === worktreeSlug); + if (!match) { + const found = worktrees.map((entry) => basename(entry.path)); + throw new Error( + found.length > 0 + ? `Paseo worktree not found for slug ${worktreeSlug}: found ${found.join(", ")}` + : `Paseo worktree not found for slug ${worktreeSlug}`, + ); + } + return match.path; } diff --git a/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts b/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts index aa9e8e01dc..eaa306c185 100644 --- a/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts +++ b/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts @@ -80,7 +80,7 @@ describe("applyTerminalAgentHookSetting", () => { expect(existsSync(paths.opencode)).toBe(true); }); - it("installs on enable and removes hooks on disable when toggled live", () => { + it("installs on enable and removes hooks on disable when toggled live", async () => { const root = createTempDir("paseo-hook-setting-"); const store = createStore(createTempDir("paseo-hook-setting-home-"), false); const paths = hookPaths(root); @@ -88,11 +88,11 @@ describe("applyTerminalAgentHookSetting", () => { applyTerminalAgentHookSetting({ store, install: createInstallEnv(root) }); expect(existsSync(paths.opencode)).toBe(false); - store.patch({ enableTerminalAgentHooks: true }); + await store.patch({ enableTerminalAgentHooks: true }); expect(existsSync(paths.codex)).toBe(true); expect(existsSync(paths.opencode)).toBe(true); - store.patch({ enableTerminalAgentHooks: false }); + await store.patch({ enableTerminalAgentHooks: false }); expect(existsSync(paths.opencode)).toBe(false); }); }); diff --git a/packages/server/src/terminal/headless-terminal.test.ts b/packages/server/src/terminal/headless-terminal.test.ts new file mode 100644 index 0000000000..d971f0d3f5 --- /dev/null +++ b/packages/server/src/terminal/headless-terminal.test.ts @@ -0,0 +1,60 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { createHeadlessTerminal } from "./headless-terminal.js"; + +const EMOJI_ROW = "A😀👍❤️👋🏽🇰🇷👨‍👩‍👧B"; +// A, six two-column emoji clusters, B. +const EMOJI_ROW_END_COL = 1 + 6 * 2 + 1; + +async function cursorColAfter(text: string): Promise { + const terminal = createHeadlessTerminal({ rows: 2, cols: 40 }); + try { + await new Promise((resolve) => terminal.write(text, resolve)); + return terminal.buffer.active.cursorX; + } finally { + terminal.dispose(); + } +} + +describe("createHeadlessTerminal", () => { + it("gives each emoji cluster one two-column cell", async () => { + expect(await cursorColAfter(EMOJI_ROW)).toBe(EMOJI_ROW_END_COL); + }); + + it("keeps ASCII and CJK widths", async () => { + expect(await cursorColAfter("ABC")).toBe(3); + expect(await cursorColAfter("中文")).toBe(4); + }); + + // The width table is decoded into a Node Buffer when the module loads. A + // Buffer carved out of Node's shared pool starts at a non-zero byteOffset, + // and an addon that reads the table header from offset 0 gets wrong widths + // or fails to load. Whether the pool offset is zero depends on earlier + // allocations, so the test forces a non-zero offset in a fresh process + // before the module loads. Zero bytes in front of the table make the + // misread header deterministic. + it("decodes widths correctly when Node's buffer pool offset is non-zero", () => { + const moduleUrl = new URL("./headless-terminal.ts", import.meta.url).href; + const script = ` + Buffer.poolSize = 8 * 1024 * 1024; + const occupied = Buffer.from(new Uint8Array(3 * 1024 * 1024)); + if (occupied.byteOffset !== 0 || occupied.buffer.byteLength !== Buffer.poolSize) { + throw new Error("expected a fresh shared pool"); + } + const { createHeadlessTerminal } = await import(${JSON.stringify(moduleUrl)}); + const terminal = createHeadlessTerminal({ rows: 2, cols: 40 }); + await new Promise((resolve) => terminal.write(${JSON.stringify(EMOJI_ROW)}, resolve)); + process.stdout.write(String(terminal.buffer.active.cursorX)); + terminal.dispose(); + `; + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { cwd: fileURLToPath(new URL(".", import.meta.url)), encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(String(EMOJI_ROW_END_COL)); + }); +}); diff --git a/packages/server/src/terminal/headless-terminal.ts b/packages/server/src/terminal/headless-terminal.ts new file mode 100644 index 0000000000..4e106c243b --- /dev/null +++ b/packages/server/src/terminal/headless-terminal.ts @@ -0,0 +1,22 @@ +import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"; +import xterm, { type Terminal } from "@xterm/headless"; + +export interface HeadlessTerminalOptions { + rows: number; + cols: number; + scrollback?: number; +} + +/** + * A headless xterm that measures text the way Paseo clients render it. + * + * Grapheme-cluster widths give every emoji cluster (VS16, skin-tone, flags, + * ZWJ sequences) one two-column cell, matching color-emoji fonts. Clients + * replay this buffer's ANSI with the same table, so cells and the cursor line + * up after reconnect. See docs/terminal-rendering.md. + */ +export function createHeadlessTerminal(options: HeadlessTerminalOptions): Terminal { + const terminal = new xterm.Terminal({ ...options, allowProposedApi: true }); + terminal.loadAddon(new UnicodeGraphemesAddon()); + return terminal; +} diff --git a/packages/server/src/terminal/paseo-cli-path.ts b/packages/server/src/terminal/paseo-cli-path.ts new file mode 100644 index 0000000000..18dff56f1b --- /dev/null +++ b/packages/server/src/terminal/paseo-cli-path.ts @@ -0,0 +1,83 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve as resolvePath } from "node:path"; + +// Where the Paseo CLI lives, for the PATH entry and PASEO_HOOK_CLI every terminal gets. Kept +// apart from terminal.ts so the daemon can resolve it without loading node-pty. + +const require = createRequire(import.meta.url); +const PASEO_CLI_BIN_ENTRY = "@getpaseo/cli/bin/paseo"; + +export function resolveExternalProcessPath(filePath: string): string { + return filePath.replace(/\.asar(?=[/\\]|$)/, ".asar.unpacked"); +} + +export function resolvePaseoCliBinDir(): string | null { + const cliExecutable = resolvePaseoCliExecutablePath(); + return cliExecutable ? dirname(cliExecutable) : null; +} + +export function resolvePaseoCliExecutablePath(): string | null { + const configuredCli = process.env.PASEO_CLI?.trim(); + if (configuredCli) { + return resolvePath(configuredCli); + } + + const cliEntrypoint = resolvePaseoCliBinEntrypoint(); + if (!cliEntrypoint) { + return null; + } + + const externalCliEntrypoint = resolveExternalProcessPath(cliEntrypoint); + const npmBinDir = findNpmBinDir(dirname(externalCliEntrypoint)); + if (npmBinDir) { + const shim = resolvePaseoCliShim(npmBinDir); + if (shim) { + return shim; + } + } + + return externalCliEntrypoint; +} + +function resolvePaseoCliBinEntrypoint(): string | null { + try { + return require.resolve(PASEO_CLI_BIN_ENTRY); + } catch { + return null; + } +} + +function findNpmBinDir(startPath: string): string | null { + let current = startPath; + while (true) { + const candidate = join(current, "node_modules", ".bin"); + if (hasPaseoCliShim(candidate)) { + return candidate; + } + + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function hasPaseoCliShim(binDir: string): boolean { + return resolvePaseoCliShim(binDir) !== null; +} + +function resolvePaseoCliShim(binDir: string): string | null { + for (const name of paseoCliShimNames()) { + const candidate = join(binDir, name); + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function paseoCliShimNames(): string[] { + return process.platform === "win32" ? ["paseo.cmd", "paseo.exe", "paseo"] : ["paseo"]; +} diff --git a/packages/server/src/terminal/terminal-capture.ts b/packages/server/src/terminal/terminal-capture.ts index e22d3b6f69..4f8a0f4625 100644 --- a/packages/server/src/terminal/terminal-capture.ts +++ b/packages/server/src/terminal/terminal-capture.ts @@ -1,6 +1,5 @@ import stripAnsi from "strip-ansi"; -import type { TerminalCell } from "@getpaseo/protocol/messages"; -import type { TerminalSession } from "./terminal.js"; +import type { IBuffer } from "@xterm/headless"; export interface CaptureTerminalLinesOptions { start?: number; @@ -13,14 +12,6 @@ export interface CaptureTerminalLinesResult { totalLines: number; } -function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string { - const text = cells - .map((cell) => cell.char) - .join("") - .trimEnd(); - return options.stripAnsi ? stripAnsi(text) : text; -} - function resolveCaptureLineIndex( lineNumber: number | undefined, totalLines: number, @@ -46,14 +37,10 @@ function resolveCaptureLineIndex( } export function captureTerminalLines( - terminal: TerminalSession, + buffer: IBuffer, options: CaptureTerminalLinesOptions = {}, ): CaptureTerminalLinesResult { - const state = terminal.getState(); - const allLines = [...state.scrollback, ...state.grid].map((cells) => - cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }), - ); - const totalLines = allLines.length; + const totalLines = buffer.length; const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start"); const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end"); @@ -64,8 +51,10 @@ export function captureTerminalLines( }; } - return { - lines: allLines.slice(startIndex, endIndex + 1), - totalLines, - }; + const lines: string[] = []; + for (let row = startIndex; row <= endIndex; row++) { + const text = buffer.getLine(row)!.translateToString(true).trimEnd(); + lines.push(options.stripAnsi === false ? text : stripAnsi(text)); + } + return { lines, totalLines }; } diff --git a/packages/server/src/terminal/terminal-manager-factory.ts b/packages/server/src/terminal/terminal-manager-factory.ts deleted file mode 100644 index 88dcc2d692..0000000000 --- a/packages/server/src/terminal/terminal-manager-factory.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { TerminalManager } from "./terminal-manager.js"; -import { createWorkerTerminalManager } from "./worker-terminal-manager.js"; - -export interface ConfiguredTerminalManagerOptions { - getTerminalActivityUrl?: () => string | null; -} - -export function createConfiguredTerminalManager( - options: ConfiguredTerminalManagerOptions = {}, -): TerminalManager { - return createWorkerTerminalManager(options); -} diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 47905d1424..83ba035c37 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -457,7 +457,6 @@ it("setTerminalTitle returns false for unknown terminal ids without changing exi expect(manager.setTerminalTitle("unknown-id", "x")).toBe(false); expect(session.getTitle()).toBe("Existing title"); - expect(session.getState().title).toBe("Existing title"); expect(snapshots).toEqual([]); unsubscribe(); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index df4ced389b..f8ba1851fa 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -1,11 +1,16 @@ +import { + replayTerminal, + type TerminalReplaySink, + type TerminalReplayMetadata, + type TerminalReplayOptions, +} from "./terminal-replay.js"; import { createTerminal, + type LocalTerminalSession, type TerminalActivityTransition, type TerminalSession, - type TerminalStateSnapshot, - type TerminalStateSnapshotOptions, } from "./terminal.js"; -import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js"; +import { type CaptureTerminalLinesResult } from "./terminal-capture.js"; import { randomBytes, randomUUID } from "node:crypto"; import { resolve, sep } from "node:path"; import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js"; @@ -50,6 +55,11 @@ export type TerminalWorkspaceContributionChangedListener = ( ) => void; export interface TerminalManager { + /** + * Set when this host's terminals cannot be served at all (a worker from a newer Paseo holds + * them). Callers that answer terminal requests report it instead of an empty list. + */ + getUnavailableError?(): Error | null; getTerminals(cwd: string, options?: { workspaceId?: string }): Promise; createTerminal(options: { id?: string; @@ -64,14 +74,17 @@ export interface TerminalManager { cols?: number; activityToken?: string; activityUrl?: string | null; + baseEnv?: Record; + paseoCliPath?: string | null; }): Promise; registerCwdEnv(options: { cwd: string; env: Record }): void; validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid"; getTerminal(id: string): TerminalSession | undefined; - getTerminalState( + replayTerminal( id: string, - options?: TerminalStateSnapshotOptions, - ): Promise; + options: TerminalReplayOptions | undefined, + sink: TerminalReplaySink, + ): Promise; setTerminalTitle(id: string, title: string): boolean; setTerminalActivity(id: string, state: TerminalActivityState): Promise; clearTerminalAttention(id: string): Promise; @@ -97,15 +110,25 @@ export interface TerminalManagerOptions { getTerminalActivityUrl?: () => string | null; } +/** The in-process manager the terminal worker runs: it owns each PTY, so it knows its pid. */ +export interface LocalTerminalManager extends TerminalManager { + createTerminal( + options: Parameters[0], + ): Promise; + getTerminal(id: string): LocalTerminalSession | undefined; + getTerminalProcessId(id: string): number | null; + getTerminalActivityToken(id: string): string | null; +} + function createActivityToken(): string { return randomBytes(32).toString("base64url"); } export function createTerminalManager( managerOptions: TerminalManagerOptions = {}, -): TerminalManager { +): LocalTerminalManager { const terminalsByCwd = new Map(); - const terminalsById = new Map(); + const terminalsById = new Map(); const terminalExitUnsubscribeById = new Map void>(); const terminalTitleUnsubscribeById = new Map void>(); const terminalActivityUnsubscribeById = new Map void>(); @@ -186,7 +209,7 @@ export function createTerminalManager( return bestMatchRoot ? defaultEnvByRootCwd.get(bestMatchRoot) : undefined; } - function registerSession(session: TerminalSession): TerminalSession { + function registerSession(session: LocalTerminalSession): LocalTerminalSession { terminalsById.set(session.id, session); const unsubscribeExit = session.onExit(() => { removeSessionById(session.id, { kill: false }); @@ -321,7 +344,9 @@ export function createTerminalManager( cols?: number; activityToken?: string; activityUrl?: string | null; - }): Promise { + baseEnv?: Record; + paseoCliPath?: string | null; + }): Promise { assertAbsolutePath(options.cwd); const terminals = terminalsByCwd.get(options.cwd) ?? []; @@ -341,7 +366,7 @@ export function createTerminalManager( ...(terminalActivityUrl ? { PASEO_TERMINAL_ACTIVITY_URL: terminalActivityUrl } : {}), }; terminalActivityTokenById.set(terminalId, activityToken); - let session: TerminalSession; + let session: LocalTerminalSession; try { session = registerSession( await createTerminal({ @@ -355,6 +380,8 @@ export function createTerminalManager( ...(options.rows !== undefined ? { rows: options.rows } : {}), ...(options.cols !== undefined ? { cols: options.cols } : {}), ...(mergedEnv ? { env: mergedEnv } : {}), + ...(options.baseEnv ? { baseEnv: options.baseEnv } : {}), + ...(options.paseoCliPath === undefined ? {} : { paseoCliPath: options.paseoCliPath }), activityEnv, }), ); @@ -386,15 +413,21 @@ export function createTerminalManager( return expected === token ? "valid" : "invalid"; }, - getTerminal(id: string): TerminalSession | undefined { + getTerminal(id: string): LocalTerminalSession | undefined { return terminalsById.get(id); }, - async getTerminalState( - id: string, - options?: TerminalStateSnapshotOptions, - ): Promise { - return terminalsById.get(id)?.getStateSnapshot(options) ?? null; + getTerminalProcessId(id: string): number | null { + return terminalsById.get(id)?.pid ?? null; + }, + + getTerminalActivityToken(id: string): string | null { + return terminalActivityTokenById.get(id) ?? null; + }, + + async replayTerminal(id, options, sink) { + const session = terminalsById.get(id); + return session ? replayTerminal(session, options, sink) : null; }, setTerminalTitle(id: string, title: string): boolean { @@ -456,7 +489,7 @@ export function createTerminalManager( totalLines: 0, }; } - return captureTerminalLines(session, options); + return session.captureLines(options); }, listDirectories(): string[] { diff --git a/packages/server/src/terminal/terminal-replay.ts b/packages/server/src/terminal/terminal-replay.ts new file mode 100644 index 0000000000..73336a88f1 --- /dev/null +++ b/packages/server/src/terminal/terminal-replay.ts @@ -0,0 +1,135 @@ +import type { FramedSocket } from "./terminal-worker-frames.js"; +import type { + TerminalWorkerRequest, + TerminalWorkerToParentMessage, +} from "./terminal-worker-protocol.js"; +import type { LocalTerminalSession } from "./terminal.js"; + +export interface TerminalReplayOptions { + scrollbackLines?: number; +} + +export interface TerminalReplayMetadata { + rows: number; + cols: number; + retainedHistoryLines: number; + revision: number; + replayPreamble: string; +} +export type TerminalReplayEvent = + | { type: "begin"; metadata: TerminalReplayMetadata } + | { type: "data"; data: Uint8Array } + | { type: "end" }; +export type TerminalReplaySink = (event: TerminalReplayEvent) => void; +export const TERMINAL_REPLAY_CHUNK_BYTES = 64 * 1024; + +export function* createTerminalReplay( + session: LocalTerminalSession, + options: TerminalReplayOptions | undefined, +): Generator { + const { metadata, data } = session.serializeReplay(options); + yield { type: "begin", metadata }; + for (let offset = 0; offset < data.length; offset += TERMINAL_REPLAY_CHUNK_BYTES) { + yield { type: "data", data: data.subarray(offset, offset + TERMINAL_REPLAY_CHUNK_BYTES) }; + } + yield { type: "end" }; + return metadata; +} + +export async function replayTerminal( + session: LocalTerminalSession, + options: TerminalReplayOptions | undefined, + sink: TerminalReplaySink, +): Promise { + const replay = createTerminalReplay(session, options); + let next = replay.next(); + while (!next.done) { + sink(next.value); + next = replay.next(); + } + return next.value; +} + +interface TerminalReplayPorts { + getSession(id: string): LocalTerminalSession | undefined; + flushOutput(id: string): void; +} + +/** Worker-owned ANSI capture and paced delivery; live observations never wait for a replay. */ +export function createTerminalReplayController(ports: TerminalReplayPorts) { + let controller: FramedSocket | null = null; + + function sendToController(message: TerminalWorkerToParentMessage): void { + // A stalled physical controller is different from a stalled viewer: all its + // observations must reconnect to the surviving worker after this socket closes. + if (controller && controller.socket.writableLength > 4 * 1024 * 1024) { + controller.socket.destroy(); + return; + } + controller?.send(message); + } + + async function deliverReplay( + connection: FramedSocket, + message: Extract, + ): Promise { + ports.flushOutput(message.terminalId); + const session = ports.getSession(message.terminalId); + if (!session) { + await connection.sendAndWait({ + type: "response", + requestId: message.requestId, + ok: true, + result: null, + }); + return; + } + // Flush and capture share one synchronous turn. Each daemon viewer buffers + // its own live tail and discards revisions already included in this cut. + const replay = createTerminalReplay(session, message.options); + let next = replay.next(); + while (!next.done) { + if (controller !== connection) throw new Error("Terminal replay controller disconnected"); + await connection.sendAndWait({ + type: "terminalReplay", + requestId: message.requestId, + event: next.value, + }); + next = replay.next(); + } + await connection.sendAndWait({ + type: "response", + requestId: message.requestId, + ok: true, + result: next.value, + }); + } + + return { + setConnection(connection: FramedSocket | null): void { + if (controller === connection) return; + const previous = controller; + controller = connection; + previous?.socket.destroy(); + }, + send: sendToController, + async replay( + request: Extract, + ): Promise { + const connection = controller; + if (!connection) return; + try { + await deliverReplay(connection, request); + } catch (error) { + await connection + .sendAndWait({ + type: "response", + requestId: request.requestId, + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + .catch(() => {}); + } + }, + }; +} diff --git a/packages/server/src/terminal/terminal-restore.test.ts b/packages/server/src/terminal/terminal-restore.test.ts index 7255cba1a5..ad008e1bd7 100644 --- a/packages/server/src/terminal/terminal-restore.test.ts +++ b/packages/server/src/terminal/terminal-restore.test.ts @@ -4,56 +4,45 @@ import { TerminalStreamOpcode, decodeTerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import { + createTerminalRestoreDelivery, encodeTerminalRestoreFrame, resolveRestoreAfterOutputOverflow, resolveTerminalRestoreSnapshotOptions, - resolveTerminalSubscriptionSnapshotMode, } from "./terminal-restore.js"; -function terminalRow(text: string, cols = 80): TerminalCell[] { - return Array.from({ length: cols }, (_, index) => ({ - char: text[index] ?? " ", - })); -} - -function terminalState(text: string): TerminalState { - return { - rows: 1, - cols: 80, - grid: [terminalRow(text)], - scrollback: [], - cursor: { row: 0, col: text.length }, - }; -} - describe("terminal restore policy", () => { - test("uses ready snapshots only for restore-aware subscriptions", () => { - expect(resolveTerminalSubscriptionSnapshotMode(undefined)).toBe("state"); - expect(resolveTerminalSubscriptionSnapshotMode({ mode: "live" })).toBe("ready"); - }); - test("resolves bounded restore snapshot options", () => { - expect(resolveTerminalRestoreSnapshotOptions({ mode: "live" })).toBeNull(); - expect(resolveTerminalRestoreSnapshotOptions({ mode: "full-snapshot" })).toBeUndefined(); - expect(resolveTerminalRestoreSnapshotOptions({ mode: "visible-snapshot" })).toEqual({ + expect( + resolveTerminalRestoreSnapshotOptions({ restore: { mode: "live" }, chunked: false }), + ).toBeNull(); + expect( + resolveTerminalRestoreSnapshotOptions({ restore: { mode: "full-snapshot" }, chunked: false }), + ).toBeUndefined(); + expect( + resolveTerminalRestoreSnapshotOptions({ + restore: { mode: "visible-snapshot" }, + chunked: false, + }), + ).toEqual({ scrollbackLines: 200, }); expect( resolveTerminalRestoreSnapshotOptions({ - mode: "visible-snapshot", - scrollbackLines: 999, + restore: { mode: "visible-snapshot", scrollbackLines: 999 }, + chunked: false, }), ).toEqual({ scrollbackLines: 500 }); }); - test("promotes live restore to visible restore after output overflow", () => { + test("bounds pressure recovery for every restore mode", () => { expect(resolveRestoreAfterOutputOverflow({ mode: "live" })).toEqual({ mode: "visible-snapshot", + scrollbackLines: 500, }); expect(resolveRestoreAfterOutputOverflow({ mode: "full-snapshot" })).toEqual({ - mode: "full-snapshot", + mode: "visible-snapshot", + scrollbackLines: 500, }); }); @@ -61,10 +50,7 @@ describe("terminal restore policy", () => { const frame = decodeTerminalStreamFrame( encodeTerminalRestoreFrame({ slot: 4, - snapshot: { - state: terminalState("restored"), - revision: 1, - }, + data: new TextEncoder().encode("restored"), }), ); @@ -73,3 +59,40 @@ describe("terminal restore policy", () => { expect(new TextDecoder().decode(frame?.payload ?? new Uint8Array())).toContain("restored"); }); }); + +test("projects chunked replay begin onto the declared wire metadata", () => { + const frames: Uint8Array[] = []; + const deliver = createTerminalRestoreDelivery({ + slot: 7, + chunked: true, + send: (frame) => { + frames.push(frame); + }, + }); + deliver({ + type: "begin", + metadata: { + rows: 30, + cols: 120, + retainedHistoryLines: 842, + revision: 5123, + replayPreamble: "\x1b[?2004h", + }, + }); + const data = new TextEncoder().encode("\x1b[31m界 replay\x1b[0m"); + deliver({ type: "data", data }); + deliver({ type: "end" }); + const decoded = frames.map((frame) => decodeTerminalStreamFrame(frame)!); + expect(decoded.map((frame) => ({ slot: frame.slot, opcode: frame.opcode }))).toEqual([ + { slot: 7, opcode: TerminalStreamOpcode.ReplayBegin }, + { slot: 7, opcode: TerminalStreamOpcode.ReplayData }, + { slot: 7, opcode: TerminalStreamOpcode.ReplayEnd }, + ]); + expect(JSON.parse(new TextDecoder().decode(decoded[0]!.payload))).toEqual({ + rows: 30, + cols: 120, + retainedHistoryLines: 842, + }); + expect(decoded[1]!.payload).toEqual(data); + expect(decoded[2]!.payload.byteLength).toBe(0); +}); diff --git a/packages/server/src/terminal/terminal-restore.ts b/packages/server/src/terminal/terminal-restore.ts index ffc1bd9faa..c0ee7b1105 100644 --- a/packages/server/src/terminal/terminal-restore.ts +++ b/packages/server/src/terminal/terminal-restore.ts @@ -1,11 +1,11 @@ +import type { z } from "zod"; import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages"; import { TerminalStreamOpcode, - encodeTerminalSnapshotPayload, + type TerminalReplayMetadataSchema, encodeTerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; -import type { TerminalStateSnapshot, TerminalStateSnapshotOptions } from "./terminal.js"; +import type { TerminalReplaySink, TerminalReplayOptions } from "./terminal-replay.js"; export const MAX_TERMINAL_OUTPUT_FRAME_BYTES = 256 * 1024; @@ -21,62 +21,84 @@ const MAX_VISIBLE_RESTORE_SCROLLBACK_LINES = 500; export type TerminalRestoreOptions = NonNullable; -export type TerminalSubscriptionSnapshotMode = "state" | "ready"; - -export function resolveTerminalSubscriptionSnapshotMode( - restore: TerminalRestoreOptions | undefined, -): TerminalSubscriptionSnapshotMode { - return restore ? "ready" : "state"; -} - export function resolveRestoreAfterOutputOverflow( restore: TerminalRestoreOptions | undefined, -): TerminalRestoreOptions | undefined { - if (restore?.mode === "live") { - return { mode: "visible-snapshot" }; - } - return restore; +): TerminalRestoreOptions { + return { + mode: "visible-snapshot", + scrollbackLines: Math.min( + restore?.scrollbackLines ?? MAX_VISIBLE_RESTORE_SCROLLBACK_LINES, + MAX_VISIBLE_RESTORE_SCROLLBACK_LINES, + ), + }; } -export function resolveTerminalRestoreSnapshotOptions( - restore: TerminalRestoreOptions, -): TerminalStateSnapshotOptions | null | undefined { +export function resolveTerminalRestoreSnapshotOptions({ + restore, + chunked, +}: { + restore: TerminalRestoreOptions; + chunked: boolean; +}): TerminalReplayOptions | null | undefined { if (restore.mode === "live") { return null; } - if (restore.mode === "visible-snapshot") { - return { - scrollbackLines: resolveVisibleRestoreScrollbackLines(restore.scrollbackLines), - }; + if (restore.mode === "full-snapshot") { + return chunked ? { scrollbackLines: restore.scrollbackLines } : undefined; } - return undefined; + const scrollbackLines = restore.scrollbackLines ?? DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES; + return { + scrollbackLines: chunked + ? scrollbackLines + : Math.min(scrollbackLines, MAX_VISIBLE_RESTORE_SCROLLBACK_LINES), + }; } -export function encodeLegacyTerminalSnapshotFrame(input: { - slot: number; - snapshot: TerminalStateSnapshot; -}): Uint8Array { - return encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Snapshot, - slot: input.slot, - payload: encodeTerminalSnapshotPayload(input.snapshot.state), - }); -} - -export function encodeTerminalRestoreFrame(input: { - slot: number; - snapshot: TerminalStateSnapshot; -}): Uint8Array { +export function encodeTerminalRestoreFrame(input: { slot: number; data: Uint8Array }): Uint8Array { return encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Restore, slot: input.slot, - payload: renderTerminalSnapshotToAnsi(input.snapshot.state), + payload: input.data, }); } -function resolveVisibleRestoreScrollbackLines(value: number | undefined): number { - if (typeof value !== "number") { - return DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES; - } - return Math.min(Math.max(0, value), MAX_VISIBLE_RESTORE_SCROLLBACK_LINES); +/** The daemon's only old-app adapter. Capable delivery never assembles the replay. */ +export function createTerminalRestoreDelivery(input: { + slot: number; + chunked: boolean; + send(frame: Uint8Array): void; +}): TerminalReplaySink { + // COMPAT(terminalSingleRestore): added after v0.14.0; remove after 2027-04-30 when client floor supports chunked replay. + const legacyChunks: Uint8Array[] = []; + return (event) => { + if (!input.chunked) { + if (event.type === "data") legacyChunks.push(event.data); + if (event.type === "end") + input.send( + encodeTerminalRestoreFrame({ + slot: input.slot, + data: Buffer.concat(legacyChunks), + }), + ); + return; + } + const opcodes = { + begin: TerminalStreamOpcode.ReplayBegin, + data: TerminalStreamOpcode.ReplayData, + end: TerminalStreamOpcode.ReplayEnd, + }; + let payload: string | Uint8Array | undefined; + if (event.type === "begin") { + const begin = { + rows: event.metadata.rows, + cols: event.metadata.cols, + retainedHistoryLines: event.metadata.retainedHistoryLines, + } satisfies z.infer; + payload = JSON.stringify(begin); + } + if (event.type === "data") payload = event.data; + input.send( + encodeTerminalStreamFrame({ slot: input.slot, opcode: opcodes[event.type], payload }), + ); + }; } diff --git a/packages/server/src/terminal/terminal-serializer.ts b/packages/server/src/terminal/terminal-serializer.ts new file mode 100644 index 0000000000..5f79064948 --- /dev/null +++ b/packages/server/src/terminal/terminal-serializer.ts @@ -0,0 +1,42 @@ +import type { Terminal as TerminalType } from "@xterm/headless"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import type { TerminalReplayOptions } from "./terminal-replay.js"; + +/** Serialize the owned model; the PTY is a transport, not an ANSI parser oracle. */ +export function createTerminalSerializer( + terminal: TerminalType, +): (options?: TerminalReplayOptions) => Uint8Array { + const addon = new SerializeAddon(); + terminal.loadAddon(addon); + return (options) => { + const data = addon.serialize({ scrollback: options?.scrollbackLines }); + const buffer = terminal.buffer.active; + // Addon 0.14 applies a horizontal offset after vertical movement has already + // clamped a pending-wrap column, losing a column. Restore a valid position + // absolutely; an x==cols cursor must keep its pending wrap for the next byte. + const position = + buffer.cursorX < terminal.cols ? `\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}H` : ""; + return Buffer.from(data + position + serializeCursorPresentation(terminal), "utf8"); + }; +} + +// Stable addon 0.14 serializes buffer/modes but omits DECSCUSR and cursor visibility. +// xterm 6 keeps the program-selected presentation in coreService, not public options. +function serializeCursorPresentation(terminal: TerminalType): string { + const core = ( + terminal as unknown as { + _core: { + coreService: { + isCursorHidden: boolean; + decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean }; + }; + }; + } + )._core.coreService; + const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`; + const cursorStyle = core.decPrivateModes.cursorStyle; + // Unset presentation belongs to each renderer's configured defaults. + if (cursorStyle === undefined) return visibility; + const style = { block: 2, underline: 4, bar: 6 }[cursorStyle]; + return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`; +} diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index b438af8381..ccb152aa23 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -1,3 +1,6 @@ +import type { TerminalReplayOptions } from "./terminal-replay.js"; +import type { TerminalRestoreOptions } from "./terminal-restore.js"; +import type { TerminalReplayMetadata } from "./terminal-replay.js"; import { describe, expect, test, vi } from "vitest"; import pino from "pino"; @@ -7,8 +10,7 @@ import { decodeTerminalStreamFrame, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; -import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js"; +import type { ServerMessage, TerminalSession } from "./terminal.js"; import { TerminalSessionController, type TerminalSessionControllerOptions, @@ -17,9 +19,35 @@ 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"; +import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js"; + +interface ReplayFixture { + data: Uint8Array; + metadata: TerminalReplayMetadata; +} +function terminalReplay(text: string, revision: number, replayPreamble = ""): ReplayFixture { + return { + data: Buffer.from(text), + metadata: { rows: 1, cols: 80, retainedHistoryLines: 0, revision, replayPreamble }, + }; +} +function replayFixture( + read: () => Promise, +): TerminalManager["replayTerminal"] { + return async (_id, _options, sink) => { + const replay = await read(); + if (!replay) return null; + await sink({ type: "begin", metadata: replay.metadata }); + await sink({ type: "data", data: replay.data }); + await sink({ type: "end" }); + return replay.metadata; + }; +} function createController( - options: TerminalSessionControllerOptions & { emitBinary(frame: Uint8Array): void }, + options: TerminalSessionControllerOptions & { + emitBinary(frame: Uint8Array): void | Promise; + }, ) { const source = {}; const ownership = new SessionDelivery( @@ -58,22 +86,6 @@ function deferred(): { return { promise, resolve, reject }; } -function terminalRow(text: string, cols = 80): TerminalCell[] { - return Array.from({ length: cols }, (_, index) => ({ - char: text[index] ?? " ", - })); -} - -function terminalState(text: string): TerminalState { - return { - rows: 1, - cols: 80, - grid: [terminalRow(text)], - scrollback: [], - cursor: { row: 0, col: text.length }, - }; -} - function createLogger(): pino.Logger { return { warn: vi.fn(), @@ -84,7 +96,7 @@ function createLogger(): pino.Logger { describe("terminal-session-controller restore", () => { test("delivers output produced while restore is in flight after the restore frame", async () => { let terminalListener: ((message: ServerMessage) => void) | null = null; - const snapshot = deferred(); + const snapshot = deferred(); const binaryFrames: TerminalStreamFrame[] = []; const outboundMessages: SessionOutboundMessage[] = []; const terminal: TerminalSession = { @@ -103,8 +115,7 @@ describe("terminal-session-controller restore", () => { onTitleChange: () => vi.fn(), onActivityChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), - getState: () => terminalState("restore-before"), - getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }), + hasOutput: () => true, getReplayPreamble: () => "\x1b[?1h\x1b[?2004h", getTitle: () => undefined, getActivity: () => null, @@ -120,7 +131,7 @@ describe("terminal-session-controller restore", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(() => terminal), - getTerminalState: vi.fn(() => snapshot.promise), + replayTerminal: replayFixture(() => snapshot.promise), setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), killTerminal: vi.fn(), @@ -156,10 +167,10 @@ describe("terminal-session-controller restore", () => { }, }); await Promise.resolve(); - expect(terminalManager.getTerminalState).toHaveBeenCalledTimes(1); + expect(binaryFrames).toEqual([]); terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 }); - snapshot.resolve({ state: terminalState("restore-before"), revision: 1 }); + snapshot.resolve(terminalReplay("restore-before", 1, "\x1b[?1h\x1b[?2004h")); await snapshot.promise; await new Promise((resolve) => setTimeout(resolve, 20)); @@ -201,8 +212,7 @@ function listSession(input: { onTitleChange: () => vi.fn(), onActivityChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), - getState: () => terminalState(""), - getStateSnapshot: () => ({ state: terminalState(""), revision: 0 }), + hasOutput: () => true, getReplayPreamble: () => "", getTitle: () => undefined, getActivity: () => null, @@ -235,7 +245,7 @@ describe("terminal-session-controller legacy terminal creation", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), clearTerminalAttention: vi.fn(), @@ -310,7 +320,7 @@ describe("terminal-session-controller legacy terminal creation", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), clearTerminalAttention: vi.fn(), @@ -347,6 +357,76 @@ describe("terminal-session-controller legacy terminal creation", () => { }); }); +describe("terminal-session-controller with terminals held by a newer Paseo", () => { + const incompatible = new TerminalWorkerIncompatibleError({ + paseoVersion: "9.9.9", + protocolVersion: 7, + }); + + function incompatibleManager(): TerminalManager { + return { + getUnavailableError: () => incompatible, + getTerminals: vi.fn(async () => Promise.reject(incompatible)), + createTerminal: vi.fn(async () => Promise.reject(incompatible)), + registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), + getTerminal: vi.fn(), + replayTerminal: async () => null, + setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), + clearTerminalAttention: vi.fn(), + killTerminal: vi.fn(), + killTerminalAndWait: vi.fn(), + captureTerminal: vi.fn(), + // The incompatible worker's terminals are not in this daemon's view. + listDirectories: vi.fn(() => []), + killAll: vi.fn(), + subscribeTerminalsChanged: vi.fn(() => vi.fn()), + subscribeTerminalActivity: vi.fn(() => vi.fn()), + subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), + }; + } + + test.each([ + { type: "list_terminals_request" as const, requestId: "req-list" }, + { type: "list_terminals_request" as const, requestId: "req-list-cwd", cwd: "/work/repo" }, + { + type: "create_terminal_request" as const, + requestId: "req-create", + cwd: "/work/repo", + workspaceId: "ws-1", + }, + ])( + "answers $type ($requestId) with a terminal_worker_incompatible rpc_error", + async (request) => { + const outboundMessages: SessionOutboundMessage[] = []; + const controller = createController({ + terminalManager: incompatibleManager(), + emit: (message) => outboundMessages.push(message), + emitBinary: vi.fn(), + hasBinaryChannel: () => true, + isPathWithinRoot: isSameOrDescendantPath, + sessionLogger: createLogger(), + listTerminalWorkspaceRefs: async () => [{ workspaceId: "ws-1", cwd: "/work/repo" }], + }); + + await controller.dispatch(request); + + expect(outboundMessages).toEqual([ + { + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: incompatible.message, + code: "terminal_worker_incompatible", + }, + }, + ]); + }, + ); +}); + async function flushMicrotasks(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -357,99 +437,6 @@ async function waitForCoalescerFlush(): Promise { await new Promise((resolve) => setTimeout(resolve, 30)); } -describe("terminal-session-controller wrap-flag gating", () => { - function setup(clientSupportsWrapReflow?: () => boolean): { - controller: ReturnType; - getTerminalState: ReturnType; - } { - const terminal: TerminalSession = { - id: "term-1", - name: "Terminal", - cwd: "/tmp", - workspaceId: "ws-test", - send: vi.fn(), - subscribe: (listener) => { - queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 })); - return vi.fn(); - }, - onExit: () => vi.fn(), - onCommandFinished: () => vi.fn(), - onTitleChange: () => vi.fn(), - onActivityChange: () => vi.fn(), - getSize: () => ({ rows: 1, cols: 80 }), - getState: () => terminalState("hello"), - getStateSnapshot: () => ({ state: terminalState("hello"), revision: 1 }), - getReplayPreamble: () => "", - getTitle: () => undefined, - getActivity: () => null, - setActivity: vi.fn(), - setTitle: vi.fn(), - getExitInfo: () => null, - kill: vi.fn(), - killAndWait: vi.fn(), - }; - const getTerminalState = vi.fn(() => - Promise.resolve({ state: terminalState("hello"), revision: 1 }), - ); - const terminalManager = { - getTerminals: vi.fn(), - createTerminal: vi.fn(), - registerCwdEnv: vi.fn(), - validateTerminalActivityToken: vi.fn(() => "unknown"), - getTerminal: vi.fn(() => terminal), - getTerminalState, - setTerminalTitle: vi.fn(), - setTerminalActivity: vi.fn(), - killTerminal: vi.fn(), - killTerminalAndWait: vi.fn(), - captureTerminal: vi.fn(), - listDirectories: vi.fn(() => []), - killAll: vi.fn(), - subscribeTerminalsChanged: vi.fn(() => vi.fn()), - subscribeTerminalActivity: vi.fn(() => vi.fn()), - subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), - } as unknown as TerminalManager; - const controller = createController({ - terminalManager, - emit: vi.fn(), - emitBinary: vi.fn(), - hasBinaryChannel: () => true, - isPathWithinRoot: () => false, - sessionLogger: createLogger(), - ...(clientSupportsWrapReflow ? { clientSupportsWrapReflow } : {}), - }); - return { controller, getTerminalState }; - } - - async function subscribe(controller: ReturnType): Promise { - await controller.dispatch({ - type: "subscribe_terminal_request", - terminalId: "term-1", - requestId: "req-1", - restore: { mode: "visible-snapshot", scrollbackLines: 200 }, - }); - await flushMicrotasks(); - } - - test("requests wrap flags when the client supports reflowable snapshots", async () => { - const { controller, getTerminalState } = setup(() => true); - await subscribe(controller); - expect(getTerminalState).toHaveBeenCalledWith( - "term-1", - expect.objectContaining({ includeWrapFlags: true }), - ); - }); - - test("omits wrap flags when the client does not advertise support", async () => { - const { controller, getTerminalState } = setup(); - await subscribe(controller); - expect(getTerminalState).toHaveBeenCalledWith( - "term-1", - expect.objectContaining({ includeWrapFlags: false }), - ); - }); -}); - describe("terminal-session-controller subdirectory aggregation", () => { test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => { const rootCwd = "/work/repo"; @@ -470,7 +457,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), killTerminal: vi.fn(), @@ -538,7 +525,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), killTerminal: vi.fn(), @@ -630,7 +617,7 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { registerCwdEnv: vi.fn(), validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), - getTerminalState: vi.fn(), + replayTerminal: async () => null, setTerminalTitle: vi.fn(), setTerminalActivity: vi.fn(), killTerminal: vi.fn(), @@ -693,9 +680,17 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { }); describe("terminal-session-controller backpressure snapshot fallback", () => { - async function setup(getClientBufferedAmount: () => number | null): Promise<{ + async function setup( + getClientBufferedAmount: () => number | null, + chunked = false, + pace?: () => Promise, + restore?: TerminalRestoreOptions, + ): Promise<{ + messages: SessionOutboundMessage[]; pushOutput: (data: string) => void; frames: TerminalStreamFrame[]; + replayOptions: Array; + ready: () => void; }> { let terminalListener: ((message: ServerMessage) => void) | null = null; const terminal: TerminalSession = { @@ -706,19 +701,15 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { send: vi.fn(), subscribe: (listener) => { terminalListener = listener; - // Legacy stream: a snapshot arrives on subscribe (one Snapshot frame), - // after which output streams through the coalescer as Output frames. - queueMicrotask(() => - listener({ type: "snapshot", state: terminalState("live"), revision: 1 }), - ); + // Readiness starts ANSI replay; later bytes use Output frames. + queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 })); return vi.fn(); }, onExit: () => vi.fn(), onCommandFinished: () => vi.fn(), onTitleChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), - getState: () => terminalState("live"), - getStateSnapshot: () => ({ state: terminalState("live"), revision: 1 }), + hasOutput: () => true, getReplayPreamble: () => "", getTitle: () => undefined, setTitle: vi.fn(), @@ -726,14 +717,16 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { kill: vi.fn(), killAndWait: vi.fn(), }; - const terminalManager = { + const replayOptions: Array = []; + const terminalManager: TerminalManager = { getTerminals: vi.fn(), createTerminal: vi.fn(), registerCwdEnv: vi.fn(), getTerminal: vi.fn(() => terminal), - getTerminalState: vi.fn(() => - Promise.resolve({ state: terminalState("live"), revision: 1 }), - ), + replayTerminal: (id, options, sink) => { + replayOptions.push(options); + return replayFixture(async () => terminalReplay("live", 1))(id, options, sink); + }, setTerminalTitle: vi.fn(), killTerminal: vi.fn(), killTerminalAndWait: vi.fn(), @@ -741,16 +734,26 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { listDirectories: vi.fn(() => []), killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => vi.fn()), - } as unknown as TerminalManager; + validateTerminalActivityToken: () => "unknown", + setTerminalActivity: async () => true, + clearTerminalAttention: async () => false, + subscribeTerminalActivity: () => () => {}, + subscribeTerminalWorkspaceContributionChanged: () => () => {}, + }; const frames: TerminalStreamFrame[] = []; + const messages: SessionOutboundMessage[] = []; const controller = createController({ terminalManager, - emit: vi.fn(), + emit: (message) => { + messages.push(message); + }, + supportsChunkedReplay: () => chunked, emitBinary: (bytes) => { const frame = decodeTerminalStreamFrame(bytes); if (frame) { frames.push(frame); + if (frame.opcode === TerminalStreamOpcode.ReplayBegin) return pace?.(); } }, hasBinaryChannel: () => true, @@ -763,6 +766,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { type: "subscribe_terminal_request", terminalId: "term-1", requestId: "req-1", + restore, }); await waitForCoalescerFlush(); // Drop the initial subscribe snapshot frame so each test only sees frames @@ -770,11 +774,83 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { frames.length = 0; return { + replayOptions, + ready: () => terminalListener?.({ type: "snapshotReady" }), pushOutput: (data) => terminalListener?.({ type: "output", data, revision: 2 }), frames, + messages, }; } + test.each([false, true])( + "preserves visible history defaults and explicit requests across delivery (chunked=%s)", + async (chunked) => { + const replayOpcodes = chunked + ? [ + TerminalStreamOpcode.ReplayBegin, + TerminalStreamOpcode.ReplayData, + TerminalStreamOpcode.ReplayEnd, + ] + : [TerminalStreamOpcode.Restore]; + for (const [restore, expected] of [ + [{ mode: "visible-snapshot" }, [{ scrollbackLines: 200 }]], + [{ mode: "visible-snapshot", scrollbackLines: 0 }, [{ scrollbackLines: 0 }]], + [{ mode: "visible-snapshot", scrollbackLines: 5 }, [{ scrollbackLines: 5 }]], + [{ mode: "visible-snapshot", scrollbackLines: 500 }, [{ scrollbackLines: 500 }]], + [ + { mode: "visible-snapshot", scrollbackLines: 10000 }, + [{ scrollbackLines: chunked ? 10000 : 500 }], + ], + [{ mode: "full-snapshot" }, [chunked ? { scrollbackLines: undefined } : undefined]], + [{ mode: "live" }, []], + ] as const) { + const fixture = await setup(() => 0, chunked, undefined, restore); + expect(fixture.replayOptions).toEqual(expected); + fixture.ready(); + await waitForCoalescerFlush(); + expect(fixture.replayOptions).toEqual([...expected, ...expected]); + expect(fixture.frames.map((frame) => frame.opcode)).toEqual( + restore.mode === "live" ? [] : replayOpcodes, + ); + fixture.pushOutput("after-restore"); + await waitForCoalescerFlush(); + expect(fixture.frames.at(-1)?.opcode).toBe(TerminalStreamOpcode.Output); + expect(new TextDecoder().decode(fixture.frames.at(-1)!.payload)).toBe("after-restore"); + } + }, + ); + + test.each([false, true])( + "bounds recovery without changing attach demand (chunked=%s)", + async (chunked) => { + for (const [restore, history] of [ + [{ mode: "visible-snapshot" }, 500], + [{ mode: "visible-snapshot", scrollbackLines: 10000 }, 500], + [{ mode: "full-snapshot" }, 500], + [{ mode: "visible-snapshot", scrollbackLines: 5 }, 5], + [{ mode: "visible-snapshot", scrollbackLines: 0 }, 0], + [{ mode: "full-snapshot", scrollbackLines: 0 }, 0], + [{ mode: "live", scrollbackLines: 0 }, 0], + [{ mode: "live" }, 500], + [undefined, 500], + ] as const) { + const fixture = await setup(() => 8 * 1024 * 1024, chunked, undefined, restore); + const initial = fixture.replayOptions.slice(); + fixture.pushOutput("pressure".repeat(40000)); + await waitForCoalescerFlush(); + expect(fixture.replayOptions.at(-1)).toEqual({ scrollbackLines: history }); + expect(fixture.replayOptions).toHaveLength(initial.length + 1); + fixture.ready(); // Worker adoption must use the original demand, even on the same stream. + await waitForCoalescerFlush(); + expect(fixture.replayOptions.slice(initial.length + 1)).toEqual(initial); + fixture.frames.length = 0; + fixture.pushOutput("after-catch-up"); + await waitForCoalescerFlush(); + expect(fixture.frames.map((frame) => frame.opcode)).toEqual([TerminalStreamOpcode.Output]); + } + }, + ); + test("streams all output without a snapshot when the client keeps up", async () => { const { pushOutput, frames } = await setup(() => 0); @@ -782,7 +858,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { pushOutput(chunk); await waitForCoalescerFlush(); - expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(false); + expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false); const outputFrames = frames.filter((frame) => frame.opcode === TerminalStreamOpcode.Output); expect(outputFrames.length).toBeGreaterThan(0); const receivedBytes = outputFrames.reduce( @@ -798,7 +874,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { pushOutput("y".repeat(300 * 1024)); await waitForCoalescerFlush(); - expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true); + expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true); // After the snapshot the byte counter is reset, so a small follow-up chunk // streams as Output rather than tripping the fallback again. @@ -806,7 +882,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { pushOutput("z".repeat(1024)); await waitForCoalescerFlush(); - expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(false); + expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(false); expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Output)).toBe(true); }); @@ -822,7 +898,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { pushOutput("p".repeat(300 * 1024)); await waitForCoalescerFlush(); - expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true); + expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true); }); test("falls back to a snapshot at the byte threshold when no backpressure signal exists", async () => { @@ -834,11 +910,41 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { pushOutput("r".repeat(300 * 1024)); await waitForCoalescerFlush(); - expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true); + expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Restore)).toBe(true); + }); + test("queues capable catch-up without waiting for the direct socket to drain", async () => { + let buffered = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { pushOutput, frames, messages } = await setup( + () => buffered, + true, + () => (buffered ? gate : Promise.resolve()), + ); + buffered = 8 * 1024 * 1024; + pushOutput("y".repeat(300 * 1024)); + await waitForCoalescerFlush(); + expect(messages.filter((message) => message.type === "terminal_stream_exit")).toEqual([]); + expect(frames.map((frame) => frame.opcode)).toEqual([ + TerminalStreamOpcode.ReplayBegin, + TerminalStreamOpcode.ReplayData, + TerminalStreamOpcode.ReplayEnd, + ]); + buffered = 0; + release(); + await waitForCoalescerFlush(); + expect(frames.map((frame) => frame.opcode)).toContain(TerminalStreamOpcode.ReplayEnd); + frames.length = 0; + buffered = 8 * 1024 * 1024; // Small output must stay live after the replay resets its byte counter. + pushOutput("live-after-recovery"); + await waitForCoalescerFlush(); + expect(frames.map((frame) => frame.opcode)).toEqual([TerminalStreamOpcode.Output]); }); }); -function exitFixture(readSnapshot: () => Promise, backedUp = false) { +function exitFixture(readReplay: () => Promise, backedUp = false) { const outputs = new Set<(message: ServerMessage) => void>(); const exits = new Set[0]>(); const terminal: TerminalSession = { @@ -863,8 +969,7 @@ function exitFixture(readSnapshot: () => Promise, onTitleChange: () => () => {}, onActivityChange: () => () => {}, getSize: () => ({ rows: 1, cols: 80 }), - getState: () => terminalState("snapshot"), - getStateSnapshot: () => ({ state: terminalState("snapshot"), revision: 0 }), + hasOutput: () => true, getReplayPreamble: () => "", getTitle: () => undefined, getActivity: () => null, @@ -877,7 +982,7 @@ function exitFixture(readSnapshot: () => Promise, }; const manager: TerminalManager = { getTerminal: () => terminal, - getTerminalState: readSnapshot, + replayTerminal: replayFixture(readReplay), getTerminals: async () => [terminal], createTerminal: async () => terminal, registerCwdEnv() {}, @@ -955,7 +1060,7 @@ describe("terminal output before natural exit", () => { "flushes each observer's final bytes before exit (modern=%s)", async (modern) => { vi.useFakeTimers(); - const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); + const f = exitFixture(async () => terminalReplay("snapshot", 0)); try { const source = {}; const owners = [ @@ -977,7 +1082,7 @@ describe("terminal output before natural exit", () => { row.message.payload.subscriptionId === owner.id)), ); expect(ordered.map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ - TerminalStreamOpcode.Snapshot, + TerminalStreamOpcode.Restore, TerminalStreamOpcode.Output, TerminalStreamOpcode.Output, "terminal_stream_exit", @@ -997,7 +1102,7 @@ describe("terminal output before natural exit", () => { test("waits for bootstrap snapshot and flushes buffered bytes without starting backpressure work", async () => { vi.useFakeTimers(); - const snapshot = deferred(); + const snapshot = deferred(); let reads = 0; const f = exitFixture(() => { reads++; @@ -1010,10 +1115,10 @@ describe("terminal output before natural exit", () => { 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 }); + snapshot.resolve(terminalReplay("snapshot", 0)); await vi.advanceTimersByTimeAsync(0); expect(f.frames.slice(1).map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ - TerminalStreamOpcode.Snapshot, + TerminalStreamOpcode.Restore, TerminalStreamOpcode.Output, "terminal_stream_exit", ]); @@ -1033,11 +1138,10 @@ describe("terminal output before natural exit", () => { "finishes an in-flight backpressure snapshot (available=%s)", async (available) => { vi.useFakeTimers(); - const snapshot = deferred(); + const snapshot = deferred(); let reads = 0; const f = exitFixture( - async () => - ++reads === 1 ? { state: terminalState("initial"), revision: 0 } : snapshot.promise, + async () => (++reads === 1 ? terminalReplay("initial", 0) : snapshot.promise), true, ); try { @@ -1051,10 +1155,7 @@ describe("terminal output before natural 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, - ); + else snapshot.resolve(available ? terminalReplay("pressure-snapshot", 1) : null); await vi.advanceTimersByTimeAsync(0); const output = f.frames .filter((row) => row.binary?.opcode === TerminalStreamOpcode.Output) @@ -1074,7 +1175,7 @@ describe("terminal output before natural exit", () => { test("release during exit's pending snapshot suppresses late delivery", async () => { vi.useFakeTimers(); - const snapshot = deferred(); + const snapshot = deferred(); const f = exitFixture(() => snapshot.promise); const source = {}; try { @@ -1087,7 +1188,7 @@ describe("terminal output before natural exit", () => { subscriptionId: owner.id!, }; const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!)); - snapshot.resolve({ state: terminalState("snapshot"), revision: 0 }); + snapshot.resolve(terminalReplay("snapshot", 0)); await release; await vi.advanceTimersByTimeAsync(0); expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); @@ -1102,7 +1203,7 @@ describe("terminal output before natural exit", () => { test("explicit release discards only that observer's trailing output", async () => { vi.useFakeTimers(); - const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); + const f = exitFixture(async () => terminalReplay("snapshot", 0)); const source = {}; try { const a = await f.subscribe(source, true, "a"); @@ -1150,8 +1251,8 @@ describe("terminal snapshot failure", () => { 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 }; + throw new Error("Terminal worker request timed out: replayTerminal"); + return terminalReplay("healthy", 0); }, phase === "backpressure"); try { const source = {}; @@ -1179,7 +1280,7 @@ describe("terminal snapshot failure", () => { payload: { terminalId: "exit-terminal", subscriptionId: failed.id, - error: "Terminal worker request timed out: getTerminalState", + error: "Terminal worker request timed out: replayTerminal", }, }, ]); @@ -1210,7 +1311,7 @@ describe("terminal snapshot failure", () => { ); test("release during pending rejection suppresses the failure and completes cleanup", async () => { - const snapshot = deferred(); + const snapshot = deferred(); const f = exitFixture(() => snapshot.promise); const source = {}; try { @@ -1222,7 +1323,7 @@ describe("terminal snapshot failure", () => { 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")); + snapshot.reject(new Error("Terminal worker request timed out: replayTerminal")); await release; expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); expect(f.delivery.registrationCount).toBe(0); @@ -1242,7 +1343,7 @@ test.each([false, true])( let reads = 0; const f = exitFixture(async () => { if (++reads === (backpressure ? 2 : 1)) throw new Error("Snapshot unavailable"); - return { state: terminalState("recovered"), revision: 0 }; + return terminalReplay("recovered", 0); }, backpressure); try { await f.subscribe({}, false, "legacy-recovery"); @@ -1256,7 +1357,7 @@ test.each([false, true])( 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( + expect(f.frames.some((row) => row.binary?.opcode === TerminalStreamOpcode.Restore)).toBe( true, ); f.exit(); @@ -1276,3 +1377,51 @@ test.each([false, true])( } }, ); + +test("bounds a capturing viewer tail without ending a healthy viewer", async () => { + const pending = deferred(); + let reads = 0; + const f = exitFixture(() => + ++reads === 2 ? pending.promise : Promise.resolve(terminalReplay("retained", 0)), + ); + const desktop = {}, + phone = {}; + try { + await f.subscribe(desktop, true, "desktop"); + await waitForCoalescerFlush(); + await f.subscribe(phone, true, "phone"); + f.output("x".repeat(4 * 1024 * 1024 + 1), 1); + await waitForCoalescerFlush(); + expect( + f.frames.filter( + (row) => row.source === phone && row.message?.type === "terminal_stream_exit", + ), + ).toHaveLength(1); + expect( + f.frames.filter( + (row) => row.source === desktop && row.message?.type === "terminal_stream_exit", + ), + ).toHaveLength(0); + expect( + f.frames.some( + (row) => row.source === desktop && row.binary?.opcode === TerminalStreamOpcode.Output, + ), + ).toBe(true); + pending.resolve(terminalReplay("phone", 1)); + await waitForCoalescerFlush(); + f.output("AFTER-OVERFLOW", 2); + await waitForCoalescerFlush(); + expect( + f.frames.some( + (row) => + row.source === desktop && + row.binary?.opcode === TerminalStreamOpcode.Output && + Buffer.from(row.binary.payload).toString().includes("AFTER-OVERFLOW"), + ), + ).toBe(true); + expect(f.delivery.registrationCount).toBe(1); + } finally { + pending.resolve(null); + await f.delivery.close(); + } +}); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 4f46dbc88c..653524e54a 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -28,16 +28,16 @@ import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js"; import { MAX_CLIENT_BUFFERED_BYTES, MAX_TERMINAL_OUTPUT_FRAME_BYTES, - encodeLegacyTerminalSnapshotFrame, - encodeTerminalRestoreFrame, + createTerminalRestoreDelivery, resolveRestoreAfterOutputOverflow, resolveTerminalRestoreSnapshotOptions, - resolveTerminalSubscriptionSnapshotMode, type TerminalRestoreOptions, } from "./terminal-restore.js"; +import type { TerminalReplayMetadata } from "./terminal-replay.js"; import type { TerminalSession } from "./terminal.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { applyTerminalSize } from "./terminal-size-ownership.js"; +import { TerminalWorkerIncompatibleError } from "./terminal-worker-pool.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; @@ -67,31 +67,28 @@ interface ActiveTerminalStream { needsSnapshot: boolean; snapshotInFlight: boolean; retrySnapshotErrors: boolean; - readyRevision?: number; + // Readiness is independent of revision: reconnect adoption has no revision. + snapshotReady: boolean; restore?: TerminalRestoreOptions; bufferedOutputs: BufferedTerminalOutput[]; + bufferedOutputBytes: number; outputBytesSinceSnapshot: number; outputCoalescer: TerminalOutputCoalescer; } -interface SnapshotSendResult { - shouldContinue: boolean; - replayRevision?: number; -} +type SnapshotSendResult = + | { shouldContinue: false } + | { shouldContinue: true; replay: TerminalReplayMetadata | null }; export interface TerminalSessionControllerOptions { terminalManager: TerminalManager | null; emit: (msg: SessionOutboundMessage) => void; hasBinaryChannel: () => boolean; + supportsChunkedReplay?: (source: object) => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; sessionLogger: pino.Logger; listTerminalWorkspaceRefs?: () => Promise; listTerminalWorkspaceRoots?: () => Promise; - // 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?: (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 // constant 0 (no backpressure signal) so callers without a transport always @@ -144,7 +141,6 @@ export class TerminalSessionController { private readonly sessionLogger: pino.Logger; private readonly listTerminalWorkspaceRefs: () => Promise; private readonly listTerminalWorkspaceRoots: () => Promise; - private readonly clientSupportsWrapReflow: (source: object) => boolean; private readonly getClientBufferedAmount: (source: object) => number | null; private readonly subscribedDirectories = new Map(); @@ -152,7 +148,7 @@ export class TerminalSessionController { private readonly activeStreams = new Map(); private nextSlot = 0; - constructor(options: TerminalSessionControllerOptions) { + constructor(private readonly options: TerminalSessionControllerOptions) { this.terminalManager = options.terminalManager; this.emit = options.emit; this.hasBinaryChannel = options.hasBinaryChannel; @@ -162,7 +158,6 @@ export class TerminalSessionController { this.listTerminalWorkspaceRoots = options.listTerminalWorkspaceRoots ?? (async () => (await this.listTerminalWorkspaceRefs()).map((workspace) => workspace.cwd)); - this.clientSupportsWrapReflow = options.clientSupportsWrapReflow ?? (() => false); this.getClientBufferedAmount = options.getClientBufferedAmount ?? (() => 0); } @@ -427,6 +422,8 @@ export class TerminalSessionController { } try { + const unavailable = this.terminalManager.getUnavailableError?.(); + if (unavailable) throw unavailable; let terminals: TerminalSession[]; if (msg.workspaceId !== undefined) { terminals = (await this.getAllTerminalSessions()).filter( @@ -449,6 +446,10 @@ export class TerminalSessionController { }); } catch (error) { this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to list terminals"); + if (error instanceof TerminalWorkerIncompatibleError) { + this.emitIncompatible(msg, error); + return; + } this.emit({ type: "list_terminals_response", payload: { @@ -460,6 +461,23 @@ export class TerminalSessionController { } } + // An empty list or a generic failure would read as "no terminals"; the client needs to know + // they exist and why it cannot reach them. + private emitIncompatible( + msg: { type: string; requestId: string }, + error: TerminalWorkerIncompatibleError, + ): void { + this.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: error.message, + code: error.code, + }, + }); + } + private async getAllTerminalSessions(): Promise { if (!this.terminalManager) { return []; @@ -597,6 +615,10 @@ export class TerminalSessionController { }); } catch (error) { this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to create terminal"); + if (error instanceof TerminalWorkerIncompatibleError) { + this.emitIncompatible(msg, error); + return; + } this.emit({ type: "create_terminal_response", payload: { @@ -876,9 +898,10 @@ export class TerminalSessionController { snapshotInFlight: false, retrySnapshotErrors: options.retrySnapshotErrors, exiting: false, - readyRevision: undefined, + snapshotReady: false, restore: options?.restore, bufferedOutputs: [], + bufferedOutputBytes: 0, outputBytesSinceSnapshot: 0, outputCoalescer: new TerminalOutputCoalescer({ timers: { setTimeout, clearTimeout }, @@ -906,7 +929,6 @@ export class TerminalSessionController { // 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; @@ -924,35 +946,45 @@ export class TerminalSessionController { this.activeStreams.set(slot, activeStream); - const unsubscribeOutput = terminal.subscribe( - (message) => { - if (this.activeStreams.get(slot) !== activeStream) { - return; - } - if (message.type === "snapshot" || message.type === "snapshotReady") { - activeStream.readyRevision = message.revision; - activeStream.outputCoalescer.flush(); - activeStream.needsSnapshot = true; - void this.trySendSnapshot(activeStream); - return; - } - if (message.type === "titleChange") { - return; - } - if (message.data.length === 0) { - return; - } - if (activeStream.needsSnapshot || activeStream.snapshotInFlight) { - activeStream.bufferedOutputs.push({ - data: message.data, - revision: message.revision, + const unsubscribeOutput = terminal.subscribe((message) => { + if (this.activeStreams.get(slot) !== activeStream) { + return; + } + if (message.type === "snapshotReady") { + activeStream.snapshotReady = true; + activeStream.outputCoalescer.flush(); + activeStream.needsSnapshot = true; + void this.trySendSnapshot(activeStream); + return; + } + if (message.type === "titleChange") { + return; + } + if (message.data.length === 0) { + return; + } + if (activeStream.needsSnapshot || activeStream.snapshotInFlight) { + activeStream.bufferedOutputBytes += Buffer.byteLength(message.data); + if (activeStream.bufferedOutputBytes > MAX_CLIENT_BUFFERED_BYTES) { + activeStream.owner.emit({ + type: "terminal_stream_exit", + payload: { + terminalId: activeStream.terminalId, + error: "Terminal replay live tail exceeded the buffer; attach again", + }, }); + this.detachRegistration(activeStream); + void activeStream.owner.release(); return; } - activeStream.outputCoalescer.handle(message.data); - }, - { initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) }, - ); + activeStream.bufferedOutputs.push({ + data: message.data, + revision: message.revision, + }); + return; + } + activeStream.outputCoalescer.handle(message.data); + }); const unsubscribeExit = terminal.onExit(() => this.detachStream(terminal.id, { emitExit: true }), ); @@ -989,22 +1021,27 @@ export class TerminalSessionController { this.detachStream(activeStream.terminalId, { emitExit: true }); return; } - if (activeStream.restore && activeStream.readyRevision === undefined) { + if (activeStream.restore && !activeStream.snapshotReady) { return; } activeStream.outputCoalescer.flush(); activeStream.snapshotInFlight = true; try { - const restore = activeStream.restore; + // A pressure catch-up replaces the skipped batch with a recent window. + // Keep the subscription demand intact for later adoption/full restores. + const restore = + activeStream.snapshotOutput !== undefined + ? resolveRestoreAfterOutputOverflow(activeStream.restore) + : activeStream.restore; const snapshotResult = restore ? await this.emitRestoreSnapshot(activeStream, terminalManager, restore) - : await this.emitLegacySnapshot(activeStream, terminalManager); + : await this.emitRestoreSnapshot(activeStream, terminalManager, { mode: "full-snapshot" }); if (!snapshotResult.shouldContinue) { return; } activeStream.snapshotOutput = undefined; - this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision); + this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replay); activeStream.needsSnapshot = false; activeStream.outputBytesSinceSnapshot = 0; } catch (error) { @@ -1042,47 +1079,31 @@ export class TerminalSessionController { } } - private async emitLegacySnapshot( - activeStream: ActiveTerminalStream, - terminalManager: TerminalManager, - ): Promise { - const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { - includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), - }); - if (this.activeStreams.get(activeStream.slot) !== activeStream) { - return { shouldContinue: false }; - } - if (!snapshot) { - this.detachStream(activeStream.terminalId, { emitExit: true }); - return { shouldContinue: false }; - } - - activeStream.owner.emitBinary( - encodeLegacyTerminalSnapshotFrame({ - slot: activeStream.slot, - snapshot, - }), - ); - // The snapshot frame went out-of-band; keep the replay that follows on the - // coalescer's trailing path so it doesn't flush back-to-back with it. - activeStream.outputCoalescer.markFlushed(); - return { shouldContinue: true, replayRevision: snapshot.revision }; - } - private async emitRestoreSnapshot( activeStream: ActiveTerminalStream, terminalManager: TerminalManager, restore: TerminalRestoreOptions, ): Promise { - const snapshotOptions = resolveTerminalRestoreSnapshotOptions(restore); + const chunked = this.options.supportsChunkedReplay?.(activeStream.owner.source) === true; + const snapshotOptions = resolveTerminalRestoreSnapshotOptions({ restore, chunked }); if (snapshotOptions === null) { - return { shouldContinue: true }; + return { shouldContinue: true, replay: null }; } - const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { - ...snapshotOptions, - includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), + const deliver = createTerminalRestoreDelivery({ + slot: activeStream.slot, + chunked, + // Network delivery belongs to the bounded application transport, not the worker request. + send: (frame) => activeStream.owner.emitBinary(frame), }); + const snapshot = await terminalManager.replayTerminal( + activeStream.terminalId, + snapshotOptions, + (event) => { + if (this.activeStreams.get(activeStream.slot) !== activeStream) return; + deliver(event); + }, + ); if (this.activeStreams.get(activeStream.slot) !== activeStream) { return { shouldContinue: false }; } @@ -1091,38 +1112,31 @@ export class TerminalSessionController { return { shouldContinue: false }; } - activeStream.owner.emitBinary( - encodeTerminalRestoreFrame({ - slot: activeStream.slot, - snapshot, - }), - ); // The restore frame went out-of-band; keep the replay that follows on the // coalescer's trailing path so it doesn't flush back-to-back with it. activeStream.outputCoalescer.markFlushed(); - return { shouldContinue: true, replayRevision: snapshot.revision }; + return { shouldContinue: true, replay: snapshot }; } private replayTerminalOutputAfterSnapshot( activeStream: ActiveTerminalStream, terminal: TerminalSession, - replayRevision: number | undefined, + replay: TerminalReplayMetadata | null, ): void { - const replayPreamble = terminal.getReplayPreamble(); + // A captured viewer must start from its cut, then apply the buffered mode changes. + // Live-only subscriptions have no capture and start from the current shared modes. + const replayPreamble = replay === null ? terminal.getReplayPreamble() : replay.replayPreamble; if (replayPreamble.length > 0) { activeStream.outputCoalescer.handle(replayPreamble); } + activeStream.bufferedOutputBytes = 0; const bufferedOutputs = activeStream.bufferedOutputs.splice( 0, activeStream.bufferedOutputs.length, ); for (const output of bufferedOutputs) { - if ( - replayRevision !== undefined && - output.revision !== undefined && - output.revision <= replayRevision - ) { + if (replay !== null && output.revision !== undefined && output.revision <= replay.revision) { continue; } activeStream.outputCoalescer.handle(output.data); diff --git a/packages/server/src/terminal/terminal-worker-client.test.ts b/packages/server/src/terminal/terminal-worker-client.test.ts new file mode 100644 index 0000000000..01a4dd91d3 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-client.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { isPlatform } from "../test-utils/platform.js"; +import { encodeFrame, FrameDecoder, TerminalWorkerFrameError } from "./terminal-worker-frames.js"; +import { connectTerminalWorker, TerminalWorkerConnectError } from "./terminal-worker-client.js"; +import { launchTerminalWorker } from "./terminal-worker-launch.js"; +import { terminalWorkerRecordPath } from "./terminal-worker-record.js"; + +const temporaryDirs: string[] = []; + +afterEach(() => { + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("condition not met in time"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +describe("terminal worker frames", () => { + it("decodes frames split across chunks and several frames in one chunk", () => { + const first = encodeFrame({ type: "a", data: new Map([["k", 1]]) }); + const second = encodeFrame({ type: "b" }); + const joined = Buffer.concat([first, second]); + const decoder = new FrameDecoder(); + expect(decoder.push(joined.subarray(0, 3))).toEqual([]); + expect(decoder.push(joined.subarray(3, first.length + 2))).toEqual([ + { type: "a", data: new Map([["k", 1]]) }, + ]); + expect(decoder.push(joined.subarray(first.length + 2))).toEqual([{ type: "b" }]); + }); + + it("rejects a frame header announcing an oversized payload", () => { + const header = Buffer.alloc(4); + header.writeUInt32BE(0xffffffff, 0); + expect(() => new FrameDecoder().push(header)).toThrow(TerminalWorkerFrameError); + }); +}); + +describe("terminal worker authentication", () => { + it("closes a connection with the wrong secret and accepts the record's secret", async () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "tw-auth-")); + temporaryDirs.push(paseoHome); + const { record, recordPath } = await launchTerminalWorker({ + paseoHome, + paseoVersion: "0.0.0-test", + }); + expect(recordPath).toBe(terminalWorkerRecordPath(paseoHome, record.pid)); + if (!isPlatform("win32")) { + expect(statSync(recordPath).mode & 0o777).toBe(0o600); + expect(statSync(record.socketPath).mode & 0o777).toBe(0o600); + } + + await expect( + connectTerminalWorker({ socketPath: record.socketPath, secret: "wrong-secret" }), + ).rejects.toBeInstanceOf(TerminalWorkerConnectError); + + const connection = await connectTerminalWorker({ + socketPath: record.socketPath, + secret: record.secret, + }); + expect(connection.workerPid).toBe(record.pid); + // With no terminals, a worker whose controller leaves exits and removes its record. + connection.disconnect(); + await waitUntil(() => !existsSync(recordPath), 10_000); + }); +}); + +it("bounds a stalled framed writer to one chunk and resumes in order", async () => { + const { createServer, createConnection } = await import("node:net"); + const { attachFramedSocket } = await import("./terminal-worker-frames.js"); + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing test address"); + const accepted = new Promise((resolve) => + server.once("connection", resolve), + ); + const reader = createConnection(address.port, "127.0.0.1"); + reader.pause(); + const writer = await accepted; + const framed = attachFramedSocket(writer, () => {}); + let completed = 0; + let peakQueuedBytes = 0; + const received: number[] = []; + attachFramedSocket(reader, (value) => received.push((value as { index: number }).index)); + const sending = (async () => { + for (let index = 0; index < 512; index++) { + const pending = framed.sendAndWait({ index, data: Buffer.alloc(64 * 1024) }); + peakQueuedBytes = Math.max(peakQueuedBytes, writer.writableLength); + await pending; + completed++; + } + })(); + try { + await waitUntil(() => writer.writableLength > 0, 10000); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(completed).toBeLessThan(512); + expect(peakQueuedBytes).toBeLessThan(65 * 1024); + reader.resume(); + await sending; + await waitUntil(() => received.length === 512, 10000); + expect(received).toEqual(Array.from({ length: 512 }, (_, index) => index)); + expect(writer.destroyed).toBe(false); + } finally { + reader.destroy(); + writer.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/packages/server/src/terminal/terminal-worker-client.ts b/packages/server/src/terminal/terminal-worker-client.ts new file mode 100644 index 0000000000..532f3075c6 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-client.ts @@ -0,0 +1,141 @@ +import { EventEmitter } from "node:events"; +import { createConnection } from "node:net"; +import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js"; +import { + TERMINAL_WORKER_PROTOCOL_VERSION, + type TerminalWorkerHello, + type TerminalWorkerHelloAck, + type TerminalWorkerRequest, + type TerminalWorkerToParentMessage, +} from "./terminal-worker-protocol.js"; + +const DEFAULT_CONNECT_TIMEOUT_MS = 5_000; + +/** The daemon's (or cleanup's) authenticated connection to one terminal worker. */ +export interface TerminalWorkerConnection { + readonly connected: boolean; + readonly workerPid: number; + send(message: TerminalWorkerRequest, callback: (error: Error | null) => void): boolean; + /** Closes the socket. The worker keeps running and keeps its terminals. */ + disconnect(): void; + on(event: "message", listener: (message: TerminalWorkerToParentMessage) => void): this; + on(event: "close", listener: () => void): this; +} + +export class TerminalWorkerConnectError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "TerminalWorkerConnectError"; + } +} + +class SocketTerminalWorkerConnection extends EventEmitter implements TerminalWorkerConnection { + readonly workerPid: number; + private readonly framed: FramedSocket; + private closed = false; + + constructor(framed: FramedSocket, workerPid: number) { + super(); + this.framed = framed; + this.workerPid = workerPid; + framed.socket.once("close", () => { + this.closed = true; + this.emit("close"); + }); + } + + get connected(): boolean { + return !this.closed && !this.framed.socket.destroyed; + } + + send(message: TerminalWorkerRequest, callback: (error: Error | null) => void): boolean { + return this.framed.send(message, callback); + } + + disconnect(): void { + this.framed.close(); + } + + deliver(message: TerminalWorkerToParentMessage): void { + this.emit("message", message); + } +} + +function isHelloAck(message: unknown): message is TerminalWorkerHelloAck { + return ( + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "helloAck" && + typeof (message as { pid?: unknown }).pid === "number" + ); +} + +/** + * Connects to a worker's socket and authenticates with the record's secret. The worker closes + * the socket on a wrong secret, so a rejected hello surfaces as a connect error. + */ +export function connectTerminalWorker(input: { + socketPath: string; + secret: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = input.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + return new Promise((resolve, reject) => { + const socket = createConnection(input.socketPath); + let connection: SocketTerminalWorkerConnection | null = null; + let settled = false; + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + reject(error); + }; + const timer = setTimeout(() => { + fail( + new TerminalWorkerConnectError( + `Terminal worker at ${input.socketPath} did not answer within ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + const framed = attachFramedSocket(socket, (message) => { + if (connection) { + connection.deliver(message as TerminalWorkerToParentMessage); + return; + } + if (!isHelloAck(message)) { + fail(new TerminalWorkerConnectError("Terminal worker sent an unexpected first frame")); + return; + } + settled = true; + clearTimeout(timer); + connection = new SocketTerminalWorkerConnection(framed, message.pid); + resolve(connection); + }); + // `on`, not `once`: an error after authentication must not go unhandled. `close` + // follows every socket error and ends the connection. + socket.on("error", (error) => { + fail( + new TerminalWorkerConnectError( + `Cannot connect to terminal worker at ${input.socketPath}: ${error.message}`, + { cause: error }, + ), + ); + }); + socket.once("close", () => { + fail( + new TerminalWorkerConnectError( + `Terminal worker at ${input.socketPath} closed the connection during authentication`, + ), + ); + }); + socket.once("connect", () => { + const hello: TerminalWorkerHello = { + type: "hello", + secret: input.secret, + protocolVersion: TERMINAL_WORKER_PROTOCOL_VERSION, + }; + framed.send(hello); + }); + }); +} diff --git a/packages/server/src/terminal/terminal-worker-frames.ts b/packages/server/src/terminal/terminal-worker-frames.ts new file mode 100644 index 0000000000..897e91826f --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-frames.ts @@ -0,0 +1,110 @@ +import type { Socket } from "node:net"; +import { deserialize, serialize } from "node:v8"; + +// Frames on the terminal worker socket: a 4-byte big-endian length, then a v8-serialized +// payload (the same structured-clone semantics the old fork IPC used with +// `serialization: "advanced"`). The frame format is part of the frozen envelope: every worker +// version must keep it. +const FRAME_HEADER_BYTES = 4; +const MAX_FRAME_BYTES = 256 * 1024 * 1024; + +export class TerminalWorkerFrameError extends Error { + constructor(message: string) { + super(message); + this.name = "TerminalWorkerFrameError"; + } +} + +export function encodeFrame(value: unknown): Buffer { + const payload = serialize(value); + if (payload.length > MAX_FRAME_BYTES) { + throw new TerminalWorkerFrameError( + `Terminal worker frame of ${payload.length} bytes is too large`, + ); + } + const header = Buffer.allocUnsafe(FRAME_HEADER_BYTES); + header.writeUInt32BE(payload.length, 0); + return Buffer.concat([header, payload]); +} + +export class FrameDecoder { + private buffered: Buffer = Buffer.alloc(0); + + push(chunk: Buffer): unknown[] { + this.buffered = this.buffered.length === 0 ? chunk : Buffer.concat([this.buffered, chunk]); + const messages: unknown[] = []; + while (this.buffered.length >= FRAME_HEADER_BYTES) { + const length = this.buffered.readUInt32BE(0); + if (length > MAX_FRAME_BYTES) { + throw new TerminalWorkerFrameError(`Terminal worker frame of ${length} bytes is too large`); + } + const end = FRAME_HEADER_BYTES + length; + if (this.buffered.length < end) break; + messages.push(deserialize(this.buffered.subarray(FRAME_HEADER_BYTES, end))); + this.buffered = this.buffered.subarray(end); + } + return messages; + } +} + +export interface FramedSocket { + readonly socket: Socket; + send(message: unknown, callback?: (error: Error | null) => void): boolean; + sendAndWait(message: unknown): Promise; + close(): void; +} + +/** + * Wraps a connected socket in length-prefixed frames. A malformed frame destroys the socket: + * the peer is either not a terminal worker endpoint or is corrupt, and both end the session. + */ +export function attachFramedSocket( + socket: Socket, + onMessage: (message: unknown) => void, +): FramedSocket { + const decoder = new FrameDecoder(); + socket.on("data", (chunk: Buffer) => { + let messages: unknown[]; + try { + messages = decoder.push(chunk); + } catch (error) { + socket.destroy(error instanceof Error ? error : new Error(String(error))); + return; + } + for (const message of messages) { + if (socket.destroyed) return; + onMessage(message); + } + }); + return { + socket, + send(message, callback) { + if (socket.destroyed || !socket.writable) { + callback?.(new Error("Terminal worker socket is closed")); + return false; + } + let frame: Buffer; + try { + frame = encodeFrame(message); + } catch (error) { + callback?.(error instanceof Error ? error : new Error(String(error))); + return false; + } + return socket.write(frame, (error) => callback?.(error ?? null)); + }, + sendAndWait(message) { + return new Promise((resolve, reject) => { + this.send(message, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, + close() { + socket.end(() => socket.destroy()); + }, + }; +} diff --git a/packages/server/src/terminal/terminal-worker-launch.ts b/packages/server/src/terminal/terminal-worker-launch.ts new file mode 100644 index 0000000000..573abe3bd8 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-launch.ts @@ -0,0 +1,93 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { buildSelfNodeCommand } from "../server/paseo-env.js"; +import { ensurePrivateDirectory } from "../server/private-files.js"; +import { + readTerminalWorkerRecord, + terminalWorkerDirectory, + terminalWorkerRecordPath, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; +import { resolveTerminalWorkerEntry, type TerminalWorkerEntry } from "./terminal-worker-runtime.js"; + +const LAUNCH_TIMEOUT_MS = 20_000; + +export class TerminalWorkerLaunchError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "TerminalWorkerLaunchError"; + } +} + +/** + * Starts a terminal worker outside this process's tree: the launcher spawns the worker + * detached and exits once it listens, so the worker is re-parented and survives this daemon. + * Resolves with the record the worker wrote; a failed start leaves no worker behind. + */ +export async function launchTerminalWorker(input: { + paseoHome: string; + paseoVersion: string; + timeoutMs?: number; + /** Defaults to the copy of this installation's bundle (source in dev). */ + entry?: TerminalWorkerEntry; +}): Promise<{ record: TerminalWorkerRecord; recordPath: string }> { + const recordId = randomUUID(); + ensurePrivateDirectory(terminalWorkerDirectory(input.paseoHome)); + const entry = input.entry ?? resolveTerminalWorkerEntry(input); + const launch = buildSelfNodeCommand([ + ...entry.execArgv, + entry.scriptPath, + "--launch", + "--record-id", + recordId, + "--paseo-home", + input.paseoHome, + "--paseo-version", + input.paseoVersion, + ...(entry.runtime ? ["--runtime", entry.runtime] : []), + ]); + const stdout = await new Promise((resolve, reject) => { + execFile( + launch.command, + launch.args, + { + env: launch.env, + timeout: input.timeoutMs ?? LAUNCH_TIMEOUT_MS, + windowsHide: true, + encoding: "utf8", + }, + (error, out, err) => { + if (error) { + const detail = err.trim() || error.message; + reject( + new TerminalWorkerLaunchError(`Terminal worker failed to start: ${detail}`, { + cause: error, + }), + ); + return; + } + resolve(out); + }, + ); + }); + + let pid: number; + try { + const parsed = JSON.parse(stdout.trim()) as { pid?: unknown }; + if (typeof parsed.pid !== "number") throw new Error("missing pid"); + pid = parsed.pid; + } catch (error) { + throw new TerminalWorkerLaunchError( + `Terminal worker launcher printed an unexpected result: ${stdout.trim()}`, + { cause: error }, + ); + } + const recordPath = terminalWorkerRecordPath(input.paseoHome, pid); + const record = await readTerminalWorkerRecord(recordPath); + if (record.recordId !== recordId) { + throw new TerminalWorkerLaunchError( + `Terminal worker record ${recordPath} belongs to another launch`, + ); + } + return { record, recordPath }; +} diff --git a/packages/server/src/terminal/terminal-worker-ownership.test.ts b/packages/server/src/terminal/terminal-worker-ownership.test.ts new file mode 100644 index 0000000000..7abbb9aa62 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-ownership.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import type { ManagedProcessInspection } from "../server/managed-processes/managed-processes.js"; +import { + classifyTerminalProgramProcess, + classifyTerminalWorkerProcess, +} from "./terminal-worker-ownership.js"; + +const BIRTH = "Wed Oct 21 18:12:49 2026"; +const OTHER_BIRTH = "Wed Oct 21 18:13:02 2026"; + +function alive(startedAt: string | null, commandLine: string | null): ManagedProcessInspection { + return { status: "alive", snapshot: { pid: 4242, startedAt, commandLine } }; +} + +const workerRecord = { + recordId: "record-1", + identity: { + startedAt: BIRTH, + commandLine: "node terminal-worker-process.js --paseo-terminal-worker record-1", + }, +}; + +describe("terminal program ownership", () => { + it("owns a program that ran exec: same pid and birth time, new command line", () => { + expect( + classifyTerminalProgramProcess({ startedAt: BIRTH }, alive(BIRTH, "sleep 100000")), + ).toEqual({ status: "owned" }); + }); + + it("treats a reused pid with the same command line but another birth time as unrelated", () => { + expect( + classifyTerminalProgramProcess({ startedAt: BIRTH }, alive(OTHER_BIRTH, "/bin/zsh -l")), + ).toMatchObject({ status: "unrelated" }); + }); + + it("never owns a process without a birth time on either side, whatever its command", () => { + expect( + classifyTerminalProgramProcess({ startedAt: BIRTH }, alive(null, "/bin/zsh -l")), + ).toMatchObject({ status: "unknown" }); + expect( + classifyTerminalProgramProcess({ startedAt: null }, alive(BIRTH, "/bin/zsh -l")), + ).toMatchObject({ status: "unknown" }); + }); + + it("reports an inspection error as unknown, not gone", () => { + expect( + classifyTerminalProgramProcess( + { startedAt: BIRTH }, + { status: "error", error: new Error("ps failed") }, + ), + ).toMatchObject({ status: "unknown", reason: expect.stringContaining("ps failed") }); + }); + + it("reports a missing process as gone", () => { + expect(classifyTerminalProgramProcess({ startedAt: BIRTH }, { status: "not-found" })).toEqual({ + status: "gone", + }); + }); +}); + +describe("terminal worker ownership", () => { + it("owns the worker whose birth time matches and whose command line carries its record id", () => { + expect( + classifyTerminalWorkerProcess(workerRecord, alive(BIRTH, workerRecord.identity.commandLine)), + ).toEqual({ status: "owned" }); + }); + + it("treats another worker reusing the pid in the same second as unrelated", () => { + expect( + classifyTerminalWorkerProcess( + workerRecord, + alive(BIRTH, "node terminal-worker-process.js --paseo-terminal-worker record-2"), + ), + ).toMatchObject({ status: "unrelated" }); + }); + + it("treats a worker pid reused with another birth time as unrelated", () => { + expect( + classifyTerminalWorkerProcess( + workerRecord, + alive(OTHER_BIRTH, workerRecord.identity.commandLine), + ), + ).toMatchObject({ status: "unrelated" }); + }); + + it("does not own a worker whose command line cannot be read", () => { + expect(classifyTerminalWorkerProcess(workerRecord, alive(BIRTH, null))).toMatchObject({ + status: "unknown", + }); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-ownership.ts b/packages/server/src/terminal/terminal-worker-ownership.ts new file mode 100644 index 0000000000..9bd02b01cd --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-ownership.ts @@ -0,0 +1,69 @@ +import type { ManagedProcessInspection } from "../server/managed-processes/managed-processes.js"; +import { TERMINAL_WORKER_MARKER_ARG, type TerminalWorkerRecord } from "./terminal-worker-record.js"; + +// Who a pid named in a terminal worker record belongs to, before anything signals it. +// +// This is deliberately not `processIdentityMatches` from managed-processes.ts: that matcher +// falls back to command text when a birth time is missing, and requires the original argv when +// it is present. A terminal program is often a shell that ran `exec`, which keeps its pid and +// birth time but changes its command line; and a reused pid can run the same command line. +// Here birth time alone identifies a terminal program, and nothing without one is ever owned. + +export type ProcessOwnership = + | { status: "gone" } + | { status: "unrelated"; reason: string } + | { status: "owned" } + | { status: "unknown"; reason: string }; + +export function classifyTerminalWorkerProcess( + record: Pick, + inspection: ManagedProcessInspection, +): ProcessOwnership { + const birth = classifyBirth(record.identity.startedAt, inspection); + if (birth.status !== "owned" || inspection.status !== "alive") return birth; + const commandLine = inspection.snapshot.commandLine; + if (!commandLine) { + return { status: "unknown", reason: "command line unavailable" }; + } + if (!hasWorkerMarker(commandLine, record.recordId)) { + return { status: "unrelated", reason: "command line lacks the worker marker" }; + } + return { status: "owned" }; +} + +export function classifyTerminalProgramProcess( + entry: { startedAt: string | null }, + inspection: ManagedProcessInspection, +): ProcessOwnership { + return classifyBirth(entry.startedAt, inspection); +} + +function classifyBirth( + recordedStartedAt: string | null, + inspection: ManagedProcessInspection, +): ProcessOwnership { + if (inspection.status === "not-found") return { status: "gone" }; + if (inspection.status === "error") { + const message = + inspection.error instanceof Error ? inspection.error.message : String(inspection.error); + return { status: "unknown", reason: `inspection failed: ${message}` }; + } + const startedAt = inspection.snapshot.startedAt; + if (!recordedStartedAt || !startedAt) { + return { status: "unknown", reason: "birth time unavailable" }; + } + if (startedAt !== recordedStartedAt) { + return { status: "unrelated", reason: "pid reused by a process with another birth time" }; + } + return { status: "owned" }; +} + +function hasWorkerMarker(commandLine: string, recordId: string): boolean { + const tokens = commandLine.split(/\s+/); + for (let index = 0; index < tokens.length - 1; index += 1) { + if (tokens[index] === TERMINAL_WORKER_MARKER_ARG && tokens[index + 1] === recordId) { + return true; + } + } + return false; +} diff --git a/packages/server/src/terminal/terminal-worker-pool.ts b/packages/server/src/terminal/terminal-worker-pool.ts new file mode 100644 index 0000000000..517d34aa33 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-pool.ts @@ -0,0 +1,288 @@ +import type { TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; +import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; +import type { + TerminalActivityListener, + TerminalsChangedListener, + TerminalWorkspaceContributionChangedListener, +} from "./terminal-manager.js"; +import type { TerminalSession } from "./terminal.js"; +import type { WorkerCreateTerminalOptions } from "./terminal-worker-protocol.js"; +import type { DaemonTerminalManager } from "./worker-terminal-manager.js"; + +// The daemon's terminal service over up to two terminal workers: the current one, which takes +// every new terminal, and one draining a previous worker protocol, which keeps serving the +// terminals it already runs and is let go once its last one exits. With no current worker (two +// were already running), the first terminal created after one exits starts one. A worker from a +// newer Paseo +// is not driven at all: the service then refuses terminal requests with +// TerminalWorkerIncompatibleError instead of serving a partial view. + +export class TerminalWorkerIncompatibleError extends Error { + readonly code = "terminal_worker_incompatible"; + readonly paseoVersion: string; + readonly protocolVersion: unknown; + + constructor(input: { paseoVersion: string; protocolVersion: unknown }) { + super( + `Terminals are held by a newer Paseo (v${input.paseoVersion}, worker protocol ${String(input.protocolVersion)}). Update Paseo to v${input.paseoVersion} or later to use them, or run \`paseo daemon stop\` to close them.`, + ); + this.name = "TerminalWorkerIncompatibleError"; + this.paseoVersion = input.paseoVersion; + this.protocolVersion = input.protocolVersion; + } +} + +export class NoTerminalWorkerAvailableError extends Error { + constructor(message: string) { + super(message); + this.name = "NoTerminalWorkerAvailableError"; + } +} + +export interface TerminalWorkerPoolMember { + manager: DaemonTerminalManager; + workerPid: number; + /** Resolves only after confirmed worker death and its scoped cleanup. */ + exited: Promise; + /** Waits for any reconnect; false once the member is retired or stopping. */ + ready(): Promise; + stopRecovery(): void; +} + +export interface TerminalWorkerPoolOptions { + current: TerminalWorkerPoolMember | null; + draining: TerminalWorkerPoolMember[]; + /** Set when a worker from a newer Paseo holds this home's terminals. */ + incompatible: TerminalWorkerIncompatibleError | null; + /** + * Starts a current worker when there is none. Throws NoTerminalWorkerAvailableError, saying + * why, while no worker may start. + */ + startCurrent?: () => Promise; + /** Closes every terminal of every worker in the home and verifies they ended. */ + closeAll: () => Promise; + /** A draining worker's last terminal exited and the pool let it go. */ + onDrained?: (member: TerminalWorkerPoolMember) => void; +} + +export function createTerminalWorkerPool( + options: TerminalWorkerPoolOptions, +): DaemonTerminalManager { + let current = options.current; + const draining = new Set(options.draining); + const terminalsChangedListeners = new Set(); + const terminalActivityListeners = new Set(); + const contributionListeners = new Set(); + let closePromise: Promise | null = null; + let detached = false; + // One start at a time, so concurrent creates never launch two workers. + let starting: Promise | null = null; + // Registrations belong to the service and are replayed whenever a current worker starts. + const cwdEnv = new Map>(); + + function members(): TerminalWorkerPoolMember[] { + return [...(current ? [current] : []), ...draining]; + } + + function assertAvailable(): void { + if (options.incompatible) throw options.incompatible; + } + + function ownerOf(terminalId: string): DaemonTerminalManager | null { + for (const member of members()) { + if (member.manager.getTerminal(terminalId)) return member.manager; + } + return null; + } + + function routed(terminalId: string): DaemonTerminalManager { + assertAvailable(); + const owner = ownerOf(terminalId) ?? current?.manager; + if (!owner) { + throw new NoTerminalWorkerAvailableError(`Terminal ${terminalId} is not running`); + } + return owner; + } + + function releaseIfDrained(member: TerminalWorkerPoolMember): void { + if (!draining.has(member) || member.manager.listDirectories().length > 0) return; + draining.delete(member); + // With no controller and no terminals the worker exits on its own. The request that closed + // the last terminal is still waiting for its answer. + void member.manager + .detach({ afterPendingRequests: true }) + .then(() => options.onDrained?.(member)); + } + + function forwardEvents(member: TerminalWorkerPoolMember): void { + void member.exited.then(() => { + if (current === member) current = null; + return draining.delete(member); + }); + member.manager.subscribeTerminalsChanged((event) => { + for (const listener of Array.from(terminalsChangedListeners)) listener(event); + releaseIfDrained(member); + }); + member.manager.subscribeTerminalActivity((event) => { + for (const listener of Array.from(terminalActivityListeners)) listener(event); + }); + member.manager.subscribeTerminalWorkspaceContributionChanged((event) => { + for (const listener of Array.from(contributionListeners)) listener(event); + }); + } + + for (const member of members()) forwardEvents(member); + for (const member of Array.from(draining)) releaseIfDrained(member); + + async function currentManager(): Promise { + if (closePromise || detached) + throw new NoTerminalWorkerAvailableError("No terminal worker can take new terminals"); + const candidate = current; + if (candidate && (await candidate.ready())) return candidate.manager; + const startCurrent = options.startCurrent; + if (!startCurrent || closePromise || detached) { + return Promise.reject( + new NoTerminalWorkerAvailableError("No terminal worker can take new terminals"), + ); + } + starting ??= startCurrent() + .then(async (member) => { + if (closePromise || detached) { + // The service closed or let go while the worker started: it takes no terminals. + member.stopRecovery(); + await member.manager.detach(); + throw new NoTerminalWorkerAvailableError("No terminal worker can take new terminals"); + } + forwardEvents(member); + for (const [cwd, env] of cwdEnv) member.manager.registerCwdEnv({ cwd, env }); + current = member; + return member; + }) + .finally(() => { + starting = null; + }); + return starting.then((member) => member.manager); + } + + function closeTerminals(): Promise { + closePromise ??= (async () => { + for (const member of members()) member.stopRecovery(); + await starting?.catch(() => {}); + await Promise.all(members().map((member) => member.manager.detach())); + draining.clear(); + await options.closeAll(); + })(); + return closePromise; + } + + return { + getUnavailableError(): Error | null { + return options.incompatible; + }, + + async getTerminals(cwd, filter) { + assertAvailable(); + const lists = await Promise.all( + members().map((member) => member.manager.getTerminals(cwd, filter)), + ); + return lists.flat(); + }, + + async createTerminal( + createOptions: WorkerCreateTerminalOptions & { workspaceId: string }, + ): Promise { + assertAvailable(); + const manager = await currentManager(); + return manager.createTerminal(createOptions); + }, + + registerCwdEnv(registration) { + cwdEnv.set(registration.cwd, registration.env); + if (current) current.manager.registerCwdEnv(registration); + }, + + validateTerminalActivityToken(terminalId, token) { + for (const member of members()) { + const result = member.manager.validateTerminalActivityToken(terminalId, token); + if (result !== "unknown") return result; + } + return "unknown"; + }, + + getTerminal(id) { + for (const member of members()) { + const session = member.manager.getTerminal(id); + if (session) return session; + } + return undefined; + }, + + async replayTerminal(id, replayOptions, sink) { + return routed(id).replayTerminal(id, replayOptions, sink); + }, + + setTerminalTitle(id, title) { + return ownerOf(id)?.setTerminalTitle(id, title) ?? false; + }, + + async setTerminalActivity(id, state: TerminalActivityState) { + return (await ownerOf(id)?.setTerminalActivity(id, state)) ?? false; + }, + + async clearTerminalAttention(id) { + return (await ownerOf(id)?.clearTerminalAttention(id)) ?? false; + }, + + killTerminal(id) { + ownerOf(id)?.killTerminal(id); + }, + + async killTerminalAndWait(id, waitOptions) { + await routed(id).killTerminalAndWait(id, waitOptions); + }, + + async captureTerminal(id, captureOptions): Promise { + return routed(id).captureTerminal(id, captureOptions); + }, + + listDirectories() { + return [...new Set(members().flatMap((member) => member.manager.listDirectories()))]; + }, + + killAll() { + void closeTerminals().catch(() => { + // The synchronous interface cannot report it; closeTerminals() callers see the error. + }); + }, + + closeTerminals, + + async detach() { + detached = true; + for (const member of members()) member.stopRecovery(); + await starting?.catch(() => {}); + await Promise.all(members().map((member) => member.manager.detach())); + }, + + async adoptTerminals() { + const counts = await Promise.all(members().map((member) => member.manager.adoptTerminals())); + for (const member of Array.from(draining)) releaseIfDrained(member); + return counts.reduce((sum, count) => sum + count, 0); + }, + + subscribeTerminalsChanged(listener) { + terminalsChangedListeners.add(listener); + return () => terminalsChangedListeners.delete(listener); + }, + + subscribeTerminalActivity(listener) { + terminalActivityListeners.add(listener); + return () => terminalActivityListeners.delete(listener); + }, + + subscribeTerminalWorkspaceContributionChanged(listener) { + contributionListeners.add(listener); + return () => contributionListeners.delete(listener); + }, + }; +} diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index a70d37c5b9..855c39da59 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -1,352 +1,765 @@ +import { createTerminalReplayController } from "./terminal-replay.js"; +import { spawn } from "node:child_process"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { chmodSync, closeSync, existsSync, openSync, rmSync, writeSync } from "node:fs"; +import { createServer, type Server, type Socket } from "node:net"; +import { fileURLToPath } from "node:url"; +import { currentBootId } from "../server/pid-lock.js"; +import { PRIVATE_FILE_MODE } from "../server/private-files.js"; import { createTerminalManager } from "./terminal-manager.js"; -import { captureTerminalLines } from "./terminal-capture.js"; import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js"; -import type { TerminalSession, TerminalStateSnapshotOptions } from "./terminal.js"; -import type { - TerminalWorkerRequest, - TerminalWorkerStateResult, - TerminalWorkerToParentMessage, - WorkerTerminalInfo, +import type { TerminalExitInfo, LocalTerminalSession } from "./terminal.js"; +import { attachFramedSocket, type FramedSocket } from "./terminal-worker-frames.js"; +import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js"; +import { + TERMINAL_WORKER_MARKER_ARG, + terminalWorkerLogPath, + terminalWorkerRecordPath, + writeTerminalWorkerRecord, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; +import { createTerminalWorkerRecordPublisher } from "./terminal-worker-record-publisher.js"; +import { resolveTerminalWorkerSocketPath } from "./terminal-worker-socket-path.js"; +import { + TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV, + TERMINAL_WORKER_PROTOCOL_VERSION, + type TerminalWorkerHello, + type TerminalWorkerHelloAck, + type TerminalWorkerListResult, + type TerminalWorkerRequest, + type TerminalWorkerToParentMessage, + type WorkerTerminalInfo, } from "./terminal-worker-protocol.js"; -type TerminalCreateRequest = Extract; +// The terminal worker owns every PTY. It runs detached from the daemon (re-parented by the +// launcher below) so a daemon restart, crash or forced kill of the daemon's process tree does +// not take the terminals down, and it talks to whichever daemon controls it over an +// owner-only socket authenticated by the secret in its record. +// +// `--launch` runs the launcher: it starts the real worker detached, waits for it to listen, +// prints `{ pid, socketPath }` and exits, which leaves the worker parented to init. -const manager = createTerminalManager(); -const unsubscribeByTerminalId = new Map void>>(); -const outputCoalescerByTerminalId = new Map(); -let ipcClosing = false; +const LAUNCH_ARG = "--launch"; +const READY_TIMEOUT_MS = 15_000; +const HELLO_TIMEOUT_MS = 5_000; +// A freshly launched worker waits this long for its first controller before giving up. +const FIRST_CONTROLLER_TIMEOUT_MS = 30_000; +// While nobody controls it, the worker checks that its record still exists. A deleted record +// (a removed $PASEO_HOME) means no daemon can ever find these terminals again. +const ORPHAN_CHECK_INTERVAL_MS = 10_000; +const READY_FD = 3; -interface InFlightTerminalCreateRequest { - requestId: string; - errorReported: boolean; +interface WorkerArgs { + recordId: string; + paseoHome: string; + paseoVersion: string; + // The copy under terminal-worker/ this worker runs from; absent when it runs from source. + runtime: string | null; } -let inFlightTerminalCreateRequest: InFlightTerminalCreateRequest | null = null; - -// The conpty failure signal is process-scoped, not request-scoped. Serializing -// creates keeps an async spawn failure attributable to exactly one request. -let createTerminalQueue: Promise = Promise.resolve(); - -// node-pty completes its Windows conpty spawn asynchronously on a separate -// conout worker thread. When that spawn fails (bad cwd, missing command, etc.) -// it throws an exception there that cannot be caught at the call site and would -// otherwise crash this worker process and sever every existing terminal. -process.on("uncaughtException", (error) => { - console.error("Terminal worker uncaught exception (kept alive):", error); - reportInFlightTerminalCreateFailure(error); -}); - -function sendToParent(message: TerminalWorkerToParentMessage): void { - if (ipcClosing || !process.connected || !process.send) { - return; - } - try { - process.send(message, (error) => { - if (error) { - ipcClosing = true; - } - }); - } catch { - ipcClosing = true; +function readArg(argv: string[], name: string): string { + const index = argv.indexOf(name); + const value = index === -1 ? undefined : argv[index + 1]; + if (!value) { + throw new Error(`Terminal worker requires ${name}`); } + return value; } -function buildTerminalStateResult( - session: TerminalSession | undefined, - options?: TerminalStateSnapshotOptions, -): TerminalWorkerStateResult { - if (!session) { - return null; - } - return { ...session.getStateSnapshot(options), replayPreamble: session.getReplayPreamble() }; -} - -function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo { +function parseWorkerArgs(argv: string[], recordIdArg: string): WorkerArgs { return { - id: session.id, - name: session.name, - cwd: session.cwd, - workspaceId: session.workspaceId, - ...(session.getTitle() ? { title: session.getTitle() } : {}), - activity: session.getActivity(), + recordId: readArg(argv, recordIdArg), + paseoHome: readArg(argv, "--paseo-home"), + paseoVersion: readArg(argv, "--paseo-version"), + runtime: argv.includes("--runtime") ? readArg(argv, "--runtime") : null, }; } -function terminalWorkerErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Terminal worker request failed"; -} - -function reportInFlightTerminalCreateFailure(error: unknown): void { - if (!inFlightTerminalCreateRequest || inFlightTerminalCreateRequest.errorReported) { - return; +// The protocol version this worker reports in its record and hello. Tests override it to stand +// up older and newer workers from one build. +function reportedProtocolVersion(): number { + const override = process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + if (override === undefined || override === "") return TERMINAL_WORKER_PROTOCOL_VERSION; + const parsed = Number(override); + if (!Number.isSafeInteger(parsed)) { + throw new Error(`${TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV} is not an integer: ${override}`); } - inFlightTerminalCreateRequest.errorReported = true; - sendToParent({ - type: "response", - requestId: inFlightTerminalCreateRequest.requestId, - ok: false, - error: terminalWorkerErrorMessage(error), - }); + return parsed; } -function clearTerminalSubscriptions(terminalId: string): void { - const subscriptions = unsubscribeByTerminalId.get(terminalId); - if (subscriptions) { - for (const unsubscribe of subscriptions) { +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// --------------------------------------------------------------------------------------------- +// Launcher +// --------------------------------------------------------------------------------------------- + +function runLauncher(argv: string[]): void { + const args = parseWorkerArgs(argv, "--record-id"); + const logFd = openSync(terminalWorkerLogPath(args.paseoHome), "a", PRIVATE_FILE_MODE); + const child = spawn( + process.execPath, + [ + ...process.execArgv, + fileURLToPath(import.meta.url), + TERMINAL_WORKER_MARKER_ARG, + args.recordId, + "--paseo-home", + args.paseoHome, + "--paseo-version", + args.paseoVersion, + ...(args.runtime ? ["--runtime", args.runtime] : []), + ], + { + detached: true, + stdio: ["ignore", "ignore", logFd, "pipe"], + env: process.env, + windowsHide: true, + }, + ); + closeSync(logFd); + + let settled = false; + let buffered = ""; + const finish = (code: number, output: string) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code !== 0 && child.exitCode === null && child.pid) { + // A worker that never reported ready must not be left behind. try { - unsubscribe(); + process.kill(child.pid, "SIGKILL"); } catch { - // no-op + // Already gone. } } - } - unsubscribeByTerminalId.delete(terminalId); - const coalescer = outputCoalescerByTerminalId.get(terminalId); - if (coalescer) { - coalescer.dispose(); - outputCoalescerByTerminalId.delete(terminalId); - } + child.unref(); + (child.stdio[READY_FD] as NodeJS.ReadableStream & { destroy(): void }).destroy(); + // Exit from the write callback: a pipe write can still be pending when process.exit runs. + (code === 0 ? process.stdout : process.stderr).write(output, () => process.exit(code)); + }; + const timer = setTimeout(() => { + finish(1, `Terminal worker did not report ready within ${READY_TIMEOUT_MS}ms\n`); + }, READY_TIMEOUT_MS); + + const readyStream = child.stdio[READY_FD] as NodeJS.ReadableStream; + readyStream.setEncoding("utf8"); + readyStream.on("data", (chunk: string) => { + buffered += chunk; + const newline = buffered.indexOf("\n"); + if (newline === -1) return; + const line = buffered.slice(0, newline); + if (line.startsWith("ready ")) { + finish(0, `${JSON.stringify({ pid: child.pid, socketPath: line.slice("ready ".length) })}\n`); + return; + } + finish(1, `${line.startsWith("error ") ? line.slice("error ".length) : line}\n`); + }); + child.once("error", (error) => finish(1, `Cannot start terminal worker: ${error.message}\n`)); + child.once("exit", (code, signal) => { + finish(1, `Terminal worker exited before it was ready (${signal ?? `code ${code}`})\n`); + }); } -function watchTerminal(session: TerminalSession): void { - clearTerminalSubscriptions(session.id); +// --------------------------------------------------------------------------------------------- +// Worker +// --------------------------------------------------------------------------------------------- - // Coalesce pty output chunks into a single IPC message per ~5ms window so a - // burst of small chunks no longer costs one process.send each. The batch - // carries the LAST chunk's revision (the highest) so downstream snapshot - // replay dedup stays correct. - let pendingOutputRevision: number | undefined; - const outputCoalescer = new TerminalOutputCoalescer({ - timers: { setTimeout, clearTimeout }, - onFlush: ({ payload }) => { - const revision = pendingOutputRevision; - pendingOutputRevision = undefined; - sendToParent({ - type: "terminalMessage", - terminalId: session.id, - message: { type: "output", data: payload.toString("utf8"), revision }, - replayPreamble: session.getReplayPreamble(), - }); - }, +type TerminalCreateRequest = Extract; + +function runWorker(argv: string[]): void { + const args = parseWorkerArgs(argv, TERMINAL_WORKER_MARKER_ARG); + const protocolVersion = reportedProtocolVersion(); + const manager = createTerminalManager(); + const inspector = createTerminalWorkerProcessInspector(); + const sessionsById = new Map(); + const unsubscribeByTerminalId = new Map void>>(); + const outputCoalescerByTerminalId = new Map(); + // A terminal is published (terminalCreated) only once its program is recorded. An exit that + // arrives before then is held here and sent after the creation, so the controller never + // receives an exit for a terminal it does not know yet. + const exitsHeldDuringCreation = new Map(); + const secret = randomBytes(32).toString("base64url"); + let record: TerminalWorkerRecord | null = null; + let server: Server | null = null; + let controller: FramedSocket | null = null; + let everControlled = false; + let exiting = false; + + interface InFlightTerminalCreateRequest { + requestId: string; + errorReported: boolean; + } + + let inFlightTerminalCreateRequest: InFlightTerminalCreateRequest | null = null; + + // The conpty failure signal is process-scoped, not request-scoped. Serializing + // creates keeps an async spawn failure attributable to exactly one request. + let createTerminalQueue: Promise = Promise.resolve(); + + // node-pty completes its Windows conpty spawn asynchronously on a separate + // conout worker thread. When that spawn fails (bad cwd, missing command, etc.) + // it throws an exception there that cannot be caught at the call site and would + // otherwise crash this worker process and sever every existing terminal. + process.on("uncaughtException", (error) => { + console.error("Terminal worker uncaught exception (kept alive):", error); + reportInFlightTerminalCreateFailure(error); }); - outputCoalescerByTerminalId.set(session.id, outputCoalescer); - const unsubscribeMessage = session.subscribe( - (message) => { + const replayController = createTerminalReplayController({ + getSession: (id) => sessionsById.get(id), + flushOutput: (id) => outputCoalescerByTerminalId.get(id)?.flush(), + }); + function sendToController(message: TerminalWorkerToParentMessage): void { + replayController.send(message); + } + + const recordPublisher = createTerminalWorkerRecordPublisher((state) => { + writeTerminalWorkerRecord(args.paseoHome, state); + }); + + async function persistRecord(): Promise { + if (!record || exiting) return; + record = { + ...record, + terminals: record.terminals.filter((entry) => sessionsById.has(entry.id)), + }; + await recordPublisher.publish(record); + } + + function removeOwnFiles(): void { + rmSync(terminalWorkerRecordPath(args.paseoHome, process.pid), { force: true }); + if (record && process.platform !== "win32") { + rmSync(record.socketPath, { force: true }); + } + } + + function exitWorker(code: number): void { + if (exiting) return; + exiting = true; + recordPublisher.stop(); + removeOwnFiles(); + server?.close(); + process.exit(code); + } + + async function killEveryTerminalAndExit(): Promise { + const sessions = Array.from(sessionsById.values()); + await Promise.all( + sessions.map((session) => + session.killAndWait().catch((error: unknown) => { + console.error(`Terminal worker could not kill terminal ${session.id}:`, error); + }), + ), + ); + exitWorker(0); + } + + function exitIfIdle(): void { + if (!controller && everControlled && sessionsById.size === 0) { + exitWorker(0); + } + } + + function terminalMetadata(session: LocalTerminalSession) { + return { ...session.getSize(), hasOutput: session.hasOutput(), title: session.getTitle() }; + } + + function toTerminalInfo(session: LocalTerminalSession): WorkerTerminalInfo { + return { + id: session.id, + name: session.name, + cwd: session.cwd, + workspaceId: session.workspaceId, + ...(session.getTitle() ? { title: session.getTitle() } : {}), + activity: session.getActivity(), + }; + } + + function terminalWorkerErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Terminal worker request failed"; + } + + function reportInFlightTerminalCreateFailure(error: unknown): void { + if (!inFlightTerminalCreateRequest || inFlightTerminalCreateRequest.errorReported) { + return; + } + inFlightTerminalCreateRequest.errorReported = true; + sendToController({ + type: "response", + requestId: inFlightTerminalCreateRequest.requestId, + ok: false, + error: terminalWorkerErrorMessage(error), + }); + } + + function clearTerminalSubscriptions(terminalId: string): void { + const subscriptions = unsubscribeByTerminalId.get(terminalId); + if (subscriptions) { + for (const unsubscribe of subscriptions) { + try { + unsubscribe(); + } catch { + // no-op + } + } + } + unsubscribeByTerminalId.delete(terminalId); + const coalescer = outputCoalescerByTerminalId.get(terminalId); + if (coalescer) { + coalescer.dispose(); + outputCoalescerByTerminalId.delete(terminalId); + } + } + + function watchTerminal(session: LocalTerminalSession): void { + clearTerminalSubscriptions(session.id); + + // Coalesce pty output chunks into a single frame per ~5ms window so a burst of small + // chunks no longer costs one socket write each. The batch carries the LAST chunk's + // revision (the highest) so downstream snapshot replay dedup stays correct. + let pendingOutputRevision: number | undefined; + const outputCoalescer = new TerminalOutputCoalescer({ + timers: { setTimeout, clearTimeout }, + onFlush: ({ payload }) => { + const revision = pendingOutputRevision; + pendingOutputRevision = undefined; + sendToController({ + type: "terminalMessage", + terminalId: session.id, + message: { type: "output", data: payload.toString("utf8"), revision }, + replayPreamble: session.getReplayPreamble(), + }); + }, + }); + outputCoalescerByTerminalId.set(session.id, outputCoalescer); + + const unsubscribeMessage = session.subscribe((message) => { if (message.type === "output") { pendingOutputRevision = message.revision; outputCoalescer.handle(message.data); return; } - // Non-output messages (snapshot/snapshotReady/titleChange) must not jump + // Non-output messages (snapshotReady/titleChange) must not jump // ahead of buffered output: flush the coalescer first, then forward. outputCoalescer.flush(); - sendToParent({ + sendToController({ type: "terminalMessage", terminalId: session.id, message, replayPreamble: session.getReplayPreamble(), }); - }, - // Creation already sends an authoritative state in terminalCreated. A second - // asynchronous state snapshot can arrive after the create response and - // overwrite a resize issued immediately by the caller. - { initialSnapshot: "ready" }, - ); - const unsubscribeExit = session.onExit((info) => { - outputCoalescer.flush(); - clearTerminalSubscriptions(session.id); - sendToParent({ - type: "terminalExit", - terminalId: session.id, - info, }); - }); - const unsubscribeTitle = session.onTitleChange((title) => { - outputCoalescer.flush(); - sendToParent({ - type: "terminalTitleChange", - terminalId: session.id, - title, - }); - }); - const unsubscribeCommandFinished = session.onCommandFinished((info) => { - outputCoalescer.flush(); - sendToParent({ - type: "terminalCommandFinished", - terminalId: session.id, - info, - }); - }); - const unsubscribeActivity = session.onActivityChange((transition) => { - sendToParent({ - type: "terminalActivityChange", - terminalId: session.id, - activity: transition.activity, - previous: transition.previous, - }); - }); - - unsubscribeByTerminalId.set(session.id, [ - unsubscribeMessage, - unsubscribeExit, - unsubscribeTitle, - unsubscribeCommandFinished, - unsubscribeActivity, - ]); -} - -function enqueueCreateTerminalRequest(message: TerminalCreateRequest): Promise { - const nextRequest = createTerminalQueue.then(() => handleCreateTerminalRequest(message)); - createTerminalQueue = nextRequest.catch(() => {}); - return nextRequest; -} - -async function handleCreateTerminalRequest(message: TerminalCreateRequest): Promise { - const request: InFlightTerminalCreateRequest = { - requestId: message.requestId, - errorReported: false, - }; - inFlightTerminalCreateRequest = request; - try { - const { workspaceId } = message.options; - if (!workspaceId) { - throw new Error("workspaceId is required"); - } - const session = await manager.createTerminal({ ...message.options, workspaceId }); - if (request.errorReported) { - session.kill(); - return; - } - watchTerminal(session); - const initialSnapshot = session.getStateSnapshot(); - sendToParent({ - type: "terminalCreated", - terminal: toTerminalInfo(session), - state: initialSnapshot.state, - }); - sendToParent({ - type: "response", - requestId: message.requestId, - ok: true, - result: { - terminal: toTerminalInfo(session), - state: initialSnapshot.state, - }, - }); - } catch (error) { - reportInFlightTerminalCreateFailure(error); - } finally { - if (inFlightTerminalCreateRequest === request) { - inFlightTerminalCreateRequest = null; - } - } -} - -async function handleRequest(message: TerminalWorkerRequest): Promise { - switch (message.type) { - case "createTerminal": { - await enqueueCreateTerminalRequest(message); - return; - } - - case "registerCwdEnv": { - manager.registerCwdEnv({ cwd: message.cwd, env: message.env }); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } - - case "setActivity": { - await manager.setTerminalActivity(message.terminalId, message.state); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } - - case "clearAttention": { - await manager.clearTerminalAttention(message.terminalId); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } - - case "killTerminal": { - manager.killTerminal(message.terminalId); - // Removal is owned by session.onExit -> terminalExit; the parent mirror - // clears contribution and emits terminalsChanged from that single path. - clearTerminalSubscriptions(message.terminalId); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } - - case "killTerminalAndWait": { - await manager.killTerminalAndWait(message.terminalId, message.options); - clearTerminalSubscriptions(message.terminalId); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; - } - - case "getTerminalState": { - // Flush buffered output before snapshotting: the headless state already includes it, - // so if the coalescer emitted it afterward (in a batch carrying a revision past the - // snapshot's) the controller's revision dedup wouldn't drop it and the client would - // see the bytes twice. Flushing first sends them with a revision <= the snapshot's. - outputCoalescerByTerminalId.get(message.terminalId)?.flush(); - sendToParent({ - type: "response", - requestId: message.requestId, - ok: true, - result: buildTerminalStateResult(manager.getTerminal(message.terminalId), message.options), + const unsubscribeExit = session.onExit((info) => { + outputCoalescer.flush(); + clearTerminalSubscriptions(session.id); + sessionsById.delete(session.id); + void persistRecord().catch((error) => { + if (!exiting) console.error("Cannot publish terminal exit:", error); }); - return; - } - - case "captureTerminal": { - const session = manager.getTerminal(message.terminalId); - const result = session - ? captureTerminalLines(session, { - start: message.start, - end: message.end, - stripAnsi: message.stripAnsi, - }) - : { lines: [], totalLines: 0 }; - sendToParent({ - type: "response", - requestId: message.requestId, - ok: true, - result, - }); - return; - } - - case "killAll": { - manager.killAll(); - for (const terminalId of Array.from(unsubscribeByTerminalId.keys())) { - clearTerminalSubscriptions(terminalId); + if (exitsHeldDuringCreation.has(session.id)) { + exitsHeldDuringCreation.set(session.id, info); + return; } - sendToParent({ type: "response", requestId: message.requestId, ok: true }); + sendToController({ + type: "terminalExit", + terminalId: session.id, + info, + }); + exitIfIdle(); + }); + const unsubscribeTitle = session.onTitleChange((title) => { + outputCoalescer.flush(); + sendToController({ + type: "terminalTitleChange", + terminalId: session.id, + title, + }); + }); + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + outputCoalescer.flush(); + sendToController({ + type: "terminalCommandFinished", + terminalId: session.id, + info, + }); + }); + const unsubscribeActivity = session.onActivityChange((transition) => { + sendToController({ + type: "terminalActivityChange", + terminalId: session.id, + activity: transition.activity, + previous: transition.previous, + }); + }); + + unsubscribeByTerminalId.set(session.id, [ + unsubscribeMessage, + unsubscribeExit, + unsubscribeTitle, + unsubscribeCommandFinished, + unsubscribeActivity, + ]); + } + + // A terminal enters the record only with its program's birth time, so cleanup can always + // tell the program from a later process that reuses its pid. A program that already exited + // needs no entry; one whose birth time cannot be read is killed and the create fails. + async function recordTerminalProgram(session: LocalTerminalSession): Promise { + if (session.getExitInfo()) return; + // The manager drops a session as soon as its program exits, so only a live one has a pid. + const pid = manager.getTerminalProcessId(session.id); + if (pid === null) { + throw new Error(`Terminal ${session.id} has no program pid`); + } + const inspection = await inspector.inspect(pid); + if (inspection.status === "not-found" || session.getExitInfo()) { return; } + if (inspection.status === "error" || !inspection.snapshot.startedAt) { + const reason = + inspection.status === "error" ? errorMessage(inspection.error) : "no birth time reported"; + throw new Error(`Cannot identify terminal program ${pid}: ${reason}`); + } + if (!record) { + throw new Error("Terminal worker record is not written yet"); + } + record = { + ...record, + terminals: [ + ...record.terminals.filter((entry) => entry.id !== session.id), + { id: session.id, pid, startedAt: inspection.snapshot.startedAt }, + ], + }; + await persistRecord(); + } - case "send": { - const session = manager.getTerminal(message.terminalId); - session?.send(message.message); - sendToParent({ type: "response", requestId: message.requestId, ok: true }); - return; + function enqueueCreateTerminalRequest(message: TerminalCreateRequest): Promise { + const nextRequest = createTerminalQueue.then(() => handleCreateTerminalRequest(message)); + createTerminalQueue = nextRequest.catch(() => {}); + return nextRequest; + } + + async function handleCreateTerminalRequest(message: TerminalCreateRequest): Promise { + const request: InFlightTerminalCreateRequest = { + requestId: message.requestId, + errorReported: false, + }; + inFlightTerminalCreateRequest = request; + try { + const { workspaceId } = message.options; + if (!workspaceId) { + throw new Error("workspaceId is required"); + } + const session = await manager.createTerminal({ ...message.options, workspaceId }); + if (request.errorReported) { + session.kill(); + return; + } + sessionsById.set(session.id, session); + exitsHeldDuringCreation.set(session.id, null); + watchTerminal(session); + try { + await recordTerminalProgram(session); + } catch (error) { + exitsHeldDuringCreation.delete(session.id); + clearTerminalSubscriptions(session.id); + sessionsById.delete(session.id); + await session.killAndWait().catch(() => undefined); + throw error; + } + const state = terminalMetadata(session); + sendToController({ + type: "terminalCreated", + terminal: toTerminalInfo(session), + state, + }); + // Startup output/readiness may have arrived while recording the program, + // before the daemon knew this terminal. Publish its current modes after creation. + sendToController({ + type: "terminalMessage", + terminalId: session.id, + message: { type: "snapshotReady" }, + replayPreamble: session.getReplayPreamble(), + }); + sendToController({ + type: "response", + requestId: message.requestId, + ok: true, + result: { + terminal: toTerminalInfo(session), + state, + }, + }); + const heldExit = exitsHeldDuringCreation.get(session.id); + exitsHeldDuringCreation.delete(session.id); + if (heldExit) { + sendToController({ type: "terminalExit", terminalId: session.id, info: heldExit }); + exitIfIdle(); + } + } catch (error) { + reportInFlightTerminalCreateFailure(error); + } finally { + if (inFlightTerminalCreateRequest === request) { + inFlightTerminalCreateRequest = null; + } } } + + function listTerminals(): TerminalWorkerListResult { + return Array.from(sessionsById.values()).map((session) => { + // Buffered output is already in the headless state; flushing first keeps a later + // batch from carrying bytes the snapshot already shows. + outputCoalescerByTerminalId.get(session.id)?.flush(); + return { + terminal: toTerminalInfo(session), + state: terminalMetadata(session), + activityToken: manager.getTerminalActivityToken(session.id), + replayPreamble: session.getReplayPreamble(), + }; + }); + } + + async function handleRequest(message: TerminalWorkerRequest): Promise { + switch (message.type) { + case "createTerminal": { + await enqueueCreateTerminalRequest(message); + return; + } + + case "registerCwdEnv": { + manager.registerCwdEnv({ cwd: message.cwd, env: message.env }); + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + + case "setActivity": { + await manager.setTerminalActivity(message.terminalId, message.state); + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + + case "clearAttention": { + await manager.clearTerminalAttention(message.terminalId); + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + + case "killTerminal": { + manager.killTerminal(message.terminalId); + // Removal is owned by session.onExit -> terminalExit; the parent mirror + // clears contribution and emits terminalsChanged from that single path. + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + + case "killTerminalAndWait": { + await manager.killTerminalAndWait(message.terminalId, message.options); + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + + case "replayTerminal": { + await replayController.replay(message); + return; + } + + case "captureTerminal": { + const session = manager.getTerminal(message.terminalId); + const result = session + ? session.captureLines({ + start: message.start, + end: message.end, + stripAnsi: message.stripAnsi, + }) + : { lines: [], totalLines: 0 }; + sendToController({ + type: "response", + requestId: message.requestId, + ok: true, + result, + }); + return; + } + + case "listTerminals": { + sendToController({ + type: "response", + requestId: message.requestId, + ok: true, + result: listTerminals(), + }); + return; + } + + case "shutdown": { + const sessions = Array.from(sessionsById.values()); + await Promise.all(sessions.map((session) => session.killAndWait().catch(() => undefined))); + // Exit once the response frame is flushed, so the requester sees the confirmation. + const requester = controller; + if (!requester) { + exitWorker(0); + return; + } + requester.send({ type: "response", requestId: message.requestId, ok: true }, () => + exitWorker(0), + ); + return; + } + + case "send": { + const session = manager.getTerminal(message.terminalId); + session?.send(message.message); + sendToController({ type: "response", requestId: message.requestId, ok: true }); + return; + } + } + } + + function isHello(message: unknown): message is TerminalWorkerHello { + return ( + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "hello" && + typeof (message as { secret?: unknown }).secret === "string" + ); + } + + function secretMatches(candidate: string): boolean { + const expected = Buffer.from(secret, "utf8"); + const actual = Buffer.from(candidate, "utf8"); + return expected.length === actual.length && timingSafeEqual(expected, actual); + } + + function handleConnection(socket: Socket): void { + let authenticated = false; + socket.on("error", () => { + // `close` follows; a broken controller socket only means no controller. + }); + const helloTimer = setTimeout(() => socket.destroy(), HELLO_TIMEOUT_MS); + const framed = attachFramedSocket(socket, (message) => { + if (!authenticated) { + clearTimeout(helloTimer); + if (!isHello(message) || !secretMatches(message.secret)) { + socket.destroy(); + return; + } + authenticated = true; + // One controller at a time: a new authenticated connection replaces the old one. + controller = framed; + replayController.setConnection(framed); + everControlled = true; + const ack: TerminalWorkerHelloAck = { + type: "helloAck", + pid: process.pid, + protocolVersion, + }; + framed.send(ack); + return; + } + if (framed !== controller) { + return; + } + const request = message as TerminalWorkerRequest; + void handleRequest(request).catch((error: unknown) => { + sendToController({ + type: "response", + requestId: request.requestId, + ok: false, + error: terminalWorkerErrorMessage(error), + }); + }); + }); + socket.once("close", () => { + clearTimeout(helloTimer); + if (controller === framed) { + controller = null; + replayController.setConnection(null); + exitIfIdle(); + } + }); + } + + function reportReady(line: string): void { + try { + writeSync(READY_FD, `${line}\n`); + closeSync(READY_FD); + } catch { + // Started without a launcher pipe (tests that spawn the worker directly). + } + } + + async function start(): Promise { + const socketPath = resolveTerminalWorkerSocketPath({ + paseoHome: args.paseoHome, + workerPid: process.pid, + }); + if (process.platform !== "win32") { + rmSync(socketPath, { force: true }); + } + const listening = createServer(handleConnection); + await new Promise((resolve, reject) => { + listening.once("error", reject); + listening.listen(socketPath, () => { + listening.off("error", reject); + resolve(); + }); + }); + server = listening; + if (process.platform !== "win32") { + chmodSync(socketPath, PRIVATE_FILE_MODE); + } + + const self = await inspector.inspect(process.pid); + if ( + self.status !== "alive" || + !self.snapshot.startedAt || + !self.snapshot.commandLine?.includes(args.recordId) + ) { + const reason = self.status === "error" ? errorMessage(self.error) : "no usable snapshot"; + throw new Error(`Terminal worker cannot read its own process identity: ${reason}`); + } + record = { + recordId: args.recordId, + pid: process.pid, + bootId: currentBootId(), + createdAt: new Date().toISOString(), + identity: { startedAt: self.snapshot.startedAt, commandLine: self.snapshot.commandLine }, + protocolVersion, + paseoVersion: args.paseoVersion, + socketPath, + secret, + runtime: args.runtime, + terminals: [], + }; + writeTerminalWorkerRecord(args.paseoHome, record); + reportReady(`ready ${socketPath}`); + + setTimeout(() => { + if (!everControlled && sessionsById.size === 0) exitWorker(0); + }, FIRST_CONTROLLER_TIMEOUT_MS).unref(); + setInterval(() => { + if (!controller && !existsSync(terminalWorkerRecordPath(args.paseoHome, process.pid))) { + void killEveryTerminalAndExit(); + } + }, ORPHAN_CHECK_INTERVAL_MS); + } + + process.on("SIGTERM", () => { + void killEveryTerminalAndExit(); + }); + + start().catch((error: unknown) => { + const message = errorMessage(error).replace(/\n/g, " | "); + console.error(`Terminal worker failed to start: ${errorMessage(error)}`); + reportReady(`error ${message}`); + exitWorker(1); + }); } -process.on("message", (message: TerminalWorkerRequest) => { - void handleRequest(message).catch((error: unknown) => { - sendToParent({ - type: "response", - requestId: message.requestId, - ok: false, - error: terminalWorkerErrorMessage(error), - }); - }); -}); - -process.once("disconnect", () => { - ipcClosing = true; - manager.killAll(); -}); +if (process.argv.includes(LAUNCH_ARG)) { + runLauncher(process.argv.slice(2)); +} else { + runWorker(process.argv.slice(2)); +} diff --git a/packages/server/src/terminal/terminal-worker-processes.ts b/packages/server/src/terminal/terminal-worker-processes.ts new file mode 100644 index 0000000000..b0c2e6d0cb --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-processes.ts @@ -0,0 +1,185 @@ +import { readFile } from "node:fs/promises"; +import { z } from "zod"; +import { execCommand } from "../utils/spawn.js"; +import { + createSystemManagedProcessTable, + type ManagedProcessCommandRunner, + type ManagedProcessInspection, + windowsPowerShellPath, +} from "../server/managed-processes/managed-processes.js"; + +// Process inspection for terminal worker ownership. Birth times are compared as the strings +// `ps -o lstart` and Win32_Process.CreationDate print, so every reader must print them the same +// way: `ps` follows LC_TIME, and the worker, the daemon, the supervisor and the CLI can run +// under different locales. Every `ps` here runs under LC_ALL=C. + +const PS_TIMEOUT_MS = 15_000; +const POSIX_LSTART_WIDTH = 24; + +export interface ProcessTableEntry { + pid: number; + ppid: number; + startedAt: string; + // POSIX session id where `ps` reports one (Linux). macOS `ps` has no session column. + sessionId: number | null; +} + +export interface TerminalWorkerProcessInspector { + /** True where the process table names each process's session (Linux). */ + readonly reportsSessions: boolean; + inspect(pid: number): Promise; + /** One snapshot of every process, for walking descendants. Throws when it cannot be read. */ + listProcesses(): Promise; + /** + * The environment a process was started with, as NAME=value entries; null when the process + * is gone. Throws when it cannot be read. Only called where `reportsSessions` is true. + */ + readEnvironment(pid: number): Promise; +} + +const WindowsProcessListSchema = z.array( + z.object({ + ProcessId: z.number().int(), + ParentProcessId: z.number().int().nullable().optional(), + CreationDate: z.string().nullable().optional(), + }), +); + +function createLocaleNeutralCommandRunner(): ManagedProcessCommandRunner { + return { + exec: (command, args) => + execCommand(command, args, { + envMode: "internal", + envOverlay: { LC_ALL: "C" }, + timeout: PS_TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024, + }).then(({ stdout, stderr }) => ({ stdout: String(stdout), stderr: String(stderr) })), + }; +} + +export function createTerminalWorkerProcessInspector( + options: { platform?: NodeJS.Platform; commandRunner?: ManagedProcessCommandRunner } = {}, +): TerminalWorkerProcessInspector { + const platform = options.platform ?? process.platform; + const commandRunner = options.commandRunner ?? createLocaleNeutralCommandRunner(); + const table = createSystemManagedProcessTable({ platform, commandRunner }); + return { + reportsSessions: platform === "linux", + inspect: (pid) => table.inspect(pid), + listProcesses: () => + platform === "win32" + ? listWindowsProcesses(commandRunner) + : listPosixProcesses(commandRunner, platform), + readEnvironment: (pid) => readLinuxEnvironment(platform, pid), + }; +} + +async function readLinuxEnvironment( + platform: NodeJS.Platform, + pid: number, +): Promise { + if (platform !== "linux") { + throw new Error(`reading a process environment is not supported on ${platform}`); + } + let raw: string; + try { + raw = await readFile(`/proc/${pid}/environ`, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + return raw.split("\0").filter((entry) => entry.length > 0); +} + +async function listPosixProcesses( + commandRunner: ManagedProcessCommandRunner, + platform: NodeJS.Platform, +): Promise { + const withSession = platform === "linux"; + const columns = withSession + ? ["-o", "pid=", "-o", "ppid=", "-o", "sid=", "-o", "lstart="] + : ["-o", "pid=", "-o", "ppid=", "-o", "lstart="]; + const { stdout } = await commandRunner.exec("ps", ["-A", "-ww", ...columns]); + const entries: ProcessTableEntry[] = []; + const leadingInts = withSession ? 3 : 2; + for (const line of stdout.split("\n")) { + const match = line.match( + withSession ? /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s*$/ : /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/, + ); + if (!match) continue; + const startedAt = match[leadingInts + 1].slice(0, POSIX_LSTART_WIDTH).trim(); + entries.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + sessionId: withSession ? Number(match[3]) : null, + startedAt, + }); + } + if (entries.length === 0) { + throw new Error("ps returned no processes"); + } + return entries; +} + +async function listWindowsProcesses( + commandRunner: ManagedProcessCommandRunner, +): Promise { + const command = + "@(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate) | ConvertTo-Json -Compress"; + const { stdout } = await commandRunner.exec(windowsPowerShellPath(), [ + "-NoProfile", + "-NonInteractive", + "-Command", + command, + ]); + const parsed: unknown = JSON.parse(stdout.trim()); + const rows = WindowsProcessListSchema.parse(Array.isArray(parsed) ? parsed : [parsed]); + return rows.flatMap((row) => + row.CreationDate + ? [ + { + pid: row.ProcessId, + ppid: row.ParentProcessId ?? 0, + startedAt: row.CreationDate, + sessionId: null, + }, + ] + : [], + ); +} + +/** + * Every process below `rootPid` in the snapshot, plus (where the platform reports sessions) + * every member of the session `rootPid` led and everything below those members. A PTY program + * is a session leader, and the kernel keeps its pid reserved as the session id while any member + * lives, so the session still names a HUP-ignoring job after the shell exits and the job is + * reparented. + */ +export function collectDescendants( + snapshot: ProcessTableEntry[], + rootPid: number, +): ProcessTableEntry[] { + const childrenByParent = new Map(); + for (const entry of snapshot) { + const siblings = childrenByParent.get(entry.ppid) ?? []; + siblings.push(entry); + childrenByParent.set(entry.ppid, siblings); + } + const found = new Map(); + const queue = [rootPid]; + for (const entry of snapshot) { + if (entry.sessionId === rootPid && entry.pid !== rootPid) { + found.set(entry.pid, entry); + queue.push(entry.pid); + } + } + while (queue.length > 0) { + const parent = queue.shift()!; + for (const child of childrenByParent.get(parent) ?? []) { + if (child.pid === rootPid || found.has(child.pid)) continue; + found.set(child.pid, child); + queue.push(child.pid); + } + } + return Array.from(found.values()); +} diff --git a/packages/server/src/terminal/terminal-worker-protocol.test.ts b/packages/server/src/terminal/terminal-worker-protocol.test.ts new file mode 100644 index 0000000000..6ea9a823db --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-protocol.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { classifyTerminalWorkerProtocol } from "./terminal-worker-protocol.js"; + +describe("classifyTerminalWorkerProtocol", () => { + it("rejects the unreleased cell protocol without a draining adapter", () => { + expect(classifyTerminalWorkerProtocol(1, 2)).toBe("obsolete"); + expect(classifyTerminalWorkerProtocol(2, 3)).toBe("obsolete"); + expect(classifyTerminalWorkerProtocol(3, 4)).toBe("obsolete"); + expect(classifyTerminalWorkerProtocol(4, 5)).toBe("obsolete"); + }); + it.each([ + [6, "current"], + [5, "previous"], + [4, "obsolete"], + [-1, "obsolete"], + [7, "newer"], + // A version this daemon cannot read comes from a Paseo it does not know. + ["6", "newer"], + [4.5, "newer"], + [Number.NaN, "newer"], + [undefined, "newer"], + ])("a worker reporting %s to a daemon at 6 is %s", (reported, fit) => { + expect(classifyTerminalWorkerProtocol(reported, 6)).toBe(fit); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index 5e7d1a30c0..e781543697 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -1,11 +1,11 @@ -import type { - TerminalExitInfo, - ServerMessage, - ClientMessage, - TerminalStateSnapshot, - TerminalStateSnapshotOptions, -} from "./terminal.js"; -import type { TerminalState } from "@getpaseo/protocol/messages"; +import type { TerminalExitInfo, ServerMessage, ClientMessage } from "./terminal.js"; +import type { TerminalReplayEvent, TerminalReplayOptions } from "./terminal-replay.js"; +export interface WorkerTerminalState { + rows: number; + cols: number; + hasOutput: boolean; + title?: string; +} import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; @@ -31,6 +31,56 @@ export interface WorkerCreateTerminalOptions { cols?: number; activityToken?: string; activityUrl?: string | null; + // The worker outlives the daemon that started it, so the controlling daemon + // sends its own environment and Paseo CLI path with every create instead of + // letting the worker reuse whatever it inherited at launch. + baseEnv?: Record; + paseoCliPath?: string | null; +} + +// Bumped when a request, response or event changes shape. The frozen envelope never changes +// across versions: the frame format, `hello`/`helloAck`, the `listTerminals` info fields, and +// `shutdown`. A daemon drives a worker of its own version, drains one a version older (no new +// terminals; it exits after its last terminal), shuts down anything older, and leaves a newer +// worker running untouched. See docs/terminal-worker.md. +export const TERMINAL_WORKER_PROTOCOL_VERSION = 5; + +// Test-only: the protocol version a worker reports, so tests can stand up older and newer +// workers from one build. +export const TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV = "PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE"; + +/** How a daemon at `current` treats a worker reporting `reported`. */ +export type TerminalWorkerProtocolFit = "current" | "previous" | "obsolete" | "newer"; + +export function classifyTerminalWorkerProtocol( + reported: unknown, + current: number = TERMINAL_WORKER_PROTOCOL_VERSION, +): TerminalWorkerProtocolFit { + // A version this daemon cannot read comes from a Paseo it does not know: treat it as newer. + if (typeof reported !== "number" || !Number.isSafeInteger(reported)) return "newer"; + if (reported === current) return "current"; + // Protocols 1 through 4 were unreleased; they have no draining adapter. + if (reported === current - 1 && reported >= 5) return "previous"; + return reported < current ? "obsolete" : "newer"; +} + +export interface TerminalWorkerHello { + type: "hello"; + secret: string; + protocolVersion: number; +} + +export interface TerminalWorkerHelloAck { + type: "helloAck"; + pid: number; + protocolVersion: number; +} + +export interface WorkerListedTerminal { + terminal: WorkerTerminalInfo; + state: WorkerTerminalState; + activityToken: string | null; + replayPreamble: string; } export interface WorkerKillAndWaitOptions { @@ -73,10 +123,10 @@ export type TerminalWorkerRequest = options?: WorkerKillAndWaitOptions; } | { - type: "getTerminalState"; + type: "replayTerminal"; requestId: string; terminalId: string; - options?: TerminalStateSnapshotOptions; + options?: TerminalReplayOptions; } | { type: "captureTerminal"; @@ -87,7 +137,12 @@ export type TerminalWorkerRequest = stripAnsi?: boolean; } | { - type: "killAll"; + type: "listTerminals"; + requestId: string; + } + | { + // Kill every terminal (waiting for each program to exit) and exit. + type: "shutdown"; requestId: string; } | { @@ -112,10 +167,11 @@ export type TerminalWorkerResponse = }; export type TerminalWorkerEvent = + | { type: "terminalReplay"; requestId: string; event: TerminalReplayEvent } | { type: "terminalCreated"; terminal: WorkerTerminalInfo; - state: TerminalState; + state: WorkerTerminalState; } | { type: "terminalMessage"; @@ -149,7 +205,6 @@ export type TerminalWorkerEvent = export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWorkerEvent; +export type TerminalWorkerListResult = WorkerListedTerminal[]; + export type TerminalWorkerCaptureResult = CaptureTerminalLinesResult; -// The worker fills TerminalStateSnapshot.replayPreamble on getTerminalState so -// the parent can cache the input-mode preamble instead of re-deriving it. -export type TerminalWorkerStateResult = TerminalStateSnapshot | null; diff --git a/packages/server/src/terminal/terminal-worker-reaper.test.ts b/packages/server/src/terminal/terminal-worker-reaper.test.ts new file mode 100644 index 0000000000..a20d571b7f --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-reaper.test.ts @@ -0,0 +1,645 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Worker } from "node:worker_threads"; +import { isPlatform } from "../test-utils/platform.js"; +import { classifyTerminalProgramProcess } from "./terminal-worker-ownership.js"; +import { currentBootId } from "../server/pid-lock.js"; +import { + createTerminalWorkerProcessInspector, + type TerminalWorkerProcessInspector, +} from "./terminal-worker-processes.js"; +import { + readUnconfirmedTerminalCleanup, + reapTerminalWorkers, + retryUnconfirmedTerminalCleanup, + TerminalCleanupNotConfirmedError, +} from "./terminal-worker-reaper.js"; +import { + readTerminalWorkerRecord, + terminalCleanupRecordPath, + terminalWorkerDirectory, + writeTerminalWorkerRecord, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; + +// Real throwaway processes stand in for workers and terminal programs, so every signal the +// reaper sends lands on a process this test started. Nothing here uses invented pids. + +const inspector = createTerminalWorkerProcessInspector(); +const children: ChildProcess[] = []; +const extraPids: number[] = []; +const temporaryDirs: string[] = []; + +function spawnIdle( + script = "setInterval(() => {}, 1e9)", + env: NodeJS.ProcessEnv = process.env, +): ChildProcess { + const child = spawn(process.execPath, ["-e", script], { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + env, + }); + children.push(child); + return child; +} + +// A program with one child of its own in a separate process group, so only the descendant walk +// (not the program's group kill) can reach it. +async function spawnProgramWithChild(): Promise<{ program: ChildProcess; childPid: number }> { + const program = spawnIdle(` + const child = require("node:child_process").spawn(process.execPath, ["-e", "setInterval(() => {}, 1e9)"], { stdio: "ignore", detached: true }); + process.stdout.write(String(child.pid) + "\\n"); + setInterval(() => {}, 1e9); + `); + const childPid = await new Promise((resolve) => { + program.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim()))); + }); + extraPids.push(childPid); + return { program, childPid }; +} + +async function birthOf(pid: number): Promise { + const inspection = await inspector.inspect(pid); + if (inspection.status !== "alive" || !inspection.snapshot.startedAt) { + throw new Error(`cannot read birth time of ${pid}`); + } + return inspection.snapshot.startedAt; +} + +async function goneProcessPid(): Promise { + const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" }); + await new Promise((resolve) => child.once("exit", resolve)); + return child.pid!; +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitUntilGone(pid: number): Promise { + const deadline = Date.now() + 5_000; + while (isAlive(pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +// A member of the terminal "t1" carries its id, as every program a terminal starts does. +function terminalEnv(terminalId: string): NodeJS.ProcessEnv { + return { ...process.env, PASEO_TERMINAL_ID: terminalId }; +} + +// A process's start environment: /proc where the real inspector reads it (Linux), `ps -E` on +// macOS, whose real inspector has no session walk and so never reads one. +function readEnvironment(pid: number): Promise { + if (process.platform === "linux") return inspector.readEnvironment(pid); + return new Promise((resolve) => { + execFile("ps", ["-E", "-ww", "-o", "command=", "-p", String(pid)], (error, stdout) => { + resolve(error ? null : stdout.trim().split(/\s+/)); + }); + }); +} + +// The real process table as a Linux `ps` would print it for one session: `memberPid` belongs to +// the session `sessionId` led, and every other process to none this test knows. macOS `ps` has +// no session column, so this is the only way to run the Linux branch here; the member is a real +// process the test started. `reusedRoot` adds a process that took the session id's pid. +function withSessionMember(input: { + memberPid: number; + sessionId: number; + reusedRoot?: boolean; + environmentUnreadable?: boolean; +}): TerminalWorkerProcessInspector { + return { + reportsSessions: true, + inspect: (pid) => inspector.inspect(pid), + listProcesses: async () => { + // A fresh snapshot each call, so it is rewritten in place. + const snapshot = await inspector.listProcesses(); + for (const entry of snapshot) { + entry.sessionId = entry.pid === input.memberPid ? input.sessionId : null; + } + if (input.reusedRoot) { + snapshot.push({ + pid: input.sessionId, + ppid: 1, + startedAt: "Fri Jan 1 00:00:00 2100", + sessionId: input.sessionId, + }); + } + return snapshot; + }, + readEnvironment: (pid) => + input.environmentUnreadable + ? Promise.reject(new Error("permission denied")) + : readEnvironment(pid), + }; +} + +// A record this process cannot open until another thread restores it, the way a worker's +// rename over its record ends a Windows sharing violation. The thread waits for `release()`, then +// restores the record while this thread is still inside the (synchronous) record read. +async function unreadableUntilReleased( + recordPath: string, +): Promise<{ release: () => void; restored: Promise }> { + chmodSync(recordPath, 0); + const signal = new Int32Array(new SharedArrayBuffer(8)); + const thread = new Worker( + ` + const { parentPort, workerData } = require("node:worker_threads"); + const signal = new Int32Array(workerData.signal); + parentPort.postMessage("ready"); + Atomics.wait(signal, 0, 0); + Atomics.wait(signal, 1, 0, 30); + require("node:fs").chmodSync(workerData.recordPath, 0o600); + `, + { eval: true, workerData: { recordPath, signal: signal.buffer } }, + ); + await new Promise((resolve) => thread.once("message", resolve)); + const restored = new Promise((resolve, reject) => { + thread.once("exit", () => resolve()); + thread.once("error", reject); + }); + return { + release: () => { + Atomics.store(signal, 0, 1); + Atomics.notify(signal, 0); + }, + restored, + }; +} + +function makeHome(): string { + const home = mkdtempSync(path.join(tmpdir(), "tw-reaper-")); + temporaryDirs.push(home); + return home; +} + +function writeRecord( + home: string, + input: Partial & Pick, +): string { + const record: TerminalWorkerRecord = { + recordId: "record-under-test", + bootId: currentBootId(), + createdAt: new Date().toISOString(), + identity: { + startedAt: "Thu Jan 1 00:00:00 1970", + commandLine: "node terminal-worker --paseo-terminal-worker record-under-test", + }, + protocolVersion: 1, + paseoVersion: "0.0.0-test", + socketPath: path.join(home, "missing.sock"), + secret: "not-a-real-secret", + terminals: [], + ...input, + }; + return writeTerminalWorkerRecord(home, record); +} + +afterEach(async () => { + for (const child of children.splice(0)) { + if (child.pid && isAlive(child.pid)) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + } + } + for (const pid of extraPids.splice(0)) { + if (isAlive(pid)) process.kill(pid, "SIGKILL"); + } + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe.skipIf(isPlatform("win32"))("terminal worker reaper", () => { + it("ends the programs a dead worker left, and their children, then retires the record", async () => { + const home = makeHome(); + const { program, childPid } = await spawnProgramWithChild(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: program.pid!, startedAt: await birthOf(program.pid!) }], + }); + + const result = await reapTerminalWorkers({ paseoHome: home, inspector }); + + await waitUntilGone(program.pid!); + await waitUntilGone(childPid); + expect(isAlive(program.pid!)).toBe(false); + expect(isAlive(childPid)).toBe(false); + expect(result.signaled).toEqual(expect.arrayContaining([program.pid!, childPid])); + expect(existsSync(recordPath)).toBe(false); + }); + + it("never signals a live process that reuses the worker pid, but still ends its programs", async () => { + const home = makeHome(); + const reusedWorkerPid = spawnIdle().pid!; + const program = spawnIdle(); + writeRecord(home, { + pid: reusedWorkerPid, + terminals: [{ id: "t1", pid: program.pid!, startedAt: await birthOf(program.pid!) }], + }); + + const result = await reapTerminalWorkers({ paseoHome: home, inspector }); + + await waitUntilGone(program.pid!); + expect(isAlive(program.pid!)).toBe(false); + expect(isAlive(reusedWorkerPid)).toBe(true); + expect(result.signaled).not.toContain(reusedWorkerPid); + }); + + it("leaves a program alive when its pid now belongs to a process with another birth time", async () => { + const home = makeHome(); + const unrelated = spawnIdle(); + const original = { id: "t1", pid: unrelated.pid!, startedAt: "Thu Jan 1 00:00:00 1970" }; + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [original], + }); + + await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); + + expect(isAlive(unrelated.pid!)).toBe(true); + expect(existsSync(recordPath)).toBe(false); + expect( + classifyTerminalProgramProcess(original, await inspector.inspect(original.pid)), + ).toMatchObject({ status: "unrelated" }); + }); + + it("retires a record from an earlier boot without signaling anything it names", async () => { + const home = makeHome(); + const program = spawnIdle(); + const recordPath = writeRecord(home, { + pid: spawnIdle().pid!, + bootId: null, + createdAt: "2000-01-01T00:00:00.000Z", + terminals: [{ id: "t1", pid: program.pid!, startedAt: await birthOf(program.pid!) }], + }); + + await reapTerminalWorkers({ paseoHome: home, inspector }); + + expect(isAlive(program.pid!)).toBe(true); + expect(existsSync(recordPath)).toBe(false); + }); + + it("keeps a record of what it could not inspect and reports cleanup unconfirmed", async () => { + const home = makeHome(); + const uninspectable = spawnIdle(); + const killable = spawnIdle(); + const failing: TerminalWorkerProcessInspector = { + reportsSessions: inspector.reportsSessions, + inspect: (pid) => + pid === uninspectable.pid + ? Promise.resolve({ status: "error", error: new Error("ps unavailable") }) + : inspector.inspect(pid), + listProcesses: () => inspector.listProcesses(), + readEnvironment: (pid) => inspector.readEnvironment(pid), + }; + const workerPid = await goneProcessPid(); + const recordPath = writeRecord(home, { + pid: workerPid, + terminals: [ + { id: "t1", pid: uninspectable.pid!, startedAt: await birthOf(uninspectable.pid!) }, + { id: "t2", pid: killable.pid!, startedAt: await birthOf(killable.pid!) }, + ], + }); + + let caught: unknown; + try { + await reapTerminalWorkers({ paseoHome: home, inspector: failing, confirmMs: 500 }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect((caught as TerminalCleanupNotConfirmedError).unresolved).toEqual([ + expect.objectContaining({ pid: uninspectable.pid, reason: "inspection failed" }), + ]); + await waitUntilGone(killable.pid!); + expect(isAlive(killable.pid!)).toBe(false); + expect(isAlive(uninspectable.pid!)).toBe(true); + // The closing state goes to the cleanup record; the worker's own record is left as it was. + const kept = await readTerminalWorkerRecord( + terminalCleanupRecordPath(home, "record-under-test"), + ); + expect(kept.pid).toBe(workerPid); + expect(kept.closing?.unresolved.length).toBeGreaterThan(0); + expect(kept.terminals.map((entry) => entry.pid)).toEqual([uninspectable.pid]); + expect((await readTerminalWorkerRecord(recordPath)).closing).toBeUndefined(); + }); + + it("ends a session member its gone terminal program left behind, where sessions are reported", async () => { + const home = makeHome(); + const member = spawnIdle(undefined, terminalEnv("t1")); + const goneProgramPid = await goneProcessPid(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: goneProgramPid, startedAt: await birthOf(process.pid) }], + }); + + const result = await reapTerminalWorkers({ + paseoHome: home, + inspector: withSessionMember({ memberPid: member.pid!, sessionId: goneProgramPid }), + }); + + await waitUntilGone(member.pid!); + expect(isAlive(member.pid!)).toBe(false); + expect(result.signaled).toContain(member.pid); + expect(existsSync(recordPath)).toBe(false); + }); + + it("leaves alive a later session that reuses a gone terminal program's session id", async () => { + const home = makeHome(); + // Born after the recorded program, as any member of a later session is; only the missing + // terminal id tells it apart. + const member = spawnIdle(undefined, terminalEnv("another-terminal")); + const goneProgramPid = await goneProcessPid(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: goneProgramPid, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + + const result = await reapTerminalWorkers({ + paseoHome: home, + inspector: withSessionMember({ memberPid: member.pid!, sessionId: goneProgramPid }), + }); + + expect(isAlive(member.pid!)).toBe(true); + expect(result.signaled).toEqual([]); + expect(existsSync(recordPath)).toBe(false); + }); + + it("signals no session member it cannot identify, and keeps the cleanup unconfirmed", async () => { + const home = makeHome(); + const member = spawnIdle(undefined, terminalEnv("t1")); + const goneProgramPid = await goneProcessPid(); + writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: goneProgramPid, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + + await expect( + reapTerminalWorkers({ + paseoHome: home, + inspector: withSessionMember({ + memberPid: member.pid!, + sessionId: goneProgramPid, + environmentUnreadable: true, + }), + }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + + expect(isAlive(member.pid!)).toBe(true); + expect(await readUnconfirmedTerminalCleanup(home)).toEqual([ + expect.objectContaining({ pid: goneProgramPid, reason: "inspection failed" }), + ]); + }); + + it("signals no session member whose environment carries no terminal id, and keeps the cleanup unconfirmed", async () => { + const home = makeHome(); + // A job started with a scrubbed environment (`env -i`) carries no terminal id at all: it + // may still be this terminal's, so it is neither signaled nor dismissed as unrelated. + const member = spawnIdle(undefined, {}); + const goneProgramPid = await goneProcessPid(); + writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: goneProgramPid, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + const scrubbedMember = withSessionMember({ memberPid: member.pid!, sessionId: goneProgramPid }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect( + reapTerminalWorkers({ paseoHome: home, inspector: scrubbedMember }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect(isAlive(member.pid!)).toBe(true); + expect(await readUnconfirmedTerminalCleanup(home)).toEqual([ + expect.objectContaining({ + pid: goneProgramPid, + reason: "inspection failed", + detail: expect.stringContaining(`session member ${member.pid}`), + }), + ]); + } + expect(existsSync(terminalCleanupRecordPath(home, "record-under-test"))).toBe(true); + }); + + it("does not walk a gone terminal program's pid that another process took before the table was read", async () => { + const home = makeHome(); + const member = spawnIdle(undefined, terminalEnv("t1")); + const goneProgramPid = await goneProcessPid(); + writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: goneProgramPid, startedAt: "Thu Jan 1 00:00:00 1970" }], + }); + + const result = await reapTerminalWorkers({ + paseoHome: home, + inspector: withSessionMember({ + memberPid: member.pid!, + sessionId: goneProgramPid, + reusedRoot: true, + }), + }); + + expect(isAlive(member.pid!)).toBe(true); + expect(result.signaled).toEqual([]); + }); + + // A kill cleanup that meets an unreadable worker record cannot check what it names and + // leaves nothing else behind, so status and retry must keep reporting it after a respawn. + it.each([ + ["cleanup", (home: string) => terminalCleanupRecordPath(home, "record-under-test")], + ["worker", (home: string) => path.join(terminalWorkerDirectory(home), "4242.json")], + ])("never reports an unreadable %s record as finished", async (_kind, recordPathIn) => { + const home = makeHome(); + mkdirSync(terminalWorkerDirectory(home), { recursive: true }); + const unreadablePath = recordPathIn(home); + writeFileSync(unreadablePath, "{ not json"); + + for (const attempt of [ + async () => await readUnconfirmedTerminalCleanup(home), + () => retryUnconfirmedTerminalCleanup(home), + () => reapTerminalWorkers({ paseoHome: home, inspector }), + ]) { + let caught: unknown; + try { + await attempt(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect((caught as TerminalCleanupNotConfirmedError).unreadableRecords).toEqual([ + expect.objectContaining({ path: unreadablePath }), + ]); + } + expect(existsSync(unreadablePath)).toBe(true); + }); + + it("signals nothing while the process table cannot be read, so a retry still ends the children", async () => { + const home = makeHome(); + const { program, childPid } = await spawnProgramWithChild(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: program.pid!, startedAt: await birthOf(program.pid!) }], + }); + const tableUnavailable: TerminalWorkerProcessInspector = { + reportsSessions: inspector.reportsSessions, + inspect: (pid) => inspector.inspect(pid), + listProcesses: () => Promise.reject(new Error("ps timed out")), + readEnvironment: (pid) => inspector.readEnvironment(pid), + }; + + await expect( + reapTerminalWorkers({ paseoHome: home, inspector: tableUnavailable }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + + expect(isAlive(program.pid!)).toBe(true); + expect(isAlive(childPid)).toBe(true); + const kept = await readTerminalWorkerRecord( + terminalCleanupRecordPath(home, "record-under-test"), + ); + expect(kept.closing?.unresolved.length).toBeGreaterThan(0); + expect(kept.terminals.map((entry) => entry.pid)).toEqual([program.pid]); + + const retry = await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); + + await waitUntilGone(program.pid!); + await waitUntilGone(childPid); + expect(isAlive(program.pid!)).toBe(false); + expect(isAlive(childPid)).toBe(false); + expect(retry.signaled).toEqual(expect.arrayContaining([program.pid!, childPid])); + expect(existsSync(recordPath)).toBe(false); + expect(existsSync(terminalCleanupRecordPath(home, "record-under-test"))).toBe(false); + }); + + it("escalates to SIGKILL a program that ignores SIGHUP and SIGTERM", async () => { + const home = makeHome(); + const stubborn = spawnIdle(` + process.on("SIGHUP", () => {}); + process.on("SIGTERM", () => {}); + setInterval(() => {}, 1e9); + `); + writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: stubborn.pid!, startedAt: await birthOf(stubborn.pid!) }], + }); + + await reapTerminalWorkers({ paseoHome: home, inspector }); + + await waitUntilGone(stubborn.pid!); + expect(isAlive(stubborn.pid!)).toBe(false); + }); + + it.skipIf(process.getuid?.() === 0)( + "keeps the daemon event loop responsive while status retries an unreadable record", + async () => { + const home = makeHome(); + const recordPath = writeRecord(home, { pid: process.pid }); + chmodSync(recordPath, 0); + let serviced = false; + setImmediate(() => { + serviced = true; + }); + try { + await expect( + Promise.resolve().then(() => readUnconfirmedTerminalCleanup(home)), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect(serviced).toBe(true); + } finally { + chmodSync(recordPath, 0o600); + } + }, + ); + + // chmod cannot make a file unreadable to root. + describe.skipIf(process.getuid?.() === 0)("a record that cannot be opened for a moment", () => { + it("still ends a live worker it names, then retires the record", async () => { + const home = makeHome(); + const worker = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1e9)", "--", "--paseo-terminal-worker", "record-under-test"], + { detached: true, stdio: "ignore" }, + ); + children.push(worker); + const recordPath = writeRecord(home, { + pid: worker.pid!, + identity: { + startedAt: await birthOf(worker.pid!), + commandLine: "node -e --paseo-terminal-worker record-under-test", + }, + }); + const record = await unreadableUntilReleased(recordPath); + + record.release(); + const result = await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); + await record.restored; + + await waitUntilGone(worker.pid!); + expect(isAlive(worker.pid!)).toBe(false); + expect(result.signaled).toContain(worker.pid!); + expect(existsSync(recordPath)).toBe(false); + }); + + it("confirms the cleanup of a worker that already exited, then retires the record", async () => { + const home = makeHome(); + const program = spawnIdle(); + const recordPath = writeRecord(home, { + pid: await goneProcessPid(), + terminals: [{ id: "t1", pid: program.pid!, startedAt: await birthOf(program.pid!) }], + }); + const record = await unreadableUntilReleased(recordPath); + + record.release(); + const result = await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); + await record.restored; + + await waitUntilGone(program.pid!); + expect(isAlive(program.pid!)).toBe(false); + expect(result.retired).toBe(1); + expect(existsSync(recordPath)).toBe(false); + }); + + it("still reports a record that stays unreadable, and signals nothing it names", async () => { + const home = makeHome(); + const worker = spawn( + process.execPath, + ["-e", "setInterval(() => {}, 1e9)", "--", "--paseo-terminal-worker", "record-under-test"], + { detached: true, stdio: "ignore" }, + ); + children.push(worker); + const recordPath = writeRecord(home, { + pid: worker.pid!, + identity: { + startedAt: await birthOf(worker.pid!), + commandLine: "node -e --paseo-terminal-worker record-under-test", + }, + }); + chmodSync(recordPath, 0); + + let caught: unknown; + try { + await reapTerminalWorkers({ paseoHome: home, inspector, gracefulMs: 0 }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect((caught as TerminalCleanupNotConfirmedError).unreadableRecords).toEqual([ + expect.objectContaining({ path: recordPath, error: expect.stringContaining("EACCES") }), + ]); + expect(isAlive(worker.pid!)).toBe(true); + chmodSync(recordPath, 0o600); + expect(existsSync(recordPath)).toBe(true); + }); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-reaper.ts b/packages/server/src/terminal/terminal-worker-reaper.ts new file mode 100644 index 0000000000..2647d4b25e --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-reaper.ts @@ -0,0 +1,642 @@ +import { randomUUID } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import treeKill from "tree-kill"; +import { writtenDuringThisBoot } from "../server/pid-lock.js"; +import { connectTerminalWorker } from "./terminal-worker-client.js"; +import { pruneTerminalWorkerRuntimes } from "./terminal-worker-runtime.js"; +import { + classifyTerminalProgramProcess, + classifyTerminalWorkerProcess, + type ProcessOwnership, +} from "./terminal-worker-ownership.js"; +import { + collectDescendants, + createTerminalWorkerProcessInspector, + type ProcessTableEntry, + type TerminalWorkerProcessInspector, +} from "./terminal-worker-processes.js"; +import { + listTerminalWorkerRecords, + retireTerminalWorkerRecord, + terminalCleanupRecordPath, + writeTerminalCleanupRecord, + type TerminalCleanupUnresolved, + type TerminalWorkerRecord, + type TerminalWorkerRecordFile, + type TerminalWorkerTerminalEntry, +} from "./terminal-worker-record.js"; + +// The one kill path for terminal workers outside a connected daemon, and the one the daemon +// uses for its own kill stop. Every kill intent (stop, quit, --force, a kill restart whose +// shutdown hung, a dead or stopped worker, a program that ignores HUP) ends here: +// +// 1. Classify the worker and every terminal program of each record on its own, by birth time. +// A record from an earlier boot names only processes the reboot ended: retire it unsignaled. +// Descendants of owned programs (and, on Linux, members of their sessions) join the +// candidates from one process-table snapshot taken before anything is signaled. Once a +// program is gone, its session id can belong to a later session, so a member of a gone +// program's session joins only when its environment carries that terminal's id. When the +// table cannot be read, nothing in the record is signaled: ending a program would reparent +// the children only the table can find, and a retry could never reach them again. +// 2. An owned worker gets an authenticated `shutdown`: it kills each terminal and waits for it. +// 3. After `gracefulMs`, SIGKILL whatever owned process is left, re-inspecting right before +// each signal. +// 4. Succeed only when every candidate is gone or proven unrelated. Otherwise keep a record of +// the unresolved entries, marked closing, in a cleanup record no worker rewrites, and throw +// TerminalCleanupNotConfirmedError. An unreadable record is never reported confirmed. + +const DEFAULT_GRACEFUL_MS = 5_000; +const DEFAULT_CONFIRM_MS = 3_000; +const POLL_INTERVAL_MS = 100; + +export interface UnresolvedTerminalProcess extends TerminalCleanupUnresolved { + recordPath: string; +} + +// What a cleanup could not confirm, as a caller reports it: read here, or from the daemon +// status of a host the caller cannot read itself. +export interface UnconfirmedTerminalProcess { + pid: number; + role: string; + reason: string; + detail: string; +} + +export interface UnreadableTerminalWorkerRecord { + path: string; + error: string; +} + +export class TerminalCleanupNotConfirmedError extends Error { + readonly code = "TERMINAL_CLEANUP_NOT_CONFIRMED"; + readonly unresolved: UnconfirmedTerminalProcess[]; + readonly unreadableRecords: UnreadableTerminalWorkerRecord[]; + + constructor( + unresolved: UnconfirmedTerminalProcess[], + unreadableRecords: UnreadableTerminalWorkerRecord[] = [], + ) { + super( + [ + "Terminal cleanup was not confirmed.", + ...(unresolved.length > 0 + ? [ + "These processes may still be running:", + ...unresolved.map( + (entry) => ` pid ${entry.pid} (${entry.role}): ${entry.reason} (${entry.detail})`, + ), + "Their record is kept; the next `paseo daemon stop` retries the cleanup.", + ] + : []), + ...(unreadableRecords.length > 0 + ? [ + "These records cannot be read, so the processes they name cannot be checked:", + ...unreadableRecords.map((entry) => ` ${entry.path}: ${entry.error}`), + "Repair or remove each once you have checked its processes are gone.", + ] + : []), + ].join("\n"), + ); + this.name = "TerminalCleanupNotConfirmedError"; + this.unresolved = unresolved; + this.unreadableRecords = unreadableRecords; + } +} + +/** + * Retries the cleanup an earlier kill could not confirm (records marked closing), leaving any + * live worker a daemon controls alone. Throws TerminalCleanupNotConfirmedError when processes + * still survive, or when a record cannot be read, so a kill restart can report them. + */ +export async function retryUnconfirmedTerminalCleanup(paseoHome: string): Promise { + const files = await listTerminalWorkerRecords(paseoHome); + const recordPaths = files.flatMap((file) => + ("record" in file && file.record.closing) || "error" in file ? [file.path] : [], + ); + if (recordPaths.length === 0) return; + await reapTerminalWorkers({ paseoHome, gracefulMs: 0, recordPaths }); +} + +/** + * What the last cleanup of this home could not confirm gone, as its closing records name it. + * Read without retrying, so a daemon can report it (daemon status) to clients that cannot read + * this home themselves. Throws TerminalCleanupNotConfirmedError when any record cannot be read, + * worker records included: a kill cleanup that met it could not check what it names, and a + * failed cleanup leaves nothing else behind, so the cleanup cannot be reported finished. + */ +export async function readUnconfirmedTerminalCleanup( + paseoHome: string, +): Promise { + const files = await listTerminalWorkerRecords(paseoHome); + const unresolved = files.flatMap((file) => + "record" in file && file.record.closing + ? file.record.closing.unresolved.map((entry) => ({ ...entry, recordPath: file.path })) + : [], + ); + const unreadable = files.flatMap((file) => + "error" in file ? [{ path: file.path, error: file.error.message }] : [], + ); + if (unreadable.length > 0) { + throw new TerminalCleanupNotConfirmedError(unresolved, unreadable); + } + return unresolved; +} + +export interface TerminalWorkerReapResult { + records: number; + retired: number; + signaled: number[]; +} + +export interface ReapTerminalWorkersOptions { + paseoHome: string; + /** Time the worker gets to close its terminals before SIGKILL. 0 skips the shutdown request. */ + gracefulMs?: number; + confirmMs?: number; + /** Limit cleanup to these record files. Defaults to every record in the home. */ + recordPaths?: string[]; + inspector?: TerminalWorkerProcessInspector; + log?: (message: string, fields?: Record) => void; +} + +interface Candidate { + pid: number; + startedAt: string; + role: UnresolvedTerminalProcess["role"]; + terminalId: string; + state: ProcessOwnership; + // Set when this program's children could not be looked up: its cleanup is never reported + // confirmed, even once the program itself is gone, and its entry stays for the retry. + descendantsUnconfirmed: string | null; +} + +interface CandidateDiscovery { + candidates: Candidate[]; + // The process table could not be read, so no process of the record may be signaled yet. + tableError: string | null; +} + +export async function reapTerminalWorkers( + options: ReapTerminalWorkersOptions, +): Promise { + const inspector = options.inspector ?? createTerminalWorkerProcessInspector(); + const gracefulMs = options.gracefulMs ?? DEFAULT_GRACEFUL_MS; + const confirmMs = options.confirmMs ?? DEFAULT_CONFIRM_MS; + const log = options.log ?? (() => {}); + const wanted = options.recordPaths ? new Set(options.recordPaths) : null; + const result: TerminalWorkerReapResult = { records: 0, retired: 0, signaled: [] }; + const unresolved: UnresolvedTerminalProcess[] = []; + const unreadable: UnreadableTerminalWorkerRecord[] = []; + + for (const file of await listTerminalWorkerRecords(options.paseoHome)) { + if (wanted && !wanted.has(file.path)) continue; + result.records += 1; + if ("error" in file) { + // Nothing in an unreadable record can identify a process, so nothing may be signaled, + // and nothing it names can be confirmed gone. + log("Skipping unreadable terminal worker record", { + recordPath: file.path, + error: file.error.message, + }); + unreadable.push({ path: file.path, error: file.error.message }); + continue; + } + const outcome = await reapRecord({ + paseoHome: options.paseoHome, + file, + inspector, + gracefulMs, + confirmMs, + log, + }); + result.signaled.push(...outcome.signaled); + if (outcome.unresolved.length === 0) { + result.retired += 1; + } else { + unresolved.push(...outcome.unresolved); + } + } + + // Copies of worker files no remaining record names belong to no worker now. + const pruned = await pruneTerminalWorkerRuntimes(options.paseoHome); + for (const failure of pruned.failed) { + log("Cannot remove a terminal worker copy; the next cleanup retries", failure); + } + + if (unresolved.length > 0 || unreadable.length > 0) { + throw new TerminalCleanupNotConfirmedError(unresolved, unreadable); + } + return result; +} + +type ReadableRecordFile = Extract; + +interface ReapRecordInput { + paseoHome: string; + file: ReadableRecordFile; + inspector: TerminalWorkerProcessInspector; + gracefulMs: number; + confirmMs: number; + log: NonNullable; +} + +async function reapRecord( + input: ReapRecordInput, +): Promise<{ signaled: number[]; unresolved: UnresolvedTerminalProcess[] }> { + const { file, inspector, log } = input; + const { record } = file; + const recordPath = file.path; + if (!writtenDuringThisBoot({ startedAt: record.createdAt, bootId: record.bootId })) { + await retireTerminalWorkerRecord(file); + log("Retired terminal worker record from an earlier boot", { recordPath }); + return { signaled: [], unresolved: [] }; + } + + const worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + const discovery = await collectCandidates(record, inspector, log); + const tracked: TrackedProcesses = { + record, + inspector, + worker, + candidates: discovery.candidates, + }; + + if (discovery.tableError !== null) { + return await keepUnresolved(input, tracked, [], `not signaled: ${discovery.tableError}`); + } + + if (tracked.worker.status === "owned" && input.gracefulMs > 0) { + await requestWorkerShutdown(record, input.gracefulMs, log); + } + await waitUntilSettled( + tracked, + Date.now() + (tracked.worker.status === "owned" ? input.gracefulMs : 0), + ); + + let signaled: number[] = []; + if (!isSettled(tracked)) { + signaled = await forceKillOwned(tracked, recordPath, log); + await waitUntilSettled(tracked, Date.now() + input.confirmMs); + } + + return await keepUnresolved(input, tracked, signaled, null); +} + +// Retires the record when every process it names is settled; otherwise writes the cleanup +// record, marked closing, with only the unresolved entries. A live worker keeps rewriting its +// own record, so the closing state never goes there. `notSignaled` explains open processes that +// were deliberately left alone. +async function keepUnresolved( + input: Pick, + tracked: TrackedProcesses, + signaled: number[], + notSignaled: string | null, +): Promise<{ signaled: number[]; unresolved: UnresolvedTerminalProcess[] }> { + const { record } = tracked; + const { file, log } = input; + const recordPath = terminalCleanupRecordPath(input.paseoHome, record.recordId); + const { unresolved, remaining } = collectUnresolved(tracked, recordPath, notSignaled); + if (unresolved.length === 0) { + await retireTerminalWorkerRecord(file); + log("Terminal worker record cleaned up", { recordPath: file.path, signaled }); + return { signaled, unresolved }; + } + await writeTerminalCleanupRecord(input.paseoHome, { + ...record, + terminals: remaining, + closing: { + unresolved: unresolved.map(({ pid, role, reason, detail }) => ({ + pid, + role, + reason, + detail, + })), + }, + }); + log("Terminal worker cleanup not confirmed; record kept", { + recordPath, + unresolved: unresolved.map((entry) => ({ pid: entry.pid, reason: entry.reason })), + }); + return { signaled, unresolved }; +} + +interface TrackedProcesses { + record: TerminalWorkerRecord; + inspector: TerminalWorkerProcessInspector; + worker: ProcessOwnership; + candidates: Candidate[]; +} + +function isOpen(state: ProcessOwnership): boolean { + return state.status === "owned" || state.status === "unknown"; +} + +function isSettled(tracked: TrackedProcesses): boolean { + return !isOpen(tracked.worker) && tracked.candidates.every((c) => !isOpen(c.state)); +} + +async function refreshTracked(tracked: TrackedProcesses): Promise { + if (isOpen(tracked.worker)) { + tracked.worker = classifyTerminalWorkerProcess( + tracked.record, + await tracked.inspector.inspect(tracked.record.pid), + ); + } + for (const candidate of tracked.candidates) { + if (isOpen(candidate.state)) { + candidate.state = classifyTerminalProgramProcess( + candidate, + await tracked.inspector.inspect(candidate.pid), + ); + } + } +} + +async function waitUntilSettled(tracked: TrackedProcesses, deadline: number): Promise { + await refreshTracked(tracked); + while (!isSettled(tracked) && Date.now() < deadline) { + await delay(POLL_INTERVAL_MS); + await refreshTracked(tracked); + } +} + +// SIGKILL every process still owned, re-inspecting each right before its signal. +async function forceKillOwned( + tracked: TrackedProcesses, + recordPath: string, + log: NonNullable, +): Promise { + const { record, inspector } = tracked; + const signaled: number[] = []; + if (tracked.worker.status === "owned") { + tracked.worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + if (tracked.worker.status === "owned") { + await forceKillWorker(record.pid); + signaled.push(record.pid); + log("Force-killed terminal worker", { pid: record.pid, recordPath }); + } + } + for (const candidate of tracked.candidates) { + if (candidate.state.status !== "owned") continue; + candidate.state = classifyTerminalProgramProcess( + candidate, + await inspector.inspect(candidate.pid), + ); + if (candidate.state.status !== "owned") continue; + forceKillProgram(candidate.pid); + signaled.push(candidate.pid); + log("Force-killed terminal program", { + pid: candidate.pid, + terminalId: candidate.terminalId, + recordPath, + }); + } + return signaled; +} + +function collectUnresolved( + tracked: TrackedProcesses, + recordPath: string, + notSignaled: string | null, +): { unresolved: UnresolvedTerminalProcess[]; remaining: TerminalWorkerTerminalEntry[] } { + const unresolved: UnresolvedTerminalProcess[] = []; + if (isOpen(tracked.worker)) { + unresolved.push( + describeUnresolved({ + pid: tracked.record.pid, + role: "worker", + state: tracked.worker, + recordPath, + notSignaled, + }), + ); + } + const remaining: TerminalWorkerTerminalEntry[] = []; + for (const candidate of tracked.candidates) { + const stillOpen = isOpen(candidate.state); + if (!stillOpen && candidate.descendantsUnconfirmed === null) continue; + unresolved.push( + stillOpen + ? describeUnresolved({ + pid: candidate.pid, + role: candidate.role, + state: candidate.state, + recordPath, + notSignaled, + }) + : { + pid: candidate.pid, + role: candidate.role, + reason: "inspection failed", + detail: candidate.descendantsUnconfirmed!, + recordPath, + }, + ); + remaining.push({ + id: candidate.terminalId, + pid: candidate.pid, + startedAt: candidate.startedAt, + }); + } + return { unresolved, remaining }; +} + +async function collectCandidates( + record: TerminalWorkerRecord, + inspector: TerminalWorkerProcessInspector, + log: NonNullable, +): Promise { + const candidates = new Map(); + for (const entry of record.terminals) { + candidates.set(entry.pid, { + pid: entry.pid, + startedAt: entry.startedAt, + role: "terminal program", + terminalId: entry.id, + state: classifyTerminalProgramProcess(entry, await inspector.inspect(entry.pid)), + descendantsUnconfirmed: null, + }); + } + // Owned programs are walked through the table. Where sessions are reported, so are programs + // already gone: the kernel keeps a session id reserved while a member lives, so a job the + // shell left behind is still found. A pid reused by another process (unrelated) leads someone + // else's session and is never walked. + const roots = Array.from(candidates.values()).filter( + (c) => c.state.status === "owned" || (c.state.status === "gone" && inspector.reportsSessions), + ); + if (roots.length === 0) return { candidates: Array.from(candidates.values()), tableError: null }; + + let snapshot: ProcessTableEntry[]; + try { + snapshot = await inspector.listProcesses(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log("Cannot list processes for terminal cleanup", { error: message }); + for (const root of roots) { + root.descendantsUnconfirmed = `its child processes could not be listed: ${message}`; + } + return { + candidates: Array.from(candidates.values()), + tableError: `process table unavailable: ${message}`, + }; + } + for (const root of roots) { + const inTable = snapshot.find((entry) => entry.pid === root.pid); + if (inTable && inTable.startedAt !== root.startedAt) { + // The program ended and its pid was reused before the snapshot: what is below that pid + // now belongs to someone else, and the old session ended with every member of it. + log("Not walking a terminal program pid reused before the process table was read", { + pid: root.pid, + terminalId: root.terminalId, + }); + continue; + } + if (!inTable && !inspector.reportsSessions) continue; + // Below a live program its parent links prove ownership. Once it is gone, its session id + // may have been reused by a later session, so each member must carry the terminal's id. + const rootGone = !inTable; + for (const descendant of collectDescendants(snapshot, root.pid)) { + if (candidates.has(descendant.pid)) continue; + if (rootGone) { + const proof = await proveTerminalMember(inspector, descendant.pid, root.terminalId); + if (proof.status === "gone") continue; + if (proof.status === "unknown") { + root.descendantsUnconfirmed = `session member ${descendant.pid} cannot be identified: ${proof.reason}`; + continue; + } + if (proof.status === "unrelated") { + log("Ignoring a session member that does not carry its terminal's id", { + pid: descendant.pid, + terminalId: root.terminalId, + }); + continue; + } + } + candidates.set(descendant.pid, { + pid: descendant.pid, + startedAt: descendant.startedAt, + role: "terminal program", + terminalId: root.terminalId, + state: { status: "owned" }, + descendantsUnconfirmed: null, + }); + } + } + return { candidates: Array.from(candidates.values()), tableError: null }; +} + +// A terminal's programs inherit PASEO_TERMINAL_ID (terminal-manager.ts) from its PTY program. +// A process in a later session that happens to reuse the session id does not carry it. +async function proveTerminalMember( + inspector: TerminalWorkerProcessInspector, + pid: number, + terminalId: string, +): Promise { + let environment: string[] | null; + try { + environment = await inspector.readEnvironment(pid); + } catch (error) { + return { + status: "unknown", + reason: `environment unreadable: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (environment === null) return { status: "gone" }; + if (environment.includes(`PASEO_TERMINAL_ID=${terminalId}`)) return { status: "owned" }; + // Another terminal's id proves the process belongs elsewhere. No id at all proves nothing: a + // job started with a scrubbed environment (`env -i`) can still be this terminal's. + return environment.some((entry) => entry.startsWith("PASEO_TERMINAL_ID=")) + ? { status: "unrelated", reason: "environment carries another terminal's id" } + : { status: "unknown", reason: "its environment carries no terminal id" }; +} + +async function requestWorkerShutdown( + record: TerminalWorkerRecord, + gracefulMs: number, + log: NonNullable, +): Promise { + const deadline = Date.now() + gracefulMs; + let connection; + try { + connection = await connectTerminalWorker({ + socketPath: record.socketPath, + secret: record.secret, + timeoutMs: Math.min(gracefulMs, 2_000), + }); + } catch (error) { + // A worker that cannot be reached is escalated by birth time below; a failed connection + // alone never authorizes a signal. + log("Terminal worker did not accept a shutdown request", { + pid: record.pid, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + const responded = new Promise((resolve) => { + connection.on("message", (message) => { + if (message.type === "response") resolve(); + }); + }); + const closed = new Promise((resolve) => connection.on("close", () => resolve())); + const sendFailed = new Promise((resolve) => { + connection.send({ type: "shutdown", requestId: randomUUID() }, (error) => { + if (error) resolve(); + }); + }); + const timeout = new AbortController(); + await Promise.race([ + responded, + closed, + sendFailed, + delay(Math.max(0, deadline - Date.now()), undefined, { signal: timeout.signal }).catch( + () => undefined, + ), + ]); + timeout.abort(); + connection.disconnect(); +} + +function forceKillWorker(pid: number): Promise { + if (process.platform === "win32") { + return new Promise((resolve) => { + treeKill(pid, "SIGKILL", () => resolve()); + }); + } + signalIgnoringMissing(pid, "SIGKILL"); + return Promise.resolve(); +} + +function forceKillProgram(pid: number): void { + if (process.platform !== "win32") { + // A PTY program leads its own session and process group. The group cannot outlive its + // leader's pid while any member is alive, so it holds nothing but this program's job. + signalIgnoringMissing(-pid, "SIGKILL"); + } + signalIgnoringMissing(pid, "SIGKILL"); +} + +function signalIgnoringMissing(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ESRCH" && code !== "EPERM") throw error; + } +} + +function describeUnresolved(input: { + pid: number; + role: UnresolvedTerminalProcess["role"]; + state: ProcessOwnership; + recordPath: string; + notSignaled: string | null; +}): UnresolvedTerminalProcess { + const { pid, role, state, recordPath, notSignaled } = input; + if (state.status === "unknown") { + return { pid, role, reason: "inspection failed", detail: state.reason, recordPath }; + } + if (notSignaled !== null) { + return { pid, role, reason: "inspection failed", detail: notSignaled, recordPath }; + } + return { pid, role, reason: "still alive", detail: "survived SIGKILL", recordPath }; +} diff --git a/packages/server/src/terminal/terminal-worker-record-publisher.test.ts b/packages/server/src/terminal/terminal-worker-record-publisher.test.ts new file mode 100644 index 0000000000..dba50b9efe --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-record-publisher.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { setTimeout as delay } from "node:timers/promises"; +import { createTerminalWorkerRecordPublisher } from "./terminal-worker-record-publisher.js"; +import type { TerminalWorkerRecord } from "./terminal-worker-record.js"; + +const record: TerminalWorkerRecord = { + recordId: "worker", + pid: 123, + bootId: null, + createdAt: "now", + identity: { startedAt: "now", commandLine: "worker" }, + protocolVersion: 1, + paseoVersion: "test", + socketPath: "socket", + secret: "secret", + terminals: [{ id: "terminal", pid: 124, startedAt: "now" }], +}; + +describe("live worker record publication", () => { + it.each(["EPERM", "EBUSY", "EACCES"])( + "publishes the latest state after %s contention clears", + async (code) => { + let locked = true; + const written: TerminalWorkerRecord[] = []; + const publisher = createTerminalWorkerRecordPublisher((state) => { + if (locked) throw Object.assign(new Error("reader holds record"), { code }); + written.push(state); + }); + try { + const creation = publisher.publish(record); + const exited = { ...record, terminals: [] }; + const exit = publisher.publish(exited); + expect(written).toEqual([]); + locked = false; + await Promise.all([creation, exit]); + expect(written).toEqual([exited]); + } finally { + publisher.stop(); + } + }, + ); + + it("does not recreate a retired record when a reader releases it", async () => { + let locked = true; + const written: TerminalWorkerRecord[] = []; + const publisher = createTerminalWorkerRecordPublisher((state) => { + if (locked) + throw Object.assign(new Error("reader holds record"), { + code: "EPERM", + }); + written.push(state); + }); + const pending = publisher.publish(record); + const rejected = expect(pending).rejects.toThrow("stopped"); + publisher.stop(); + locked = false; + await rejected; + await delay(50); + expect(written).toEqual([]); + await expect(publisher.publish(record)).rejects.toThrow("stopped"); + }); + + it("reports permanent storage failures without retrying", async () => { + const publisher = createTerminalWorkerRecordPublisher(() => { + throw Object.assign(new Error("disk full"), { code: "ENOSPC" }); + }); + await expect(publisher.publish(record)).rejects.toThrow("disk full"); + publisher.stop(); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-record-publisher.ts b/packages/server/src/terminal/terminal-worker-record-publisher.ts new file mode 100644 index 0000000000..415c785102 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-record-publisher.ts @@ -0,0 +1,62 @@ +import type { TerminalWorkerRecord } from "./terminal-worker-record.js"; + +const TRANSIENT_ERRORS = new Set(["EPERM", "EBUSY", "EACCES"]); + +/** Serializes live worker publication, retaining the latest state across reader contention. + * Creation awaits publication; exit notifications need not wait for a reader to close. + * Stop before retiring the record so a pending retry cannot recreate it. + */ +export function createTerminalWorkerRecordPublisher(write: (record: TerminalWorkerRecord) => void) { + let stopped = false; + let latest: TerminalWorkerRecord; + let timer: ReturnType | undefined; + let pending: + | { + promise: Promise; + resolve: () => void; + reject: (error: unknown) => void; + } + | undefined; + + function attempt(): void { + timer = undefined; + const publication = pending; + if (!publication) return; + try { + write(latest); + publication.resolve(); + pending = undefined; + } catch (error) { + if (TRANSIENT_ERRORS.has((error as NodeJS.ErrnoException).code ?? "")) { + timer = setTimeout(attempt, 25); + } else { + publication.reject(error); + pending = undefined; + } + } + } + + return { + publish(record: TerminalWorkerRecord): Promise { + if (stopped) return Promise.reject(new Error("Worker record publisher stopped")); + latest = record; + if (pending) return pending.promise; + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + pending = { promise, resolve, reject }; + attempt(); + return promise; + }, + stop(): void { + stopped = true; + clearTimeout(timer); + timer = undefined; + pending?.reject(new Error("Worker record publisher stopped")); + pending = undefined; + }, + }; +} diff --git a/packages/server/src/terminal/terminal-worker-record.ts b/packages/server/src/terminal/terminal-worker-record.ts new file mode 100644 index 0000000000..3176d2ee8e --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-record.ts @@ -0,0 +1,230 @@ +import { readdir, readFile, rm } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import path from "node:path"; +import { z } from "zod"; +import { writePrivateFileAtomicSync, writePrivateFileAtomic } from "../server/private-files.js"; + +// Owner-only metadata a detached terminal worker leaves in $PASEO_HOME so the next daemon can +// find, authenticate and adopt it, and so cleanup can identify every process it is allowed to +// signal. Process identity is the birth time `ps`/Win32_Process report, never the pid alone. + +const TerminalWorkerTerminalEntrySchema = z.object({ + id: z.string().min(1), + pid: z.number().int().positive(), + startedAt: z.string().min(1), +}); + +// One process a cleanup could not confirm gone, kept so a later caller (a retry, or a remote +// client reading daemon status) can name it. +const TerminalCleanupUnresolvedSchema = z.object({ + pid: z.number().int().positive(), + role: z.enum(["worker", "terminal program"]), + reason: z.enum(["still alive", "inspection failed"]), + detail: z.string(), +}); + +const TerminalWorkerRecordSchema = z.object({ + recordId: z.string().min(1), + pid: z.number().int().positive(), + bootId: z.string().nullable(), + createdAt: z.string().min(1), + identity: z.object({ + startedAt: z.string().min(1), + commandLine: z.string().min(1), + }), + // Read loosely: a version this daemon cannot interpret marks a worker from a newer Paseo + // (classifyTerminalWorkerProtocol), not an unreadable record. + protocolVersion: z.union([z.number(), z.string()]), + paseoVersion: z.string(), + socketPath: z.string().min(1), + secret: z.string().min(1), + // The copy of the worker's files it runs from (a directory name under terminal-worker/), or + // null when it runs from source. A copy named by any record is never deleted. + runtime: z.string().nullable().optional(), + terminals: z.array(TerminalWorkerTerminalEntrySchema), + // Set only in a cleanup record (below), with what a cleanup could not confirm gone. A record + // marked closing is never adopted: the next daemon or stop retries the cleanup instead. + closing: z.object({ unresolved: z.array(TerminalCleanupUnresolvedSchema).min(1) }).optional(), +}); + +export type TerminalWorkerTerminalEntry = z.infer; +export type TerminalCleanupUnresolved = z.infer; +export type TerminalWorkerRecord = z.infer; + +// A worker record (`.json`) belongs to the worker, which rewrites it from memory whenever +// a terminal starts or exits. A cleanup that cannot confirm every process gone therefore keeps +// its state in a cleanup record of its own (`closing-.json`) that no worker writes, so +// the kill intent and what it could not confirm survive any later worker rewrite. +type TerminalWorkerRecordKind = "worker" | "cleanup"; + +export type TerminalWorkerRecordFile = + | { + // The file holding this record's state: the cleanup record when there is one. + path: string; + record: TerminalWorkerRecord; + // The worker's own record file, when it still exists. + workerRecordPath: string | null; + } + | { path: string; error: Error }; + +// The worker's command line carries this marker followed by its record id, so a reused pid +// running some other process (even another worker) never matches the record. +export const TERMINAL_WORKER_MARKER_ARG = "--paseo-terminal-worker"; + +export function terminalWorkerDirectory(paseoHome: string): string { + return path.join(paseoHome, "terminal-worker"); +} + +export function terminalWorkerRecordPath(paseoHome: string, workerPid: number): string { + return path.join(terminalWorkerDirectory(paseoHome), `${workerPid}.json`); +} + +export function terminalCleanupRecordPath(paseoHome: string, recordId: string): string { + return path.join(terminalWorkerDirectory(paseoHome), `closing-${recordId}.json`); +} + +export function terminalWorkerLogPath(paseoHome: string): string { + return path.join(paseoHome, "terminal-worker.log"); +} + +export function writeTerminalWorkerRecord(paseoHome: string, record: TerminalWorkerRecord): string { + const recordPath = terminalWorkerRecordPath(paseoHome, record.pid); + writePrivateFileAtomicSync(recordPath, `${JSON.stringify(record, null, 2)}\n`); + return recordPath; +} + +/** Writes the state of a cleanup that could not confirm every process gone. */ +export async function writeTerminalCleanupRecord( + paseoHome: string, + record: TerminalWorkerRecord & { closing: NonNullable }, +): Promise { + if (!RECORD_ID_PATTERN.test(record.recordId)) { + throw new Error(`Terminal worker record id is not a file name: ${record.recordId}`); + } + const recordPath = terminalCleanupRecordPath(paseoHome, record.recordId); + await writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`); + return recordPath; +} + +export async function readTerminalWorkerRecord(recordPath: string): Promise { + return TerminalWorkerRecordSchema.parse(JSON.parse(await readRecordFile(recordPath))); +} + +// A worker rewrites its record by renaming a new file over it. On Windows an open() that races +// that rename fails with EPERM (or EBUSY) for a moment, so such an error is retried for a short +// window before the record counts as unreadable. Reads and retries yield so daemon status and +// cleanup never block unrelated requests while another process holds the record open. +const TRANSIENT_READ_ERRORS = new Set(["EPERM", "EBUSY", "EACCES"]); +const TRANSIENT_READ_WINDOW_MS = 250; +const TRANSIENT_READ_PAUSE_MS = 10; + +async function readRecordFile(recordPath: string): Promise { + const deadline = Date.now() + TRANSIENT_READ_WINDOW_MS; + for (;;) { + try { + return await readFile(recordPath, "utf8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !TRANSIENT_READ_ERRORS.has(code) || Date.now() >= deadline) throw error; + await delay(TRANSIENT_READ_PAUSE_MS); + } + } +} + +const RECORD_ID_PATTERN = /^[A-Za-z0-9-]+$/; +const WORKER_RECORD_NAME = /^\d+\.json$/; +const CLEANUP_RECORD_NAME = /^closing-[A-Za-z0-9-]+\.json$/; + +function recordKindOf(fileName: string): TerminalWorkerRecordKind | null { + if (WORKER_RECORD_NAME.test(fileName)) return "worker"; + if (CLEANUP_RECORD_NAME.test(fileName)) return "cleanup"; + return null; +} + +/** + * Every record in the home. A cleanup record is merged with the worker record of the same + * record id: its closing state wins, and terminals the worker recorded since join it, so the + * retry covers everything the worker still runs. + */ +export async function listTerminalWorkerRecords( + paseoHome: string, +): Promise { + const directory = terminalWorkerDirectory(paseoHome); + let names: string[]; + try { + names = await readdir(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + const errors: TerminalWorkerRecordFile[] = []; + const workers: Array<{ path: string; record: TerminalWorkerRecord }> = []; + const cleanups: Array<{ path: string; record: TerminalWorkerRecord }> = []; + for (const name of names.sort()) { + const kind = recordKindOf(name); + if (!kind) continue; + const recordPath = path.join(directory, name); + try { + const record = await readTerminalWorkerRecord(recordPath); + (kind === "worker" ? workers : cleanups).push({ path: recordPath, record }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + errors.push({ + path: recordPath, + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + const files: TerminalWorkerRecordFile[] = []; + const merged = new Set(); + for (const cleanup of cleanups) { + const worker = workers.find( + (entry) => + entry.record.recordId === cleanup.record.recordId && + entry.record.pid === cleanup.record.pid, + ); + if (worker) merged.add(worker.path); + const known = new Set(cleanup.record.terminals.map((entry) => `${entry.id}:${entry.pid}`)); + const added = (worker?.record.terminals ?? []).filter( + (entry) => !known.has(`${entry.id}:${entry.pid}`), + ); + files.push({ + path: cleanup.path, + record: { ...cleanup.record, terminals: [...cleanup.record.terminals, ...added] }, + workerRecordPath: worker?.path ?? null, + }); + } + for (const worker of workers) { + if (merged.has(worker.path)) continue; + files.push({ path: worker.path, record: worker.record, workerRecordPath: worker.path }); + } + return [...files, ...errors]; +} + +/** + * Deletes a record, the worker's own record file and the socket it names, unless a later worker + * that reused the pid wrote that file since. The pipe namespace on Windows needs no unlink. + */ +export async function retireTerminalWorkerRecord(file: { + path: string; + record: TerminalWorkerRecord; + workerRecordPath: string | null; +}): Promise { + if (file.path !== file.workerRecordPath) await rm(file.path, { force: true }); + if (file.workerRecordPath) { + if (await workerRecordNamesAnother(file.workerRecordPath, file.record.recordId)) return; + await rm(file.workerRecordPath, { force: true }); + } + if (process.platform !== "win32") { + await rm(file.record.socketPath, { force: true }); + } +} + +// True when the file now belongs to another worker, or cannot be read to tell. +async function workerRecordNamesAnother(recordPath: string, recordId: string): Promise { + try { + return (await readTerminalWorkerRecord(recordPath)).recordId !== recordId; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } +} diff --git a/packages/server/src/terminal/terminal-worker-runtime.test.ts b/packages/server/src/terminal/terminal-worker-runtime.test.ts new file mode 100644 index 0000000000..feb4bf3309 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-runtime.test.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { builtinModules } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { isPlatform } from "../test-utils/platform.js"; +import { + prepareTerminalWorkerRuntime, + pruneTerminalWorkerRuntimes, + TerminalWorkerRuntimeError, +} from "./terminal-worker-runtime.js"; +import { + terminalWorkerDirectory, + writeTerminalWorkerRecord, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; + +const temporaryDirs: string[] = []; + +afterEach(() => { + for (const dir of temporaryDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function makeDir(prefix: string): string { + const dir = mkdtempSync(path.join(tmpdir(), prefix)); + temporaryDirs.push(dir); + return dir; +} + +// A built bundle directory and a node-pty package shaped like the real ones. +function makeSources(bundleSource = "export {};\n"): { + bundleDirectory: string; + nodePtyDirectory: string; +} { + const bundleDirectory = makeDir("tw-bundle-"); + writeFileSync(path.join(bundleDirectory, "terminal-worker.mjs"), bundleSource); + mkdirSync(path.join(bundleDirectory, "shell-integration", "zsh"), { recursive: true }); + writeFileSync(path.join(bundleDirectory, "shell-integration", "zsh", ".zshenv"), "# zsh\n"); + const nodePtyDirectory = makeDir("tw-node-pty-"); + writeFileSync(path.join(nodePtyDirectory, "package.json"), '{"version":"1.2.3"}\n'); + mkdirSync(path.join(nodePtyDirectory, "lib")); + writeFileSync(path.join(nodePtyDirectory, "lib", "index.js"), "module.exports = {};\n"); + const native = path.join(nodePtyDirectory, "prebuilds", `${process.platform}-${process.arch}`); + mkdirSync(native, { recursive: true }); + writeFileSync(path.join(native, "pty.node"), "native"); + writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 }); + // Another platform's build is not copied. + mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform")); + return { bundleDirectory, nodePtyDirectory }; +} + +function recordNaming(runtime: string | null, pid = 4242): TerminalWorkerRecord { + return { + recordId: `record-${pid}`, + pid, + bootId: null, + createdAt: new Date().toISOString(), + identity: { startedAt: "Thu Jan 1 00:00:00 1970", commandLine: "worker" }, + protocolVersion: 1, + paseoVersion: "1.0.0", + socketPath: "/tmp/does-not-exist.sock", + secret: "secret", + runtime, + terminals: [], + }; +} + +describe("terminal worker runtime copy", () => { + it("copies the bundle, its shell integration and this platform's node-pty into the home", () => { + const home = makeDir("tw-runtime-home-"); + const entry = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0-beta.1", + ...makeSources(), + }); + + const directory = path.join(terminalWorkerDirectory(home), entry.runtime!); + expect(entry.runtime).toMatch(/^1\.0\.0-beta\.1-[0-9a-f]{12}$/); + expect(entry.scriptPath).toBe(path.join(directory, "terminal-worker.mjs")); + expect(entry.execArgv).toEqual([]); + expect(existsSync(path.join(directory, "shell-integration", "zsh", ".zshenv"))).toBe(true); + const nodePty = path.join(directory, "node_modules", "node-pty"); + expect(existsSync(path.join(nodePty, "lib", "index.js"))).toBe(true); + expect(readdirSync(path.join(nodePty, "prebuilds"))).toEqual([ + `${process.platform}-${process.arch}`, + ]); + if (!isPlatform("win32")) { + const helper = path.join(nodePty, "prebuilds", `${process.platform}-${process.arch}`); + expect(statSync(path.join(helper, "spawn-helper")).mode & 0o111).toBe(0o111); + } + }); + + it("reuses a finished copy of the same content and makes a new one for a different build", () => { + const home = makeDir("tw-runtime-home-"); + const sources = makeSources(); + const first = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...sources, + }); + const marker = path.join(terminalWorkerDirectory(home), first.runtime!, "terminal-worker.mjs"); + writeFileSync(marker, "// the running copy\n"); + + const again = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...sources, + }); + const rebuilt = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...makeSources("export const rebuilt = true;\n"), + }); + + expect(again.runtime).toBe(first.runtime); + expect(statSync(marker).size).toBe("// the running copy\n".length); + expect(rebuilt.runtime).not.toBe(first.runtime); + }); + + it("replaces a copy an earlier run left unfinished", () => { + const home = makeDir("tw-runtime-home-"); + const sources = makeSources(); + const first = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...sources, + }); + const directory = path.join(terminalWorkerDirectory(home), first.runtime!); + rmSync(directory, { recursive: true }); + mkdirSync(directory); + + prepareTerminalWorkerRuntime({ paseoHome: home, paseoVersion: "1.0.0", ...sources }); + + expect(existsSync(path.join(directory, "terminal-worker.mjs"))).toBe(true); + }); + + it("fails before copying anything when node-pty has no native build for this platform", () => { + const home = makeDir("tw-runtime-home-"); + const sources = makeSources(); + rmSync(path.join(sources.nodePtyDirectory, "prebuilds"), { recursive: true }); + + expect(() => + prepareTerminalWorkerRuntime({ paseoHome: home, paseoVersion: "1.0.0", ...sources }), + ).toThrow(TerminalWorkerRuntimeError); + expect(readdirSync(terminalWorkerDirectory(home))).toEqual([]); + }); + + it("removes only copies no worker record names", async () => { + const home = makeDir("tw-runtime-home-"); + const used = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...makeSources(), + }); + const unused = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "0.9.0", + ...makeSources(), + }); + writeTerminalWorkerRecord(home, recordNaming(used.runtime)); + const socketDirectory = path.join(terminalWorkerDirectory(home), "sock"); + mkdirSync(socketDirectory); + + const result = await pruneTerminalWorkerRuntimes(home); + + expect(result.removed).toEqual([path.join(terminalWorkerDirectory(home), unused.runtime!)]); + expect(existsSync(path.dirname(used.scriptPath))).toBe(true); + expect(existsSync(socketDirectory)).toBe(true); + }); + + it("removes nothing while a record cannot be read", async () => { + const home = makeDir("tw-runtime-home-"); + const copy = prepareTerminalWorkerRuntime({ + paseoHome: home, + paseoVersion: "1.0.0", + ...makeSources(), + }); + writeFileSync(path.join(terminalWorkerDirectory(home), "4242.json"), "{ not json"); + + expect((await pruneTerminalWorkerRuntimes(home)).removed).toEqual([]); + expect(existsSync(copy.scriptPath)).toBe(true); + }); +}); + +describe("terminal worker bundle", () => { + // The copy holds the bundle and node-pty only. A package the bundle loads at run time + // (a require hidden from esbuild behind createRequire) would be missing from it. + it("loads nothing at run time but Node built-ins and node-pty", async () => { + const outDir = makeDir("tw-bundle-build-"); + const script = fileURLToPath( + new URL("../../scripts/build-terminal-worker.mjs", import.meta.url), + ); + const { buildTerminalWorkerBundle } = (await import(script)) as { + buildTerminalWorkerBundle(input: { outDir: string }): Promise; + }; + await buildTerminalWorkerBundle({ outDir }); + + const bundle = readFileSync(path.join(outDir, "terminal-worker.mjs"), "utf8"); + const loaded = new Set(); + for (const match of bundle.matchAll( + /(?:\brequire\w*(?:\.resolve)?\(|\bfrom\s+|\bimport\s*\()\s*"([^"]+)"/g, + )) { + loaded.add(match[1]!); + } + const builtins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); + const packages = [...loaded].filter((specifier) => !builtins.has(specifier)); + expect(packages.map((specifier) => specifier.split("/")[0])).toEqual( + packages.map(() => "node-pty"), + ); + expect(existsSync(path.join(outDir, "shell-integration", "zsh"))).toBe(true); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-runtime.ts b/packages/server/src/terminal/terminal-worker-runtime.ts new file mode 100644 index 0000000000..7ee0696478 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-runtime.ts @@ -0,0 +1,249 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { access, readdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ensurePrivateDirectory } from "../server/private-files.js"; +import { listTerminalWorkerRecords, terminalWorkerDirectory } from "./terminal-worker-record.js"; + +// A terminal worker outlives the installation that started it. An npm or desktop update +// replaces that installation's files, so a worker launched from them would fail its next +// terminal create (node-pty's spawn-helper, the zsh integration). A built daemon therefore +// copies the worker bundle, its shell integration and node-pty into +// $PASEO_HOME/terminal-worker/-/ and launches the worker from there. +// The hash keeps two builds of one version (a local rebuild) from sharing a copy. A copy is +// removed once no worker record names it: after a worker drains, after a kill cleanup, and at +// daemon start. Dev runs (.ts source) launch from source and copy nothing. + +export const TERMINAL_WORKER_BUNDLE_NAME = "terminal-worker.mjs"; +// Written last into a finished copy; a directory without it is an interrupted copy. +const RUNTIME_MARKER = "paseo-terminal-worker-runtime.json"; +const TEMP_PREFIX = ".tmp-"; + +export class TerminalWorkerRuntimeError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "TerminalWorkerRuntimeError"; + } +} + +/** What the launcher runs: the worker script, the node flags it needs, and the copy it is in. */ +export interface TerminalWorkerEntry { + scriptPath: string; + execArgv: string[]; + /** The copy's directory name under terminal-worker/, or null when run from source. */ + runtime: string | null; +} + +function sourceExecArgv(): string[] { + const loaderUrl = new URL("./terminal-ts-loader.mjs", import.meta.url).href; + const importSource = [ + 'import { register } from "node:module";', + 'import { pathToFileURL } from "node:url";', + `register(${JSON.stringify(loaderUrl)}, pathToFileURL("./"));`, + ].join(" "); + return [ + "--experimental-strip-types", + "--import", + `data:text/javascript,${encodeURIComponent(importSource)}`, + ]; +} + +/** + * The entry a daemon launches its worker from: the source file in dev, otherwise a copy of the + * built bundle in this home, prepared on demand. + */ +export function resolveTerminalWorkerEntry(input: { + paseoHome: string; + paseoVersion: string; +}): TerminalWorkerEntry { + if (import.meta.url.endsWith(".ts")) { + return { + scriptPath: fileURLToPath(new URL("./terminal-worker-process.ts", import.meta.url)), + execArgv: sourceExecArgv(), + runtime: null, + }; + } + const bundleDirectory = fileURLToPath(new URL("../terminal-worker/", import.meta.url)); + if (!existsSync(path.join(bundleDirectory, TERMINAL_WORKER_BUNDLE_NAME))) { + throw new TerminalWorkerRuntimeError( + `The terminal worker bundle is missing from ${bundleDirectory}; rebuild @getpaseo/server (npm run build).`, + ); + } + const require = createRequire(import.meta.url); + const nodePtyDirectory = path.dirname(require.resolve("node-pty/package.json")); + return prepareTerminalWorkerRuntime({ + paseoHome: input.paseoHome, + paseoVersion: input.paseoVersion, + bundleDirectory, + nodePtyDirectory, + }); +} + +function runtimeName(input: { + paseoVersion: string; + bundleDirectory: string; + nodePtyDirectory: string; +}): string { + const hash = createHash("sha256"); + hash.update(readFileSync(path.join(input.bundleDirectory, TERMINAL_WORKER_BUNDLE_NAME))); + hash.update(readFileSync(path.join(input.nodePtyDirectory, "package.json"))); + hash.update(`${process.platform}-${process.arch}`); + const version = input.paseoVersion.replace(/[^A-Za-z0-9._-]/g, "_"); + return `${version}-${hash.digest("hex").slice(0, 12)}`; +} + +// Plain fs calls on purpose: inside the desktop app the sources live in app.asar, which +// Electron's fs reads transparently but newer helpers like cpSync do not. +function copyTree(source: string, destination: string): void { + const stat = statSync(source); + if (stat.isDirectory()) { + mkdirSync(destination, { recursive: true }); + for (const name of readdirSync(source)) { + copyTree(path.join(source, name), path.join(destination, name)); + } + return; + } + writeFileSync(destination, readFileSync(source)); + // Keeps node-pty's spawn-helper executable. + chmodSync(destination, stat.mode & 0o777); +} + +function copyNodePty(source: string, destination: string): void { + mkdirSync(destination, { recursive: true }); + copyTree(path.join(source, "package.json"), path.join(destination, "package.json")); + copyTree(path.join(source, "lib"), path.join(destination, "lib")); + const native = [ + path.join("build", "Release"), + path.join("prebuilds", `${process.platform}-${process.arch}`), + ].filter((relative) => existsSync(path.join(source, relative))); + if (native.length === 0) { + throw new TerminalWorkerRuntimeError( + `node-pty at ${source} has no native build for ${process.platform}-${process.arch}`, + ); + } + for (const relative of native) { + copyTree(path.join(source, relative), path.join(destination, relative)); + } +} + +function isCompleteRuntime(directory: string): boolean { + return existsSync(path.join(directory, RUNTIME_MARKER)); +} + +/** + * Copies the worker bundle, its shell integration and node-pty into this home, unless a finished + * copy of the same content is already there. Returns the entry to launch from the copy. + */ +export function prepareTerminalWorkerRuntime(input: { + paseoHome: string; + paseoVersion: string; + bundleDirectory: string; + nodePtyDirectory: string; +}): TerminalWorkerEntry { + const root = terminalWorkerDirectory(input.paseoHome); + ensurePrivateDirectory(root); + const name = runtimeName(input); + const directory = path.join(root, name); + const entry = { + scriptPath: path.join(directory, TERMINAL_WORKER_BUNDLE_NAME), + execArgv: [], + runtime: name, + }; + if (isCompleteRuntime(directory)) return entry; + + // An interrupted copy left behind: no worker can be running from it. + rmSync(directory, { recursive: true, force: true }); + const temporary = path.join(root, `${TEMP_PREFIX}${name}-${randomUUID()}`); + try { + mkdirSync(temporary, { recursive: true }); + copyTree( + path.join(input.bundleDirectory, TERMINAL_WORKER_BUNDLE_NAME), + path.join(temporary, TERMINAL_WORKER_BUNDLE_NAME), + ); + copyTree( + path.join(input.bundleDirectory, "shell-integration"), + path.join(temporary, "shell-integration"), + ); + copyNodePty(input.nodePtyDirectory, path.join(temporary, "node_modules", "node-pty")); + writeFileSync( + path.join(temporary, RUNTIME_MARKER), + `${JSON.stringify({ paseoVersion: input.paseoVersion, copiedAt: new Date().toISOString() })}\n`, + ); + renameSync(temporary, directory); + } catch (error) { + rmSync(temporary, { recursive: true, force: true }); + if (isCompleteRuntime(directory)) return entry; + throw new TerminalWorkerRuntimeError( + `Cannot copy the terminal worker into ${directory}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + return entry; +} + +export interface PruneTerminalWorkerRuntimesResult { + removed: string[]; + /** Copies that could not be deleted (a worker that just exited can still hold files on Windows). */ + failed: Array<{ directory: string; error: string }>; +} + +/** + * Deletes every copy no worker record names. With an unreadable record it deletes nothing: that + * record could name a copy a live worker runs from. + */ +export async function pruneTerminalWorkerRuntimes( + paseoHome: string, +): Promise { + const result: PruneTerminalWorkerRuntimesResult = { removed: [], failed: [] }; + const root = terminalWorkerDirectory(paseoHome); + const files = await listTerminalWorkerRecords(paseoHome); + const inUse = new Set(); + for (const file of files) { + if ("error" in file) return result; + if (file.record.runtime) inUse.add(file.record.runtime); + } + let names: string[]; + try { + names = await readdir(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return result; + throw error; + } + for (const name of names) { + if (inUse.has(name)) continue; + const directory = path.join(root, name); + if ( + !name.startsWith(TEMP_PREFIX) && + !(await access(path.join(directory, RUNTIME_MARKER)).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ENOTDIR") return false; + throw error; + }, + )) + ) + continue; + try { + await rm(directory, { recursive: true, force: true }); + result.removed.push(directory); + } catch (error) { + result.failed.push({ + directory, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return result; +} diff --git a/packages/server/src/terminal/terminal-worker-socket-path.test.ts b/packages/server/src/terminal/terminal-worker-socket-path.test.ts new file mode 100644 index 0000000000..409cf60c81 --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-socket-path.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { isPlatform } from "../test-utils/platform.js"; +import { + resolveTerminalWorkerSocketPath, + TerminalWorkerSocketPathError, +} from "./terminal-worker-socket-path.js"; + +const temporaryDirs: string[] = []; + +function makeDir(prefix: string, root = tmpdir()): string { + const dir = mkdtempSync(path.join(root, prefix)); + temporaryDirs.push(dir); + return dir; +} + +// A home path long enough that its socket cannot fit sun_path, with multi-byte segments so the +// budget is measured in bytes, not characters. +function longHome(root: string): string { + const home = path.join(root, "worktree-ñandú-".repeat(5), "paseo-home"); + mkdirSync(home, { recursive: true }); + return home; +} + +afterEach(() => { + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe.skipIf(isPlatform("win32"))("terminal worker socket path", () => { + it("uses the home's own socket directory when the path fits", () => { + // macOS $TMPDIR alone is ~50 bytes; a short root keeps the home's own path in budget. + const home = makeDir("tw-h-", "/tmp"); + const socketPath = resolveTerminalWorkerSocketPath({ + paseoHome: home, + workerPid: 4242, + platform: "darwin", + }); + expect(socketPath.startsWith(path.join(home, "terminal-worker", "sock"))).toBe(true); + }); + + it("falls to /tmp/paseo- when the home and TMPDIR are too long, within the byte budget", () => { + const root = makeDir("tw-sock-long-"); + const home = longHome(root); + const tmpDir = path.join(root, "tmp-ñandú-".repeat(6)); + mkdirSync(tmpDir, { recursive: true }); + const uid = process.getuid!(); + const socketPath = resolveTerminalWorkerSocketPath({ + paseoHome: home, + workerPid: 99999, + platform: "darwin", + tmpDir, + }); + expect(path.dirname(socketPath)).toBe(`/tmp/paseo-${uid}`); + expect(Buffer.byteLength(socketPath, "utf8")).toBeLessThanOrEqual(103); + }); + + it("rejects a shared-root directory that is a symlink, has another mode, or is not a directory", () => { + const root = makeDir("tw-sock-reject-"); + const home = longHome(root); + const uid = process.getuid!(); + const tmpDir = makeDir("tw-"); + const target = makeDir("tw-target-"); + chmodSync(target, 0o700); + symlinkSync(target, path.join(tmpDir, `paseo-${uid}`)); + // A budget check alone would accept the long-home fallback through the symlink; it must + // also be refused, and /tmp/paseo- takes over. + const socketPath = resolveTerminalWorkerSocketPath({ + paseoHome: home, + workerPid: 7, + platform: "darwin", + tmpDir, + }); + expect(path.dirname(socketPath)).toBe(`/tmp/paseo-${uid}`); + + const wideTmp = makeDir("tw-"); + mkdirSync(path.join(wideTmp, `paseo-${uid}`), { mode: 0o755 }); + chmodSync(path.join(wideTmp, `paseo-${uid}`), 0o755); + expect( + path.dirname( + resolveTerminalWorkerSocketPath({ + paseoHome: home, + workerPid: 7, + platform: "darwin", + tmpDir: wideTmp, + }), + ), + ).toBe(`/tmp/paseo-${uid}`); + }); + + it("names every rejected candidate with its byte length when none fits", () => { + const root = makeDir("tw-sock-none-"); + const home = longHome(root); + const tmpDir = path.join(root, "tmp-ñandú-".repeat(6)); + mkdirSync(tmpDir, { recursive: true }); + let caught: unknown; + try { + resolveTerminalWorkerSocketPath({ + paseoHome: home, + workerPid: 7, + platform: "darwin", + tmpDir, + // Another uid's /tmp/paseo- directory does not exist or is not ours; with a uid + // nobody owns, the /tmp candidate's directory is created by us but owned by our real + // uid, so it is rejected for its owner. + uid: 2_000_000_000, + }); + } catch (error) { + caught = error; + } + rmSync("/tmp/paseo-2000000000", { recursive: true, force: true }); + expect(caught).toBeInstanceOf(TerminalWorkerSocketPathError); + const rejected = (caught as TerminalWorkerSocketPathError).rejected; + expect(rejected).toHaveLength(3); + expect(rejected[0].reason).toMatch(/longer than 103 bytes/); + expect(rejected[0].byteLength).toBe(Buffer.byteLength(rejected[0].path, "utf8")); + expect(rejected[2].reason).toMatch(/owned by uid/); + }); + + it("accepts a real listen on the chosen path", async () => { + const home = makeDir("tw-l-", "/tmp"); + const socketPath = resolveTerminalWorkerSocketPath({ paseoHome: home, workerPid: 5 }); + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, () => resolve()); + }); + await new Promise((resolve) => server.close(() => resolve())); + }); +}); diff --git a/packages/server/src/terminal/terminal-worker-socket-path.ts b/packages/server/src/terminal/terminal-worker-socket-path.ts new file mode 100644 index 0000000000..99fce0e64c --- /dev/null +++ b/packages/server/src/terminal/terminal-worker-socket-path.ts @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import { lstatSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ensurePrivateDirectory } from "../server/private-files.js"; +import { terminalWorkerDirectory } from "./terminal-worker-record.js"; + +// `sun_path` holds 104 bytes on macOS and 108 on Linux, NUL included. Dev homes under +// worktrees and long $TMPDIR values exceed that, so the socket goes to the first owner-only +// directory whose full path fits, measured in UTF-8 bytes. +const SOCKET_PATH_BYTE_BUDGET: Partial> = { + darwin: 103, + linux: 107, +}; +const DEFAULT_SOCKET_PATH_BYTE_BUDGET = 103; +const OWNER_ONLY_MODE = 0o700; + +export interface TerminalWorkerSocketPathInput { + paseoHome: string; + workerPid: number; + platform?: NodeJS.Platform; + tmpDir?: string; + uid?: number; +} + +export interface RejectedSocketCandidate { + path: string; + byteLength: number; + reason: string; +} + +export class TerminalWorkerSocketPathError extends Error { + readonly rejected: RejectedSocketCandidate[]; + + constructor(rejected: RejectedSocketCandidate[], budget: number) { + super( + [ + `No terminal worker socket path fits ${budget} bytes in an owner-only directory:`, + ...rejected.map( + (candidate) => ` ${candidate.path} (${candidate.byteLength} bytes): ${candidate.reason}`, + ), + ].join("\n"), + ); + this.name = "TerminalWorkerSocketPathError"; + this.rejected = rejected; + } +} + +/** First 16 hex chars of sha256(home) plus the worker pid: unique per home and worker. */ +export function terminalWorkerSocketId(paseoHome: string, workerPid: number): string { + const digest = createHash("sha256").update(path.resolve(paseoHome)).digest("hex"); + return `${digest.slice(0, 16)}-${workerPid}`; +} + +/** + * Picks (and creates) the socket path a worker listens on. Windows uses a named pipe; POSIX + * tries $PASEO_HOME/terminal-worker/sock, then /paseo-, then /tmp/paseo-. + * A shared root is never chmodded: an existing directory there is used only when it is a real + * directory owned by this user with mode 0700. + */ +export function resolveTerminalWorkerSocketPath(input: TerminalWorkerSocketPathInput): string { + const platform = input.platform ?? process.platform; + const id = terminalWorkerSocketId(input.paseoHome, input.workerPid); + if (platform === "win32") { + return `\\\\.\\pipe\\paseo-tw-${id}`; + } + + const uid = input.uid ?? process.getuid?.() ?? 0; + const budget = SOCKET_PATH_BYTE_BUDGET[platform] ?? DEFAULT_SOCKET_PATH_BYTE_BUDGET; + const fileName = `tw-${id}.sock`; + const homeSocketDirectory = path.join(terminalWorkerDirectory(input.paseoHome), "sock"); + const candidates: Array<{ directory: string; prepare: () => string | null }> = [ + { + directory: homeSocketDirectory, + prepare: () => { + ensurePrivateDirectory(homeSocketDirectory); + return verifyOwnerOnlyDirectory(homeSocketDirectory, uid); + }, + }, + ...[input.tmpDir ?? tmpdir(), "/tmp"].map((root) => { + const directory = path.join(root, `paseo-${uid}`); + return { + directory, + prepare: () => prepareSharedRootDirectory(directory, uid), + }; + }), + ]; + + const rejected: RejectedSocketCandidate[] = []; + const seen = new Set(); + for (const candidate of candidates) { + const socketPath = path.join(candidate.directory, fileName); + if (seen.has(socketPath)) continue; + seen.add(socketPath); + const byteLength = Buffer.byteLength(socketPath, "utf8"); + if (byteLength > budget) { + rejected.push({ path: socketPath, byteLength, reason: `longer than ${budget} bytes` }); + continue; + } + let problem: string | null; + try { + problem = candidate.prepare(); + } catch (error) { + problem = error instanceof Error ? error.message : String(error); + } + if (problem) { + rejected.push({ path: socketPath, byteLength, reason: problem }); + continue; + } + return socketPath; + } + throw new TerminalWorkerSocketPathError(rejected, budget); +} + +function prepareSharedRootDirectory(directory: string, uid: number): string | null { + try { + mkdirSync(directory, { mode: OWNER_ONLY_MODE }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + return verifyOwnerOnlyDirectory(directory, uid); +} + +/** Returns why the directory cannot hold the socket, or null when it is owner-only. */ +function verifyOwnerOnlyDirectory(directory: string, uid: number): string | null { + const stats = lstatSync(directory); + if (stats.isSymbolicLink()) return "is a symlink"; + if (!stats.isDirectory()) return "is not a directory"; + if (stats.uid !== uid) return `is owned by uid ${stats.uid}, not ${uid}`; + const mode = stats.mode & 0o777; + if (mode !== OWNER_ONLY_MODE) return `has mode ${mode.toString(8)}, not 700`; + return null; +} diff --git a/packages/server/src/terminal/terminal.posix.test.ts b/packages/server/src/terminal/terminal.posix.test.ts index 4452fb6c1f..474507f771 100644 --- a/packages/server/src/terminal/terminal.posix.test.ts +++ b/packages/server/src/terminal/terminal.posix.test.ts @@ -1,3 +1,5 @@ +import { readTerminalState } from "./test-utils/terminal-state.js"; +import type { TerminalState } from "@getpaseo/protocol/messages"; // POSIX-only: node-pty + POSIX shell assertions /* eslint-disable max-nested-callbacks */ import { describe, it, expect, afterEach } from "vitest"; @@ -6,7 +8,7 @@ import { buildTerminalEnvironment, createTerminal, resolveZshShellIntegrationDir, - type TerminalSession, + type LocalTerminalSession as TerminalSession, } from "./terminal.js"; import { chmodSync, @@ -28,7 +30,7 @@ if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) { process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; } -type TerminalRow = ReturnType["grid"][number]; +type TerminalRow = TerminalState["grid"][number]; function rowToText(row: TerminalRow): string { return row @@ -38,12 +40,12 @@ function rowToText(row: TerminalRow): string { } // Extract text from a single row -function getRowText(state: ReturnType, rowIndex: number): string { +function getRowText(state: TerminalState, rowIndex: number): string { return rowToText(state.grid[rowIndex]); } // Extract all visible lines as array (trimmed, empty lines included) -function getLines(state: ReturnType): string[] { +function getLines(state: TerminalState): string[] { return state.grid.map(rowToText); } @@ -55,7 +57,7 @@ async function waitForLines( ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const lines = getLines(session.getState()); + const lines = getLines(await readTerminalState(session)); let matches = true; for (let i = 0; i < expectedLines.length; i++) { if (lines[i] !== expectedLines[i]) { @@ -68,7 +70,7 @@ async function waitForLines( } await new Promise((resolve) => setTimeout(resolve, 50)); } - const actual = getLines(session.getState()).slice(0, expectedLines.length); + const actual = getLines(await readTerminalState(session)).slice(0, expectedLines.length); throw new Error( `Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, ); @@ -76,12 +78,12 @@ async function waitForLines( async function waitForState( session: TerminalSession, - predicate: (state: ReturnType) => boolean, + predicate: (state: TerminalState) => boolean, timeoutMs = 5000, -): Promise> { +): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const state = session.getState(); + const state = await readTerminalState(session); if (predicate(state)) { return state; } @@ -232,19 +234,19 @@ function isOsc11OkLine(line: string): boolean { return line.startsWith("OSC11_OK:"); } -function hasDaOkLine(state: ReturnType): boolean { +function hasDaOkLine(state: TerminalState): boolean { return getLines(state).some(isDaOkLine); } -function hasDsrOkLine(state: ReturnType): boolean { +function hasDsrOkLine(state: TerminalState): boolean { return getLines(state).some(isDsrOkLine); } -function hasOsc11OkLine(state: ReturnType): boolean { +function hasOsc11OkLine(state: TerminalState): boolean { return getLines(state).some(isOsc11OkLine); } -function lastNonEmptyLineIsPrompt(state: ReturnType): boolean { +function lastNonEmptyLineIsPrompt(state: TerminalState): boolean { const last = getLines(state) .toReversed() @@ -322,7 +324,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { // Line 2: "$" await waitForLines(session, ["$ echo hello", "hello", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); expect(getRowText(state, 0)).toBe("$ echo hello"); expect(getRowText(state, 1)).toBe("hello"); expect(getRowText(state, 2)).toBe("$"); @@ -346,7 +348,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: "echo second\r" }); await waitForLines(session, ["$ echo first", "first", "$ echo second", "second", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); expect(getRowText(state, 0)).toBe("$ echo first"); expect(getRowText(state, 1)).toBe("first"); expect(getRowText(state, 2)).toBe("$ echo second"); @@ -370,7 +372,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ pwd", "/tmp", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); expect(getRowText(state, 0)).toBe("$ pwd"); expect(getRowText(state, 1)).toBe("/tmp"); expect(getRowText(state, 2)).toBe("$"); @@ -431,7 +433,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { expect(seenTitles).toContain("npm run dev"); expect(session.getTitle()).toBe("npm run dev"); - expect(session.getState().title).toBe("npm run dev"); + expect(session.getTitle()).toBe("npm run dev"); unsubscribeTitle(); }); @@ -457,7 +459,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { expect(seenTitles).toContain("Build Log"); expect(session.getTitle()).toBe("Build Log"); - expect(session.getState().title).toBe("Build Log"); + expect(session.getTitle()).toBe("Build Log"); unsubscribeTitle(); }); @@ -478,7 +480,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await new Promise((resolve) => setTimeout(resolve, 150)); expect(session.getTitle()).toBe("typecheck"); - expect(session.getState().title).toBe("typecheck"); + expect(session.getTitle()).toBe("typecheck"); }); it("emits command completion from VS Code OSC 633 without visible output", async () => { @@ -507,7 +509,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForState(session, () => commandCompletions.length === 1); expect(commandCompletions).toEqual([7]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;7"); unsubscribeCommandFinished(); }); @@ -541,7 +543,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForState(session, () => commandCompletions.length === 1); expect(commandCompletions).toEqual([3]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;garbage"); unsubscribeCommandFinished(); }); @@ -696,7 +698,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForState(session, () => commandCompletions.includes(1)); expect(commandCompletions).toEqual([1]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;1"); unsubscribeCommandFinished(); }); @@ -720,7 +722,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; expect(outputRow[0].char).toBe("R"); @@ -750,7 +752,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ printf '\\033[38;5;208mORG\\033[0m'", "ORG$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; // Check O cell @@ -776,7 +778,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; // Check R cell @@ -805,7 +807,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; expect(outputRow[0].char).toBe("B"); @@ -815,7 +817,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { }); describe("subscribe", () => { - it("receives a snapshot on initial subscription", async () => { + it("receives readiness on initial subscription", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -831,7 +833,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await new Promise((resolve) => setTimeout(resolve, 100)); expect(messages.length).toBeGreaterThan(0); - expect(messages[0].type).toBe("snapshot"); + expect(messages[0].type).toBe("snapshotReady"); unsubscribe(); }); @@ -861,12 +863,14 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$ echo test", "test", "$"]); expect(messages.some((message) => message.type === "output")).toBe(true); - expect(messages.some((message) => message.type === "snapshot")).toBe(false); + expect( + messages.every((message) => message.type === "output" || message.type === "titleChange"), + ).toBe(true); unsubscribe(); }); - it("does not emit snapshot messages for resize-only updates", async () => { + it("keeps resize updates in the live observation lifecycle", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -889,13 +893,15 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "resize", rows: 30, cols: 100 }); await new Promise((resolve) => setTimeout(resolve, 200)); - expect(messages.some((message) => message.type === "snapshot")).toBe(false); + expect( + messages.every((message) => message.type === "output" || message.type === "titleChange"), + ).toBe(true); expect(session.getSize()).toEqual({ rows: 30, cols: 100 }); unsubscribe(); }); - it("emits output only after getState reflects the new data", async () => { + it("emits output only after text capture reflects the new data", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -912,7 +918,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { return; } unsubscribe(); - const stateText = getLines(session.getState()).join("\n"); + const stateText = session.captureLines().lines.join("\n"); resolve(stateText.includes("state-after-output")); }); }); @@ -965,7 +971,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` }); await waitForState(session, hasDaOkLine); - const ack = getLines(session.getState()).find(isDaOkLine) ?? ""; + const ack = getLines(await readTerminalState(session)).find(isDaOkLine) ?? ""; expect(ack).toMatch(/^DA_OK:\[\?[\d;]+c$/); }); @@ -1003,7 +1009,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: `${process.execPath} ${helperPath} cursor\r` }); await waitForState(session, hasDsrOkLine); - const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + const ack = getLines(await readTerminalState(session)).find(isDsrOkLine) ?? ""; expect(ack).toMatch(/^DSR_OK:\d+:\d+$/); }); @@ -1023,7 +1029,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: `${process.execPath} ${helperPath} private-cursor\r` }); await waitForState(session, hasDsrOkLine); - const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + const ack = getLines(await readTerminalState(session)).find(isDsrOkLine) ?? ""; expect(ack).toMatch(/^DSR_OK:\d+:\d+$/); }); @@ -1043,7 +1049,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: `${process.execPath} ${helperPath} status\r` }); await waitForState(session, hasDsrOkLine); - const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + const ack = getLines(await readTerminalState(session)).find(isDsrOkLine) ?? ""; expect(ack).toBe("DSR_OK:status"); }); @@ -1063,7 +1069,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` }); await waitForState(session, hasOsc11OkLine); - const ack = getLines(session.getState()).find(isOsc11OkLine) ?? ""; + const ack = getLines(await readTerminalState(session)).find(isOsc11OkLine) ?? ""; expect(ack).toBe("OSC11_OK:ESC]11;rgb:0b0b/0b0b/0b0bESC\\"); }); }); @@ -1097,7 +1103,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { unsubscribe(); }); - it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => { + it("announces readiness for a fresh replay instead of replaying raw live output", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -1121,26 +1127,24 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { "$", ]); - let snapshotText = ""; - const unsubscribe = session.subscribe((message) => { - if (message.type !== "snapshot") { - return; - } - snapshotText = [...message.state.scrollback, ...message.state.grid] - .map(rowToText) - .join("\n"); + let ready!: () => void; + const readiness = new Promise((resolve) => { + ready = resolve; }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(snapshotText).toContain("before-detach"); - expect(snapshotText).toContain("after-detach"); + const unsubscribe = session.subscribe((message) => { + if (message.type === "snapshotReady") ready(); + }); + await readiness; + const state = await readTerminalState(session); + const text = [...state.scrollback, ...state.grid].map(rowToText).join("\n"); + expect(text).toContain("before-detach"); + expect(text).toContain("after-detach"); unsubscribe(); }); }); - describe("getState", () => { - it("returns current terminal state with grid", async () => { + describe("ANSI replay observation", () => { + it("reconstructs current terminal state from ANSI", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -1152,7 +1156,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { }), ); - const state = session.getState(); + const state = await readTerminalState(session); expect(state.rows).toBe(24); expect(state.cols).toBe(80); @@ -1179,19 +1183,16 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$"]); session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" }); - const state = await waitForState( - session, - (current) => - current.cursor.style === "block" && - current.cursor.blink === false && - current.cursor.hidden === true, + const deadline = Date.now() + 5000; + while ( + !new TextDecoder().decode(session.serializeReplay().data).includes("\x1b[?25l\x1b[2 q") + ) { + if (Date.now() > deadline) throw new Error("cursor presentation did not reach replay"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(new TextDecoder().decode(session.serializeReplay().data)).toContain( + "\x1b[?25l\x1b[2 q", ); - - expect(state.cursor).toMatchObject({ - style: "block", - blink: false, - hidden: true, - }); }); it("grid cells have char and color attributes", async () => { @@ -1206,7 +1207,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { await waitForLines(session, ["$"]); - const state = session.getState(); + const state = await readTerminalState(session); // First cell should be "$" expect(state.grid[0][0].char).toBe("$"); expect(state.grid[0][0]).toHaveProperty("fg"); @@ -1238,7 +1239,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { // The visible area will show something like: 17, 18, 19, 20, $ await waitForLines(session, ["17", "18", "19", "20", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); // Scrollback should contain the earlier output expect(state.scrollback.length).toBeGreaterThan(0); @@ -1269,7 +1270,7 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => { session.kill(); // Should not throw when trying to get state after kill - const state = session.getState(); + const state = await readTerminalState(session); expect(state).toBeDefined(); }); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 751fb1b33a..3d1fcca545 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1,3 +1,7 @@ +import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; +import { createTerminalSerializer } from "./terminal-serializer.js"; +import { readTerminalState } from "./test-utils/terminal-state.js"; +import type { TerminalState } from "@getpaseo/protocol/messages"; import { describe, it, expect, afterEach, vi } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { @@ -10,7 +14,7 @@ import { humanizeProcessTitle, normalizeProcessTitle, resolveZshShellIntegrationDir, - type TerminalSession, + type LocalTerminalSession as TerminalSession, } from "./terminal.js"; import { chmodSync, @@ -26,7 +30,6 @@ import { import { spawnSync } from "node:child_process"; import * as pty from "node-pty"; import xterm from "@xterm/headless"; -import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; @@ -34,7 +37,7 @@ import { stripVTControlCharacters } from "node:util"; const hasZsh = existsSync("/bin/zsh"); -type TerminalRow = ReturnType["grid"][number]; +type TerminalRow = TerminalState["grid"][number]; function rowToText(row: TerminalRow): string { return row @@ -44,12 +47,12 @@ function rowToText(row: TerminalRow): string { } // Extract text from a single row -function getRowText(state: ReturnType, rowIndex: number): string { +function getRowText(state: TerminalState, rowIndex: number): string { return rowToText(state.grid[rowIndex]); } // Extract all visible lines as array (trimmed, empty lines included) -function getLines(state: ReturnType): string[] { +function getLines(state: TerminalState): string[] { return state.grid.map(rowToText); } @@ -65,7 +68,7 @@ async function waitForLines( ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const lines = getLines(session.getState()); + const lines = getLines(await readTerminalState(session)); let matches = true; for (let i = 0; i < expectedLines.length; i++) { if (lines[i] !== expectedLines[i]) { @@ -78,7 +81,7 @@ async function waitForLines( } await new Promise((resolve) => setTimeout(resolve, 50)); } - const actual = getLines(session.getState()).slice(0, expectedLines.length); + const actual = getLines(await readTerminalState(session)).slice(0, expectedLines.length); throw new Error( `Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, ); @@ -86,12 +89,12 @@ async function waitForLines( async function waitForState( session: TerminalSession, - predicate: (state: ReturnType) => boolean, + predicate: (state: TerminalState) => boolean, timeoutMs = 5000, -): Promise> { +): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - const state = session.getState(); + const state = await readTerminalState(session); if (predicate(state)) { return state; } @@ -407,7 +410,12 @@ describe("createTerminal", () => { cwd: realpathSync(tmpdir()), cols: 500, command: process.execPath, - args: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", nastyPrompt], + // Keep the PTY alive until the screen assertion; exit disposes its parser. + args: [ + "-e", + "process.stdout.write(JSON.stringify(process.argv.slice(1))); setInterval(() => {}, 100000)", + nastyPrompt, + ], }), ); @@ -473,7 +481,7 @@ describe("createTerminal", () => { }), ); - const state = session.getState(); + const state = await readTerminalState(session); expect(state.rows).toBe(24); expect(state.cols).toBe(80); }); @@ -493,12 +501,12 @@ describe("createTerminal", () => { }), ); - const state = session.getState(); + const state = await readTerminalState(session); expect(state.rows).toBe(40); expect(state.cols).toBe(120); }); - it("reports per-row soft-wrap flags only when wrap flags are requested", async () => { + it("preserves soft-wrap boundaries through public replay", async () => { const session = trackSession( await createTerminal({ workspaceId: "ws-test", @@ -513,21 +521,15 @@ describe("createTerminal", () => { const start = Date.now(); while (Date.now() - start < 5000) { - if (rowToText(session.getState().grid[2]).startsWith("A")) { + if (rowToText((await readTerminalState(session)).grid[2]).startsWith("A")) { break; } await new Promise((resolve) => setTimeout(resolve, 50)); } // Rows 0 and 1 continue onto the next row; row 2 is the end of the logical line. - const withFlags = session.getState({ includeWrapFlags: true }); + const withFlags = await readTerminalState(session); expect(withFlags.gridWrapped?.slice(0, 3)).toEqual([true, true, false]); - - // Back-compat gate: without the capability the daemon must not attach the new - // fields, so an old strict-schema client still parses the snapshot. - const withoutFlags = session.getState(); - expect(withoutFlags.gridWrapped).toBeUndefined(); - expect(withoutFlags.scrollbackWrapped).toBeUndefined(); }); it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => { @@ -555,9 +557,7 @@ describe("createTerminal", () => { // row back the way a selection copy would. const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true }); try { - await new Promise((resolve) => - restored.write(renderTerminalSnapshotToAnsi(state), resolve), - ); + await new Promise((resolve) => restored.write(session.serializeReplay().data, resolve)); expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text); } finally { restored.dispose(); @@ -568,6 +568,71 @@ describe("createTerminal", () => { expect(state.grid[0][1].char).toBe(""); }); + it("reports the cursor column after an emoji cluster in DSR replies", async () => { + // Programs that measure the cursor (prompts, TUIs) must see the same + // column the client renders: an emoji occupies two cells. + const program = ` + process.stdin.setRawMode(true); + process.stdin.once("data", (reply) => { + process.stdout.write("\\r\\nREPLY " + JSON.stringify(reply.toString()).slice(2, -1)); + }); + process.stdout.write("A😀\\x1b[6n"); + setInterval(() => {}, 100000); + `; + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + cols: 40, + rows: 5, + command: process.execPath, + args: ["-e", program], + }), + ); + + const state = await waitForState(session, (current) => + getRowText(current, 1).startsWith("REPLY"), + ); + + expect(getRowText(state, 1)).toBe("REPLY u001b[1;4R"); + }); + + it("replays emoji clusters at their columns with the cursor after them", async () => { + const text = "A😀B❤️C👋🏽D👨‍👩‍👧E"; + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + cols: 40, + rows: 5, + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`, + ], + }), + ); + + const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "E")); + + expect(state.grid[0].slice(0, 13).map((cell) => cell.char)).toEqual([ + "A", + "😀", + "", + "B", + "❤️", + "", + "C", + "👋🏽", + "", + "D", + "👨‍👩‍👧", + "", + "E", + ]); + expect(state.cursor).toEqual({ row: 0, col: 13 }); + }); + it("captures exit diagnostics from the terminal buffer", async () => { const session = trackSession( await createTerminal({ @@ -615,11 +680,8 @@ describe("createTerminal", () => { // Poll the parsed buffer until the final line lands, so the exit summary // (which reads the headless buffer) can't race the async xterm parse. - const finalLineParsed = (): boolean => { - const state = session.getState(); - const rowText = (row: { char: string }[]): string => row.map((cell) => cell.char).join(""); - return [...state.scrollback, ...state.grid].some((row) => rowText(row).includes("line-2999")); - }; + const finalLineParsed = (): boolean => + session.captureLines({ start: -12 }).lines.some((line) => line.includes("line-2999")); const deadline = Date.now() + 10000; while (!finalLineParsed()) { if (Date.now() > deadline) { @@ -678,7 +740,7 @@ describe("createTerminal", () => { ); await waitForLines(session, ["paseo-launch-ok"]); - expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok"); }, ); @@ -721,7 +783,7 @@ describe("createTerminal", () => { ); await waitForLines(session, ["paseo-launch-ok"]); - expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok"); }, ); @@ -743,7 +805,7 @@ describe("createTerminal", () => { ); await waitForLines(session, ["paseo-launch-ok"]); - expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok"); }); it("leaves the command check to the terminal itself on Windows", async () => { @@ -807,7 +869,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => { // Line 2: "$" await waitForLines(session, ["$ echo hello", "hello", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); expect(getRowText(state, 0)).toBe("$ echo hello"); expect(getRowText(state, 1)).toBe("hello"); expect(getRowText(state, 2)).toBe("$"); @@ -829,7 +891,7 @@ describe.skipIf(isPlatform("win32"))("send input", () => { await waitForLines(session, ["$ pwd", "/tmp", "$"]); - const state = session.getState(); + const state = await readTerminalState(session); expect(getRowText(state, 0)).toBe("$ pwd"); expect(getRowText(state, 1)).toBe("/tmp"); expect(getRowText(state, 2)).toBe("$"); @@ -890,7 +952,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { expect(seenTitles).toContain("npm run dev"); expect(session.getTitle()).toBe("npm run dev"); - expect(session.getState().title).toBe("npm run dev"); unsubscribeTitle(); }); @@ -916,7 +977,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { expect(seenTitles).toContain("Build Log"); expect(session.getTitle()).toBe("Build Log"); - expect(session.getState().title).toBe("Build Log"); unsubscribeTitle(); }); @@ -937,7 +997,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { await new Promise((resolve) => setTimeout(resolve, 150)); expect(session.getTitle()).toBe("typecheck"); - expect(session.getState().title).toBe("typecheck"); }); it("emits command completion from VS Code OSC 633 without visible output", async () => { @@ -966,7 +1025,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { await waitForState(session, () => commandCompletions.length === 1); expect(commandCompletions).toEqual([7]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;7"); unsubscribeCommandFinished(); }); @@ -1000,7 +1059,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { await waitForState(session, () => commandCompletions.length === 1); expect(commandCompletions).toEqual([3]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;garbage"); unsubscribeCommandFinished(); }); @@ -1155,7 +1214,7 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { await waitForState(session, () => commandCompletions.includes(1)); expect(commandCompletions).toEqual([1]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); + expect(getLines(await readTerminalState(session)).join("\n")).not.toContain("633;D;1"); unsubscribeCommandFinished(); }); @@ -1189,7 +1248,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { expect(seenTitles).toEqual(["Build Log", "User terminal"]); expect(session.getTitle()).toBe("User terminal"); - expect(session.getState().title).toBe("User terminal"); unsubscribeTitle(); }); @@ -1219,7 +1277,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { expect(seenTitles).toEqual(["Build Log", "User terminal"]); expect(session.getTitle()).toBe("User terminal"); - expect(session.getState().title).toBe("User terminal"); unsubscribeTitle(); }); @@ -1248,7 +1305,6 @@ describe.skipIf(isPlatform("win32"))("terminal title", () => { expect(seenTitles).toEqual(["Build Log", "User terminal"]); expect(session.getTitle()).toBe("User terminal"); - expect(session.getState().title).toBe("User terminal"); unsubscribeTitle(); }); @@ -1272,7 +1328,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => { await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; expect(outputRow[0].char).toBe("R"); @@ -1302,7 +1358,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => { await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; // Check R cell @@ -1331,7 +1387,7 @@ describe.skipIf(isPlatform("win32"))("colors", () => { await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); - const state = session.getState(); + const state = await readTerminalState(session); const outputRow = state.grid[1]; expect(outputRow[0].char).toBe("B"); @@ -1355,7 +1411,7 @@ describe("resize", () => { await new Promise((resolve) => setTimeout(resolve, 100)); - const state = session.getState(); + const state = await readTerminalState(session); expect(state.rows).toBe(40); expect(state.cols).toBe(120); }); @@ -1373,7 +1429,7 @@ describe("resize", () => { session.send({ type: "resize", rows: 10, cols: 40 }); await new Promise((resolve) => setTimeout(resolve, 100)); - const state = session.getState(); + const state = await readTerminalState(session); expect(state.grid.length).toBe(10); expect(state.grid[0].length).toBe(40); }); @@ -1552,7 +1608,7 @@ it.runIf(isPlatform("linux", "darwin"))( env: { PATH: "bin" }, }); await waitForLines(session, ["paseo-launch-ok"]); - expect(getLines(session.getState())[0]).toBe("paseo-launch-ok"); + expect(getLines(await readTerminalState(session))[0]).toBe("paseo-launch-ok"); expect(session.cwd).toBe(realpathSync(projectDir)); expect(session.getExitInfo()).toBeNull(); } finally { @@ -1574,3 +1630,309 @@ it.runIf(isPlatform("linux", "darwin"))( ).rejects.toThrow(" node : command not found"); }, ); + +it.each([false, true])( + "serializes styled Unicode, cursor presentation and modes directly (alternate=%s)", + async (alternate) => { + // Exercise the serializer's input model directly. ConPTY renders program + // output: it consumes input modes and need not publish a hidden cursor move. + const terminal = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + const serialize = createTerminalSerializer(terminal); + const inputModes = new TerminalInputModeTracker(); + const content = + Array.from({ length: 8 }, (_, i) => "normal-" + i + "\r\n").join("") + + (alternate ? "\x1b[?1049h\x1b[H" : "") + + "\x1b[38;2;255;128;64;4m界é🙂\x1b[0m\r\nVIEW-READY\x1b[?1h\x1b[?2004h\x1b[>1u\x1b[?9001h\x1b[?7l\x1b[?1000h\x1b[5 q\x1b[?25l"; + const output = content + "\x1b[2;7H"; + await new Promise((resolve) => terminal.write(output, resolve)); + inputModes.feed(output); + const data = serialize(); + const ansi = new TextDecoder().decode(data); + expect(ansi).toContain("\x1b[?25l\x1b[5 q"); + expect(ansi).not.toContain("NaN"); + expect(terminal.buffer.normal.baseY).toBeGreaterThan(0); + const receiver = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => receiver.write(data, resolve)); + await new Promise((resolve) => receiver.write(inputModes.getPreamble(), resolve)); + expect(receiver.buffer.active.type).toBe(alternate ? "alternate" : "normal"); + const lines = Array.from({ length: receiver.buffer.active.length }, (_, i) => + receiver.buffer.active.getLine(i)!.translateToString(true), + ); + expect(lines.join("\n")).toContain("界é🙂"); + expect(lines.join("\n")).toContain("VIEW-READY"); + const coloredIndex = lines.findIndex((line) => line.includes("界")); + const colored = receiver.buffer.active.getLine(coloredIndex)!.getCell(0)!; + expect(colored.getChars()).toBe("界"); + expect(colored.getFgColor()).toBe(0xff8040); + expect(colored.isUnderline()).not.toBe(0); + expect(receiver.buffer.normal.getLine(0)!.translateToString(true)).toBe("normal-0"); + expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual({ + row: 1, + col: 6, + }); + expect(receiver.modes).toMatchObject({ + applicationCursorKeysMode: true, + bracketedPasteMode: true, + wraparoundMode: false, + mouseTrackingMode: "vt200", + }); + const modes = new TerminalInputModeTracker(); + modes.feed(ansi + inputModes.getPreamble()); + expect(modes.getState()).toMatchObject({ + kittyKeyboardFlags: 1, + win32InputMode: true, + applicationCursorKeys: true, + bracketedPaste: true, + }); + } finally { + receiver.dispose(); + terminal.dispose(); + } + }, +); + +it.each([ + { name: "unset", presentation: "", style: undefined, visible: true }, + { + name: "reset to client default", + presentation: "\x1b[5 q\x1b[0 q", + style: undefined, + visible: true, + }, + { name: "unset hidden", presentation: "\x1b[?25l", style: undefined, visible: false }, + ...[1, 2, 3, 4, 5, 6].map((style) => ({ + name: `explicit style ${style}`, + presentation: `\x1b[${style} q\x1b[?25l`, + style, + visible: false, + })), +])("replays cursor presentation without replacing client defaults ($name)", async (input) => { + const session = trackSession( + await createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "cursor-presentation", + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(input.presentation + "CURSOR-READY\r\n")});setInterval(()=>{},1000)`, + ], + }), + ); + await waitForLines(session, ["CURSOR-READY"]); + const { data } = session.serializeReplay(); + const ansi = new TextDecoder().decode(data); + const receiver = new xterm.Terminal({ + cursorStyle: "bar", + cursorBlink: true, + allowProposedApi: true, + }); + const styles: number[] = []; + receiver.parser.registerCsiHandler({ intermediates: " ", final: "q" }, (params) => { + styles.push(Number(params[0])); + return false; + }); + try { + await new Promise((resolve) => receiver.write(data, resolve)); + expect(styles).toEqual(input.style === undefined ? [] : [input.style]); + expect(ansi).toContain(`\x1b[?25${input.visible ? "h" : "l"}`); + } finally { + receiver.dispose(); + } +}); + +it("captures input modes at the same committed cut as serialized bytes", async () => { + const session = trackSession( + await createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "mode-cut", + rows: 4, + cols: 80, + command: process.execPath, + args: [ + "-e", + 'process.stdin.on("data",()=>process.stdout.write("x".repeat(1024*1024)+"\x1b[?1hMODE-CUT"));setInterval(()=>{},1000)', + ], + }), + ); + const mismatches: string[] = []; + let completed!: () => void; + const output = new Promise((resolve) => { + completed = resolve; + }); + const unsubscribe = session.subscribe((message) => { + if (message.type !== "output") return; + if (session.getReplayPreamble().includes("\x1b[?1h")) { + const cut = session.serializeReplay({ scrollbackLines: 0 }); + if (!new TextDecoder().decode(cut.data).includes("\x1b[?1h")) + mismatches.push(cut.metadata.replayPreamble); + } + if (message.data.includes("MODE-CUT")) completed(); + }); + try { + session.send({ type: "input", data: "go\r" }); + await Promise.race([ + output, + delay(5000).then(() => { + throw new Error("mode cut did not complete"); + }), + ]); + expect(mismatches).toEqual([]); + } finally { + unsubscribe(); + } +}); + +it.each( + [false, true].flatMap((alternate) => [ + { + alternate, + pendingWrap: false, + position: "\x1b[3;16H", + cursor: { row: 2, col: 15 }, + after: { row: 2, col: 16 }, + }, + { + alternate, + pendingWrap: true, + position: "", + cursor: { row: 0, col: 16 }, + after: { row: 1, col: 1 }, + }, + ]), +)( + "replays a full-width row without losing cursor state ($alternate, pendingWrap=$pendingWrap)", + async (input) => { + const source = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + const serialize = createTerminalSerializer(source); + const receiver = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => + source.write( + (input.alternate ? "\x1b[?1049h" : "") + "x".repeat(16) + input.position, + resolve, + ), + ); + expect({ row: source.buffer.active.cursorY, col: source.buffer.active.cursorX }).toEqual( + input.cursor, + ); + await new Promise((resolve) => receiver.write(serialize(), resolve)); + expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual( + input.cursor, + ); + await new Promise((resolve) => receiver.write("Z", resolve)); + expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual( + input.after, + ); + } finally { + receiver.dispose(); + source.dispose(); + } + }, +); + +it.each([false, true])( + "replays the observed PTY projection including startup modes (alternate=%s)", + async (alternate) => { + const gate = mkdtempSync(join(tmpdir(), "terminal-projection-")); + temporaryDirs.push(gate); + const release = join(gate, "output"); + const content = + Array.from({ length: 8 }, (_, i) => `normal-${i}\r\n`).join("") + + (alternate ? "\x1b[?1049h\x1b[H" : "") + + "\x1b[38;2;255;128;64;4m界é🙂\x1b[0m\r\nVIEW-READY\x1b[?1h\x1b[?2004h\x1b[>1u\x1b[?9001h\x1b[?7l\x1b[?1000h\x1b[5 q\x1b[?25l\x1b[2;7H"; + const session = trackSession( + await createTerminal({ + cwd: realpathSync(tmpdir()), + workspaceId: "projection", + rows: 4, + cols: 16, + command: process.execPath, + args: [ + "-e", + `const fs=require("node:fs");let sent=false; + function emit(){if(!sent&&fs.existsSync(${JSON.stringify(release)})){sent=true;watcher.close();process.stdout.write(${JSON.stringify(content)});}} + const watcher=fs.watch(${JSON.stringify(gate)},emit);emit();setInterval(()=>{},1000);`, + ], + }), + ); + const bootstrap = session.serializeReplay(); + const output: string[] = []; + const unsubscribe = session.subscribe((message) => { + if (message.type === "output") output.push(message.data); + }); + const reference = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + const receiver = new xterm.Terminal({ + rows: 4, + cols: 16, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => reference.write(bootstrap.data, resolve)); + await new Promise((resolve) => + reference.write(bootstrap.metadata.replayPreamble, resolve), + ); + writeFileSync(release, "release"); + const deadline = Date.now() + 5000; + while (!session.captureLines().lines.some((line) => line.includes("VIEW-READY"))) { + if (Date.now() > deadline) throw new Error("PTY did not render fixture output"); + await delay(20); + } + // Bootstrap and these committed output events cover precisely this source cut. + // Program bytes are not the oracle: ConPTY consumes modes and renders cells. + const replay = session.serializeReplay(); + const committed = output.join(""); + await new Promise((resolve) => reference.write(committed, resolve)); + await new Promise((resolve) => receiver.write(replay.data, resolve)); + await new Promise((resolve) => receiver.write(replay.metadata.replayPreamble, resolve)); + const text = (terminal: typeof receiver) => + Array.from({ length: terminal.buffer.active.length }, (_, i) => + terminal.buffer.active.getLine(i)!.translateToString(true).trimEnd(), + ); + expect(text(receiver)).toEqual(text(reference)); + expect(text(receiver).join("\n")).toContain("界é🙂"); + expect(text(receiver).join("\n")).toContain("VIEW-READY"); + expect(receiver.buffer.active.type).toBe(alternate ? "alternate" : "normal"); + expect({ row: receiver.buffer.active.cursorY, col: receiver.buffer.active.cursorX }).toEqual({ + row: reference.buffer.active.cursorY, + col: reference.buffer.active.cursorX, + }); + expect(receiver.modes).toEqual(reference.modes); + const expectedModes = new TerminalInputModeTracker(); + expectedModes.feed(bootstrap.metadata.replayPreamble + committed); + const actualModes = new TerminalInputModeTracker(); + actualModes.feed(new TextDecoder().decode(replay.data) + replay.metadata.replayPreamble); + expect(actualModes.getState()).toEqual(expectedModes.getState()); + } finally { + unsubscribe(); + receiver.dispose(); + reference.dispose(); + } + }, +); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 24f243b6ea..b09bc8bbf6 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -1,5 +1,5 @@ import * as pty from "node-pty"; -import xterm, { type Terminal as TerminalType } from "@xterm/headless"; +import type { Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; import { accessSync, @@ -15,19 +15,29 @@ import { basename, delimiter, dirname, extname, join, resolve as resolvePath } f import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { createExternalProcessEnv } from "../server/paseo-env.js"; +import { + resolveExternalProcessPath, + resolvePaseoCliBinDir, + resolvePaseoCliExecutablePath, +} from "./paseo-cli-path.js"; import { writePrivateFileAtomicSync } from "../server/private-files.js"; import { findExecutable, findExecutableWithoutProbing, } from "../executable-resolution/executable-resolution.js"; -import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; +import { createHeadlessTerminal } from "./headless-terminal.js"; +import { createTerminalSerializer } from "./terminal-serializer.js"; +import { + captureTerminalLines, + type CaptureTerminalLinesOptions, + type CaptureTerminalLinesResult, +} from "./terminal-capture.js"; +import type { TerminalReplayOptions, TerminalReplayMetadata } from "./terminal-replay.js"; import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js"; import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; -const { Terminal } = xterm; const require = createRequire(import.meta.url); -const PASEO_CLI_BIN_ENTRY = "@getpaseo/cli/bin/paseo"; let nodePtySpawnHelperChecked = false; const TERMINAL_TITLE_DEBOUNCE_MS = 150; const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12; @@ -62,26 +72,6 @@ export interface TerminalCommandFinishedInfo { exitCode: number | null; } -export interface TerminalStateSnapshot { - state: TerminalState; - revision: number; - // Input-mode replay preamble at snapshot time. Populated by the terminal - // worker so the daemon main loop doesn't have to re-derive it from output. - replayPreamble?: string; -} - -export interface TerminalStateSnapshotOptions { - scrollbackLines?: number; - // Include per-row soft-wrap flags (gridWrapped/scrollbackWrapped) so the client - // can reflow restored content on resize. Gated on a client capability, so old - // clients never receive the extra fields. - includeWrapFlags?: boolean; -} - -export interface TerminalSubscribeOptions { - initialSnapshot?: "state" | "ready"; -} - export type ClientMessage = | { type: "input"; data: string } | { type: "resize"; rows: number; cols: number } @@ -89,7 +79,6 @@ export type ClientMessage = export type ServerMessage = | { type: "output"; data: string; revision?: number } - | { type: "snapshot"; state: TerminalState; revision?: number } | { type: "snapshotReady"; revision?: number; replayPreamble?: string } | { type: "titleChange"; title?: string }; @@ -104,14 +93,13 @@ export interface TerminalSession { cwd: string; workspaceId: string; send(msg: ClientMessage): void; - subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void; + subscribe(listener: (msg: ServerMessage) => void): () => void; onExit(listener: (info: TerminalExitInfo) => void): () => void; onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void; onTitleChange(listener: (title?: string) => void): () => void; onActivityChange(listener: (transition: TerminalActivityTransition) => void): () => void; getSize(): { rows: number; cols: number }; - getState(): TerminalState; - getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot; + hasOutput(): boolean; getReplayPreamble(): string; getTitle(): string | undefined; getActivity(): TerminalActivity | null; @@ -123,6 +111,16 @@ export interface TerminalSession { killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise; } +/** A session that owns its PTY in this process, so it knows the program's pid. */ +export interface LocalTerminalSession extends TerminalSession { + readonly pid: number; + serializeReplay(options?: TerminalReplayOptions): { + metadata: TerminalReplayMetadata; + data: Uint8Array; + }; + captureLines(options?: CaptureTerminalLinesOptions): CaptureTerminalLinesResult; +} + function parseCommandFinishedOsc(data: string): TerminalCommandFinishedInfo | null { // OSC 633 is terminal control traffic, but a foreground command can still // print arbitrary control bytes. Keep this boundary to the exact VS Code @@ -153,6 +151,11 @@ export interface CreateTerminalOptions { title?: string; command?: string; args?: string[]; + // Environment the terminal starts from instead of this process's own. A detached terminal + // worker outlives the daemon that launched it, so each create carries the controlling + // daemon's environment and its resolved Paseo CLI path. + baseEnv?: Record; + paseoCliPath?: string | null; } function toTerminalActivity(snapshot: { @@ -181,6 +184,7 @@ function isTerminalActivityInterruptInput(data: string): boolean { interface BuildTerminalEnvironmentInput { shell: string; env: Record; + baseEnv?: Record; zshShellIntegrationDir?: string; paseoCliBinDir?: string | null; paseoHookCliPath?: string | null; @@ -410,84 +414,12 @@ export async function resolveTerminalSpawnCommand( return { command: resolved, args }; } +export { resolvePaseoCliBinDir, resolvePaseoCliExecutablePath }; + export function resolveZshShellIntegrationDir(): string { return fileURLToPath(new URL("./shell-integration/zsh", import.meta.url)); } -function resolveExternalProcessPath(filePath: string): string { - return filePath.replace(/\.asar(?=[/\\]|$)/, ".asar.unpacked"); -} - -export function resolvePaseoCliBinDir(): string | null { - const cliExecutable = resolvePaseoCliExecutablePath(); - return cliExecutable ? dirname(cliExecutable) : null; -} - -export function resolvePaseoCliExecutablePath(): string | null { - const configuredCli = process.env.PASEO_CLI?.trim(); - if (configuredCli) { - return resolvePath(configuredCli); - } - - const cliEntrypoint = resolvePaseoCliBinEntrypoint(); - if (!cliEntrypoint) { - return null; - } - - const externalCliEntrypoint = resolveExternalProcessPath(cliEntrypoint); - const npmBinDir = findNpmBinDir(dirname(externalCliEntrypoint)); - if (npmBinDir) { - const shim = resolvePaseoCliShim(npmBinDir); - if (shim) { - return shim; - } - } - - return externalCliEntrypoint; -} - -function resolvePaseoCliBinEntrypoint(): string | null { - try { - return require.resolve(PASEO_CLI_BIN_ENTRY); - } catch { - return null; - } -} - -function findNpmBinDir(startPath: string): string | null { - let current = startPath; - while (true) { - const candidate = join(current, "node_modules", ".bin"); - if (hasPaseoCliShim(candidate)) { - return candidate; - } - - const parent = dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} - -function hasPaseoCliShim(binDir: string): boolean { - return resolvePaseoCliShim(binDir) !== null; -} - -function resolvePaseoCliShim(binDir: string): string | null { - for (const name of paseoCliShimNames()) { - const candidate = join(binDir, name); - if (existsSync(candidate)) { - return candidate; - } - } - return null; -} - -function paseoCliShimNames(): string[] { - return process.platform === "win32" ? ["paseo.cmd", "paseo.exe", "paseo"] : ["paseo"]; -} - function resolveZshShellIntegrationRuntimeDir(): string { let username = "unknown"; try { @@ -517,10 +449,14 @@ function prepareZshShellIntegrationRuntimeDir(sourceDir = resolveZshShellIntegra export function buildTerminalEnvironment( input: BuildTerminalEnvironmentInput, ): Record { - const baseEnv: Record = createExternalProcessEnv(process.env, input.env, { - TERM: "xterm-256color", - TERM_PROGRAM: "kitty", - }); + const baseEnv: Record = createExternalProcessEnv( + input.baseEnv ?? process.env, + input.env, + { + TERM: "xterm-256color", + TERM_PROGRAM: "kitty", + }, + ); const envWithAgentHooks = prependPaseoCliToPath( baseEnv, input.paseoCliBinDir === undefined ? resolvePaseoCliBinDir() : input.paseoCliBinDir, @@ -556,6 +492,23 @@ function injectPaseoHookCli( }; } +// The environment and CLI path a terminal starts from: the caller's when it sends them (the +// daemon controlling a detached worker does), this process's otherwise. +function resolveTerminalEnvironmentSource( + input: Pick, +): Pick { + const source: Pick< + BuildTerminalEnvironmentInput, + "baseEnv" | "paseoCliBinDir" | "paseoHookCliPath" + > = {}; + if (input.baseEnv) source.baseEnv = input.baseEnv; + if (input.paseoCliPath !== undefined) { + source.paseoCliBinDir = input.paseoCliPath ? dirname(input.paseoCliPath) : null; + source.paseoHookCliPath = input.paseoCliPath; + } + return source; +} + function prependPaseoCliToPath( env: Record, cliBinDir: string | null, @@ -581,179 +534,6 @@ function prependPathEntry(currentPath: string, entry: string): string { return [entry, ...entries].join(delimiter); } -function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell { - const buffer = terminal.buffer.active; - const line = buffer.getLine(row); - if (!line) { - return { char: " ", fg: undefined, bg: undefined }; - } - - const cell = line.getCell(col); - if (!cell) { - return { char: " ", fg: undefined, bg: undefined }; - } - - // Color modes from xterm.js: 0=DEFAULT, 1=16 colors (ANSI), 2=256 colors, 3=RGB - // getFgColorMode() returns packed value with mode in upper byte (e.g. 0x01000000 for mode 1) - const fgModeRaw = cell.getFgColorMode(); - const bgModeRaw = cell.getBgColorMode(); - const fgMode = fgModeRaw >> 24; - const bgMode = bgModeRaw >> 24; - - // Only return color if not default (mode 0) - const fg = fgMode !== 0 ? cell.getFgColor() : undefined; - const bg = bgMode !== 0 ? cell.getBgColor() : undefined; - - return { - // Wide chars (CJK) span two cells; xterm returns "" for the zero-width - // continuation. Collapsing it to " " injects a real space on restore. - char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", - fg, - bg, - fgMode: fgMode !== 0 ? fgMode : undefined, - bgMode: bgMode !== 0 ? bgMode : undefined, - bold: cell.isBold() !== 0, - italic: cell.isItalic() !== 0, - underline: cell.isUnderline() !== 0, - dim: cell.isDim() !== 0, - inverse: cell.isInverse() !== 0, - strikethrough: cell.isStrikethrough() !== 0, - }; -} - -function extractGrid(terminal: TerminalType): TerminalCell[][] { - const grid: TerminalCell[][] = []; - const buffer = terminal.buffer.active; - // Visible viewport starts at baseY - const baseY = buffer.baseY; - - for (let row = 0; row < terminal.rows; row++) { - const rowCells: TerminalCell[] = []; - for (let col = 0; col < terminal.cols; col++) { - rowCells.push(extractCell(terminal, baseY + row, col)); - } - grid.push(rowCells); - } - - return grid; -} - -function extractScrollback( - terminal: TerminalType, - options?: { scrollbackLines?: number }, -): TerminalCell[][] { - const scrollback: TerminalCell[][] = []; - const buffer = terminal.buffer.active; - // baseY is the first row of the visible viewport (0-indexed) - // Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible - const scrollbackLines = buffer.baseY; - const startRow = - typeof options?.scrollbackLines === "number" - ? Math.max(0, scrollbackLines - options.scrollbackLines) - : 0; - - for (let row = startRow; row < scrollbackLines; row++) { - const rowCells: TerminalCell[] = []; - const line = buffer.getLine(row); - for (let col = 0; col < terminal.cols; col++) { - if (line) { - const cell = line.getCell(col); - if (cell) { - const fgModeRaw = cell.getFgColorMode(); - const bgModeRaw = cell.getBgColorMode(); - const fgMode = fgModeRaw >> 24; - const bgMode = bgModeRaw >> 24; - const fg = fgMode !== 0 ? cell.getFgColor() : undefined; - const bg = bgMode !== 0 ? cell.getBgColor() : undefined; - rowCells.push({ - // Wide chars (CJK) span two cells; xterm returns "" for the zero-width - // continuation. Collapsing it to " " injects a real space on restore. - char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", - fg, - bg, - fgMode: fgMode !== 0 ? fgMode : undefined, - bgMode: bgMode !== 0 ? bgMode : undefined, - bold: cell.isBold() !== 0, - italic: cell.isItalic() !== 0, - underline: cell.isUnderline() !== 0, - dim: cell.isDim() !== 0, - inverse: cell.isInverse() !== 0, - strikethrough: cell.isStrikethrough() !== 0, - }); - } else { - rowCells.push({ char: " ", fg: undefined, bg: undefined }); - } - } else { - rowCells.push({ char: " ", fg: undefined, bg: undefined }); - } - } - scrollback.push(rowCells); - } - - return scrollback; -} - -// xterm marks a line `isWrapped` when it is a continuation of the PREVIOUS line. -// The snapshot carries the inverse, tmux-style flag — "this row continues onto the -// next row" — so the client can rejoin and reflow logical lines. So row y's flag is -// whether line y+1 is a wrapped continuation. -function lineContinuesToNext(terminal: TerminalType, absoluteRow: number): boolean { - return terminal.buffer.active.getLine(absoluteRow + 1)?.isWrapped === true; -} - -function extractGridWrapped(terminal: TerminalType): boolean[] { - const baseY = terminal.buffer.active.baseY; - const wrapped: boolean[] = []; - for (let row = 0; row < terminal.rows; row++) { - wrapped.push(lineContinuesToNext(terminal, baseY + row)); - } - return wrapped; -} - -function extractScrollbackWrapped( - terminal: TerminalType, - options?: { scrollbackLines?: number }, -): boolean[] { - const scrollbackLines = terminal.buffer.active.baseY; - const startRow = - typeof options?.scrollbackLines === "number" - ? Math.max(0, scrollbackLines - options.scrollbackLines) - : 0; - const wrapped: boolean[] = []; - for (let row = startRow; row < scrollbackLines; row++) { - wrapped.push(lineContinuesToNext(terminal, row)); - } - return wrapped; -} - -function extractCursorState(terminal: TerminalType): TerminalState["cursor"] { - const coreService = (terminal as unknown as { _core?: { coreService?: Record } }) - ._core?.coreService as - | { - decPrivateModes?: { cursorStyle?: unknown; cursorBlink?: unknown }; - isCursorHidden?: unknown; - } - | undefined; - const cursorStyle = coreService?.decPrivateModes?.cursorStyle; - const normalizedCursorStyle = - cursorStyle === "block" || cursorStyle === "underline" || cursorStyle === "bar" - ? cursorStyle - : undefined; - const cursorBlink = - typeof coreService?.decPrivateModes?.cursorBlink === "boolean" - ? coreService.decPrivateModes.cursorBlink - : undefined; - const hidden = Boolean(coreService?.isCursorHidden); - - return { - row: terminal.buffer.active.cursorY, - col: terminal.buffer.active.cursorX, - ...(hidden ? { hidden: true } : {}), - ...(normalizedCursorStyle ? { style: normalizedCursorStyle } : {}), - ...(typeof cursorBlink === "boolean" ? { blink: cursorBlink } : {}), - }; -} - function normalizeProcessToken(token: string): string { if (token.length === 0) { return token; @@ -967,7 +747,9 @@ export async function assertTerminalCommandCanStart( } } -export async function createTerminal(options: CreateTerminalOptions): Promise { +export async function createTerminal( + options: CreateTerminalOptions, +): Promise { const { cwd, workspaceId, @@ -980,8 +762,10 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void>(); @@ -1012,13 +796,9 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void>(); let titleChangeSubscription: { dispose(): void } | null = null; - // Create xterm.js headless terminal - const terminal = new Terminal({ - rows, - cols, - scrollback: 1000, - allowProposedApi: true, - }); + const terminal = createHeadlessTerminal({ rows, cols, scrollback: 10000 }); + + const serialize = createTerminalSerializer(terminal); ensureNodePtySpawnHelperExecutableForCurrentPlatform(); @@ -1033,6 +813,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { if (killed) return; - const inputModeUpdate = inputModeTracker.feed(data); - for (const response of inputModeUpdate.responses) { - ptyProcess.write(response); - } recentOutputChunks.push(data); recentOutputLength += data.length; // Drop whole leading chunks while the rest still covers the char limit, so @@ -1326,32 +1107,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void, - subscribeOptions?: TerminalSubscribeOptions, - ): () => void { + function subscribe(listener: (msg: ServerMessage) => void): () => void { let active = true; let snapshotDelivered = false; const queuedMessages: ServerMessage[] = []; - const initialSnapshot = subscribeOptions?.initialSnapshot ?? "state"; const subscriptionListener = (msg: ServerMessage): void => { if (!active) { return; @@ -1441,17 +1201,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { if (!disposed && active && listeners.has(subscriptionListener)) { snapshotDelivered = true; - if (initialSnapshot === "ready") { - // Carry the input-mode preamble so the snapshot-less "ready" path - // (live restore) can replay it without a separate state fetch. - listener({ - type: "snapshotReady", - revision: stateRevision, - replayPreamble: getReplayPreamble(), - }); - } else { - listener({ type: "snapshot", ...getStateSnapshot() }); - } + listener({ + type: "snapshotReady", + revision: stateRevision, + replayPreamble: getReplayPreamble(), + }); for (const message of queuedMessages.splice(0)) { listener(message); } @@ -1624,6 +1378,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise captureTerminalLines(terminal.buffer.active, captureOptions), + hasOutput: () => { + const buffer = terminal.buffer.active; + for (let row = 0; row < buffer.length; row++) { + if (buffer.getLine(row)?.translateToString(true).trim()) return true; + } + return false; + }, getReplayPreamble, getTitle, getActivity, diff --git a/packages/server/src/terminal/test-utils/terminal-state.ts b/packages/server/src/terminal/test-utils/terminal-state.ts new file mode 100644 index 0000000000..fb5f5d610a --- /dev/null +++ b/packages/server/src/terminal/test-utils/terminal-state.ts @@ -0,0 +1,50 @@ +import type { TerminalState } from "@getpaseo/protocol/messages"; +import { createHeadlessTerminal } from "../headless-terminal.js"; +import type { LocalTerminalSession } from "../terminal.js"; + +/** Observe the public ANSI replay using a real receiver; no production cell extraction API. */ +export async function readTerminalState(session: LocalTerminalSession): Promise { + const { metadata, data } = session.serializeReplay(); + const terminal = createHeadlessTerminal({ + rows: metadata.rows, + cols: metadata.cols, + scrollback: 10000, + }); + try { + await new Promise((resolve) => terminal.write(data, resolve)); + const buffer = terminal.buffer.active; + const rows = Array.from({ length: buffer.length }, (_, row) => + Array.from({ length: terminal.cols }, (_cell, col) => { + const cell = buffer.getLine(row)!.getCell(col)!; + const fgMode = cell.getFgColorMode() >> 24; + const bgMode = cell.getBgColorMode() >> 24; + return { + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", + fg: fgMode ? cell.getFgColor() : undefined, + bg: bgMode ? cell.getBgColor() : undefined, + fgMode: fgMode || undefined, + bgMode: bgMode || undefined, + bold: !!cell.isBold(), + italic: !!cell.isItalic(), + underline: !!cell.isUnderline(), + dim: !!cell.isDim(), + inverse: !!cell.isInverse(), + strikethrough: !!cell.isStrikethrough(), + }; + }), + ); + const wrapped = rows.map((_, i) => buffer.getLine(i + 1)?.isWrapped === true); + return { + rows: terminal.rows, + cols: terminal.cols, + grid: rows.slice(buffer.baseY), + scrollback: rows.slice(0, buffer.baseY), + gridWrapped: wrapped.slice(buffer.baseY), + scrollbackWrapped: wrapped.slice(0, buffer.baseY), + cursor: { row: buffer.cursorY, col: buffer.cursorX }, + ...(session.getTitle() ? { title: session.getTitle() } : {}), + }; + } finally { + terminal.dispose(); + } +} diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index e11c9eca9f..b0ef983a8e 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -1,13 +1,41 @@ +import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; +import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; +import type { SubscribeTerminalRequest } from "@getpaseo/protocol/messages"; +import { TerminalSessionController } from "./terminal-session-controller.js"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; +import { + decodeTerminalStreamFrame, + TerminalStreamOpcode, + type TerminalStreamFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import type { SessionOutboundMessage } from "../server/messages.js"; +import { createConnection } from "node:net"; +import { attachFramedSocket } from "./terminal-worker-frames.js"; +import xterm from "@xterm/headless"; import { afterEach, expect, it } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { EventEmitter } from "node:events"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + closeSync, + existsSync, + openSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; -import { createWorkerTerminalManager } from "./worker-terminal-manager.js"; +import pino from "pino"; +import { + createWorkerTerminalManager, + startWorkerTerminalManager, + type DaemonTerminalManager, +} from "./worker-terminal-manager.js"; import type { TerminalActivityTransitionEvent, - TerminalManager, TerminalWorkspaceContributionChangedEvent, } from "./terminal-manager.js"; import { @@ -19,8 +47,30 @@ import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import type { TerminalWorkerRequest, + WorkerTerminalState, TerminalWorkerToParentMessage, } from "./terminal-worker-protocol.js"; +import { classifyTerminalProgramProcess } from "./terminal-worker-ownership.js"; +import { createTerminalWorkerProcessInspector } from "./terminal-worker-processes.js"; +import { + readUnconfirmedTerminalCleanup, + reapTerminalWorkers, + TerminalCleanupNotConfirmedError, +} from "./terminal-worker-reaper.js"; +import { + listTerminalWorkerRecords, + readTerminalWorkerRecord, + terminalWorkerDirectory, + terminalWorkerRecordPath, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; +import { TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV } from "./terminal-worker-protocol.js"; +import { + NoTerminalWorkerAvailableError, + TerminalWorkerIncompatibleError, +} from "./terminal-worker-pool.js"; +import { connectTerminalWorker } from "./terminal-worker-client.js"; +import { launchTerminalWorker } from "./terminal-worker-launch.js"; type TerminalRow = TerminalState["grid"][number]; @@ -46,8 +96,35 @@ async function waitForCondition( throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); } -function getVisibleText(session: TerminalSession): string { - return getVisibleTextFromState(session.getState()); +async function readReplay(manager: DaemonTerminalManager, id: string) { + const chunks: Uint8Array[] = []; + const metadata = await manager.replayTerminal(id, undefined, (event) => { + if (event.type === "data") chunks.push(event.data); + }); + if (!metadata) return null; + const terminal = new xterm.Terminal({ + rows: metadata.rows, + cols: metadata.cols, + scrollback: 1000, + allowProposedApi: true, + }); + await new Promise((resolve) => terminal.write(Buffer.concat(chunks), resolve)); + const buffer = terminal.buffer.active; + const rows = Array.from({ length: buffer.length }, (_, i) => { + const line = buffer.getLine(i)!; + return Array.from({ length: metadata.cols }, (_cell, col) => ({ + char: line.getCell(col)?.getChars() || " ", + })); + }); + const state: TerminalState = { + rows: metadata.rows, + cols: metadata.cols, + scrollback: rows.slice(0, buffer.baseY), + grid: rows.slice(buffer.baseY), + cursor: { row: buffer.cursorY, col: buffer.cursorX }, + }; + terminal.dispose(); + return { state, revision: metadata.revision }; } function rowToText(row: TerminalRow): string { @@ -76,20 +153,13 @@ function readRenderedTokenIndexesFromState(state: TerminalState): number[] { return indexes; } -function createTerminalState(): TerminalState { - const blankCell = { char: " " }; - return { - rows: 1, - cols: 1, - grid: [[blankCell]], - scrollback: [], - cursor: { row: 0, col: 0 }, - }; +function createTerminalState(): WorkerTerminalState { + return { rows: 1, cols: 1, hasOutput: false }; } class FakeTerminalWorker extends EventEmitter { connected = true; - killed = false; + readonly workerPid = 1; readonly sentMessages: TerminalWorkerRequest[] = []; send(message: TerminalWorkerRequest, callback: (error: Error | null) => void): boolean { @@ -100,14 +170,7 @@ class FakeTerminalWorker extends EventEmitter { disconnect(): void { this.connected = false; - this.emit("exit", 0, null); - } - - kill(): boolean { - this.killed = true; - this.connected = false; - this.emit("exit", 0, null); - return true; + this.emit("close"); } emitWorkerMessage(message: TerminalWorkerToParentMessage): void { @@ -115,8 +178,31 @@ class FakeTerminalWorker extends EventEmitter { } } -let manager: TerminalManager | null = null; +let manager: DaemonTerminalManager | null = null; const temporaryDirs: string[] = []; + +// A real detached worker in a throwaway home; afterEach closes its terminals and the worker. +async function startRealWorkerManager( + options: { + getTerminalActivityUrl?: () => string | null; + requestTimeoutMs?: number; + paseoHome?: string; + } = {}, +): Promise { + const { paseoHome = createTemporaryHome(), ...rest } = options; + return startWorkerTerminalManager({ + paseoHome, + paseoVersion: "0.0.0-test", + logger: pino({ level: "silent" }), + ...rest, + }); +} + +function createTemporaryHome(): string { + const paseoHome = mkdtempSync(join(tmpdir(), "worker-terminal-manager-home-")); + temporaryDirs.push(paseoHome); + return paseoHome; +} const terminalSessions: TerminalSession[] = []; function trackTerminal(session: TerminalSession): TerminalSession { @@ -150,7 +236,7 @@ afterEach(async () => { .catch(() => {}), ), ); - manager?.killAll(); + await manager?.closeTerminals(); manager = null; while (temporaryDirs.length > 0) { const dir = temporaryDirs.pop(); @@ -160,10 +246,187 @@ afterEach(async () => { } }); +it("retires a killed worker's sessions and shares one replacement across concurrent creates", async () => { + const paseoHome = createTemporaryHome(); + manager = await startRealWorkerManager({ paseoHome }); + manager.registerCwdEnv({ cwd: paseoHome, env: { QA_RECOVERY_ENV: "retained" } }); + const terminal = await manager.createTerminal({ + workspaceId: "ws-recovery", + cwd: paseoHome, + ...nodeTerminalCommand( + "process.stdin.on('data', chunk => process.stdout.write('echo:' + process.env.QA_RECOVERY_ENV + ':' + chunk)); setInterval(() => {}, 1000)", + ), + }); + const records = await listTerminalWorkerRecords(paseoHome); + const file = records.find((entry) => !("error" in entry)); + if (!file || "error" in file) throw new Error("Missing worker record"); + const changes: string[][] = []; + manager.subscribeTerminalsChanged((event) => + changes.push(event.terminals.map((item) => item.id)), + ); + const exits: unknown[] = []; + terminal.onExit((info) => exits.push(info)); + process.kill(file.record.pid, "SIGKILL"); + await waitForCondition(() => manager!.getTerminal(terminal.id) === undefined, 10000); + expect(changes).toContainEqual([]); + expect(exits).toHaveLength(1); + const sessions = await Promise.all( + Array.from({ length: 4 }, () => + manager!.createTerminal({ + workspaceId: "ws-recovery", + cwd: paseoHome, + ...nodeTerminalCommand( + "process.stdin.on('data', chunk => process.stdout.write('echo:' + process.env.QA_RECOVERY_ENV + ':' + chunk)); setInterval(() => {}, 1000)", + ), + }), + ), + ); + for (const session of sessions) { + const output: string[] = []; + session.subscribe((message) => { + if (message.type === "output") output.push(message.data); + }); + session.send({ type: "input", data: "recovered\r" }); + await waitForCondition(() => output.join("").includes("echo:retained:recovered"), 10000); + } + expect(await manager.getTerminals(paseoHome)).toHaveLength(4); + const remaining = (await listTerminalWorkerRecords(paseoHome)).filter( + (entry) => !("error" in entry), + ); + expect(remaining).toHaveLength(1); +}, 30000); + +it("reconnects a live worker without replacing terminal sessions or processes", async () => { + const paseoHome = createTemporaryHome(); + manager = await startRealWorkerManager({ paseoHome }); + const terminal = await manager.createTerminal({ + workspaceId: "ws-reconnect", + cwd: paseoHome, + ...nodeTerminalCommand( + "process.stdin.on('data', chunk => process.stdout.write('echo:' + chunk)); setInterval(() => {}, 1000)", + ), + }); + const [before] = await workerRecords(paseoHome); + const pids = await recordedTerminalPids(paseoHome); + let replayRequests = 0; + terminal.subscribe((message) => { + if (message.type === "snapshotReady") replayRequests += 1; + }); + // A second authenticated controller replaces the daemon's socket. The process stays alive. + const competitor = await connectTerminalWorker({ + socketPath: before!.socketPath, + secret: before!.secret, + }); + await waitForCondition(() => !competitor.connected, 10000); + await waitForCondition(async () => { + try { + return (await readReplay(manager!, terminal.id)) !== null; + } catch { + return false; + } + }, 10000); + await waitForCondition(() => replayRequests > 0, 1000); + expect(manager.getTerminal(terminal.id)).toBe(terminal); + expect(await recordedTerminalPids(paseoHome)).toEqual(pids); + expect((await workerRecords(paseoHome)).map((record) => record.pid)).toEqual([before!.pid]); + const output: string[] = []; + terminal.subscribe((message) => { + if (message.type === "output") output.push(message.data); + }); + terminal.send({ type: "input", data: "reconnected\r" }); + await waitForCondition(() => output.join("").includes("echo:reconnected"), 10000); +}, 30000); + +it.each(["detach", "closeTerminals"] as const)( + "does not replace a failed worker during %s", + async (operation) => { + const paseoHome = createTemporaryHome(); + manager = await startRealWorkerManager({ paseoHome }); + await manager.createTerminal({ + workspaceId: "ws-stop-recovery", + cwd: paseoHome, + ...nodeTerminalCommand("setInterval(() => {}, 1000)"), + }); + const [record] = await workerRecords(paseoHome); + process.kill(record!.pid, "SIGKILL"); + await manager[operation](); + await expect( + manager.createTerminal({ workspaceId: "ws-stop-recovery", cwd: paseoHome }), + ).rejects.toBeInstanceOf(NoTerminalWorkerAvailableError); + await manager.closeTerminals(); + expect(await workerRecords(paseoHome)).toEqual([]); + }, + 30000, +); + +it.each(["detach", "closeTerminals"] as const)( + "does not leak a replacement stopped during launch by %s", + async (operation) => { + const paseoHome = createTemporaryHome(); + manager = await startRealWorkerManager({ paseoHome }); + const terminal = await manager.createTerminal({ + workspaceId: "ws-launch-race", + cwd: paseoHome, + ...nodeTerminalCommand("setInterval(() => {}, 1000)"), + }); + const [record] = await workerRecords(paseoHome); + process.kill(record!.pid, "SIGKILL"); + await waitForCondition(() => !manager!.getTerminal(terminal.id), 10000); + const creating = manager.createTerminal({ workspaceId: "ws-launch-race", cwd: paseoHome }); + const rejected = expect(creating).rejects.toBeInstanceOf(NoTerminalWorkerAvailableError); + await manager[operation](); + await rejected; + await waitForCondition(async () => (await workerRecords(paseoHome)).length === 0, 10000); + expect(await manager.getTerminals(paseoHome)).toEqual([]); + }, + 30000, +); + +it.each(["current", "draining"] as const)( + "keeps the peer usable when the %s worker dies", + async (target) => { + const paseoHome = createTemporaryHome(); + const seeds = [ + await launchDetachedWorkerWithTerminal(paseoHome), + await launchDetachedWorkerWithTerminal(paseoHome), + ]; + // Exercise an existing seed adopted as current; launch age does not imply draining ownership. + const [firstAdopted] = await workerRecords(paseoHome); + const seed = seeds.find((candidate) => candidate.workerPid === firstAdopted!.pid)!; + manager = await startRealWorkerManager({ paseoHome }); + const added = await manager.createTerminal({ + workspaceId: "ws-peer", + cwd: seed.cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000)"), + }); + const records = await workerRecords(paseoHome); + const current = records.find((record) => + record.terminals.some((terminal) => terminal.id === added.id), + )!; + const draining = records.find((record) => record.pid !== current.pid)!; + const drainingId = draining.terminals[0]!.id; + const deadId = target === "current" ? added.id : drainingId; + const healthyId = target === "current" ? drainingId : added.id; + const healthy = manager.getTerminal(healthyId); + expect(healthy).toBeDefined(); + process.kill(target === "current" ? current.pid : draining.pid, "SIGKILL"); + await waitForCondition(() => !manager!.getTerminal(deadId), 10000); + expect(manager.getTerminal(healthyId)).toBe(healthy); + expect(await readReplay(manager, healthyId)).not.toBeNull(); + await manager.createTerminal({ + workspaceId: "ws-peer", + cwd: seed.cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000)"), + }); + expect((await workerRecords(paseoHome)).length).toBeLessThanOrEqual(2); + }, + 30000, +); + it("creates a terminal through the worker and streams output", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-output-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -177,38 +440,33 @@ it("creates a terminal through the worker and streams output", async () => { }), ); const messages: string[] = []; - let snapshots = 0; + let readinessEvents = 0; const unsubscribe = session.subscribe((message) => { if (message.type === "output") { messages.push(message.data); } - if (message.type === "snapshot") { - snapshots += 1; + if (message.type === "snapshotReady") { + readinessEvents += 1; } }); await new Promise((resolve) => setTimeout(resolve, 100)); - const snapshotsBeforeOutput = snapshots; + expect(readinessEvents).toBe(1); session.send({ type: "input", data: "hello\r" }); - await waitForCondition( - () => - messages.join("").includes("worker-output:hello") || - getVisibleText(session).includes("worker-output:hello"), - 10000, - ); + await waitForCondition(() => messages.join("").includes("worker-output:hello"), 10000); await new Promise((resolve) => setTimeout(resolve, 100)); unsubscribe(); - expect(messages.join("") + getVisibleText(session)).toContain("worker-output:hello"); - expect(snapshots).toBe(snapshotsBeforeOutput); + expect(messages.join("")).toContain("worker-output:hello"); + expect(readinessEvents).toBe(1); }); it("delivers rapid small writes complete and in order through worker coalescing", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-coalesce-")); temporaryDirs.push(cwd); const burstGatePath = join(cwd, "burst-ready"); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); // The file gate starts the burst after this test subscribes without depending // on platform-specific PTY input echo/canonical-mode behavior. const session = trackTerminal( @@ -237,19 +495,19 @@ it("delivers rapid small writes complete and in order through worker coalescing" session.subscribe((message) => { if (message.type === "output") { events.push({ type: "output", data: message.data }); - } else if (message.type === "snapshot" || message.type === "snapshotReady") { + } else if (message.type === "snapshotReady") { events.push({ type: message.type }); } }); - // Let the snapshot subscription settle, then trigger the burst. + // Let readiness settle, then trigger the burst. await new Promise((resolve) => setTimeout(resolve, 100)); writeFileSync(burstGatePath, "go"); const expected = Array.from({ length: 500 }, (_, index) => index); let received: number[] = []; await waitForCondition(async () => { - const snapshot = await manager!.getTerminalState(session.id); + const snapshot = await readReplay(manager!, session.id); received = snapshot ? readRenderedTokenIndexesFromState(snapshot.state) : []; return expected.every((value, index) => received[index] === value); }, 10000); @@ -258,20 +516,19 @@ it("delivers rapid small writes complete and in order through worker coalescing" await waitForCondition(() => events.some((event) => event.type === "output"), 10000); - // No snapshot may land after output it should have preceded: every snapshot - // event must come before the first output event. + // Readiness precedes the ordered live stream. const firstOutputIndex = events.findIndex((event) => event.type === "output"); expect(firstOutputIndex).toBeGreaterThanOrEqual(0); - const snapshotAfterOutput = events + const readinessAfterOutput = events .slice(firstOutputIndex + 1) - .find((event) => event.type === "snapshot" || event.type === "snapshotReady"); - expect(snapshotAfterOutput).toBeUndefined(); + .find((event) => event.type === "snapshotReady"); + expect(readinessAfterOutput).toBeUndefined(); }); it("pulls fresh terminal state from the worker authority", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-state-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -285,7 +542,7 @@ it("pulls fresh terminal state from the worker authority", async () => { let visibleText = ""; await waitForCondition(async () => { - const snapshot = await manager!.getTerminalState(session.id); + const snapshot = await readReplay(manager!, session.id); visibleText = snapshot ? getVisibleTextFromState(snapshot.state) : ""; return visibleText.includes("worker-state-ready"); }, 10000); @@ -298,11 +555,11 @@ it("pulls fresh terminal state from the worker authority", async () => { // preamble-caching contract is verified on Linux/macOS; the daemon's input-mode // handling runs identically on every platform once the escape is observed. it.skipIf(isPlatform("win32"))( - "caches the input-mode replay preamble from the worker after getTerminalState", + "caches input modes from worker output independently of replay capture", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-preamble-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); // \x1b[>1u pushes kitty keyboard flag 1, which the worker's input-mode // tracker records and reflects in its replay preamble (\x1b[=1;1u). const session = trackTerminal( @@ -316,11 +573,10 @@ it.skipIf(isPlatform("win32"))( }), ); - await waitForCondition(async () => { - const snapshot = await manager!.getTerminalState(session.id); - return snapshot !== null && session.getReplayPreamble() === "\x1b[=1;1u"; - }, 10000); - + await waitForCondition(() => session.getReplayPreamble() === "\x1b[=1;1u", 10000); + // Initial shared modes must be ready without a replay response repairing the cache. + expect(session.getReplayPreamble()).toBe("\x1b[=1;1u"); + expect(await readReplay(manager!, session.id)).not.toBeNull(); expect(session.getReplayPreamble()).toBe("\x1b[=1;1u"); }, ); @@ -328,7 +584,7 @@ it.skipIf(isPlatform("win32"))( it("refreshes cached terminal title after worker title changes", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-title-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -342,28 +598,28 @@ it("refreshes cached terminal title after worker title changes", async () => { await waitForCondition(() => session.getTitle() === "Build Output", 10000); - expect(session.getState().title).toBe("Build Output"); + expect(session.getTitle()).toBe("Build Output"); }); it("refreshes cached terminal size after worker resize", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-resize-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" })); session.send({ type: "resize", rows: 10, cols: 40 }); - const snapshot = await manager.getTerminalState(session.id); + const snapshot = await readReplay(manager, session.id); expect(snapshot?.state.rows).toBe(10); expect(snapshot?.state.cols).toBe(40); - expect(session.getState().rows).toBe(10); - expect(session.getState().cols).toBe(40); + expect(session.getSize().rows).toBe(10); + expect(session.getSize().cols).toBe(40); }); it("captures terminal output from the worker authority", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-capture-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal(await manager.createTerminal({ cwd, workspaceId: "ws-test" })); session.send({ type: "input", data: "echo hello world\r" }); @@ -382,7 +638,7 @@ it("does not surface fire-and-forget send timeouts as unhandled rejections", asy const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 5, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -416,7 +672,7 @@ it("does not surface fire-and-forget send timeouts as unhandled rejections", asy }); it("keeps registered cwd env inheritance behind the worker manager interface", async () => { - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-env-")); temporaryDirs.push(cwd); const markerPath = join(cwd, "env.txt"); @@ -449,7 +705,7 @@ it("injects parent-minted terminal activity env through the worker", async () => temporaryDirs.push(cwd); const envPath = join(cwd, "activity-env.json"); const activityUrl = "http://127.0.0.1:12345/api/terminal-activity"; - manager = createWorkerTerminalManager({ + manager = await startRealWorkerManager({ getTerminalActivityUrl: () => activityUrl, }); @@ -496,8 +752,218 @@ it("injects parent-minted terminal activity env through the worker", async () => expect(env.path?.split(delimiter)[0]).toBe(paseoCliBinDir); }); +it("adopts a detached worker's terminals with their activity tokens and keeps them usable", async () => { + const paseoHome = createTemporaryHome(); + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-adopt-")); + temporaryDirs.push(cwd); + const envPath = join(cwd, "adopt-env.json"); + const first = await startRealWorkerManager({ paseoHome }); + const created = await first.createTerminal({ + workspaceId: "ws-adopt", + cwd, + ...nodeTerminalCommand(` + require("node:fs").writeFileSync( + ${JSON.stringify(envPath)}, + JSON.stringify({ token: process.env.PASEO_ACTIVITY_TOKEN }), + ); + process.stdin.on("data", (chunk) => { + process.stdout.write("adopted-output:" + chunk.toString()); + }); + setInterval(() => {}, 1000); + `), + }); + await waitForCondition(() => existsSync(envPath), 10000); + const { token } = JSON.parse(readFileSync(envPath, "utf8")) as { token: string }; + + await first.detach(); + manager = await startRealWorkerManager({ paseoHome }); + + const adopted = manager.getTerminal(created.id); + expect(adopted?.workspaceId).toBe("ws-adopt"); + expect(await manager.getTerminals(cwd)).toHaveLength(1); + expect(manager.validateTerminalActivityToken(created.id, token)).toBe("valid"); + const messages: string[] = []; + adopted?.subscribe((message) => { + if (message.type === "output") messages.push(message.data); + }); + adopted?.send({ type: "input", data: "after-adopt\r" }); + await waitForCondition(() => messages.join("").includes("adopted-output:after-adopt"), 10000); +}); + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +// The terminals the worker itself recorded, read from its own record file. +async function recordedTerminalPids(paseoHome: string): Promise> { + const pids = new Map(); + const directory = terminalWorkerDirectory(paseoHome); + for (const name of readdirSync(directory).filter((entry) => /^\d+\.json$/.test(entry))) { + for (const entry of (await readTerminalWorkerRecord(join(directory, name))).terminals) { + pids.set(entry.id, entry.pid); + } + } + return pids; +} + +it.each([false, true])( + "never adopts a live worker's terminals after a kill cleanup it could not finish, even once the worker rewrites its record (held reader: %s)", + async (holdReader) => { + const paseoHome = createTemporaryHome(); + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-")); + temporaryDirs.push(cwd); + const first = await startRealWorkerManager({ paseoHome }); + manager = first; + const exitRequestPath = join(cwd, "exit-requested"); + const exitObservedPath = join(cwd, "exit-observed"); + const ending = await first.createTerminal({ + workspaceId: "ws-unfinished-kill", + cwd, + ...nodeTerminalCommand(` + const { existsSync, writeFileSync } = require("node:fs"); + setInterval(() => { + if (existsSync(${JSON.stringify(exitRequestPath)})) { + writeFileSync(${JSON.stringify(exitObservedPath)}, ""); + process.exit(0); + } + }, 25); + `), + }); + const remaining = await first.createTerminal({ + workspaceId: "ws-unfinished-kill", + cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + const pids = await recordedTerminalPids(paseoHome); + const endingPid = pids.get(ending.id)!; + const remainingPid = pids.get(remaining.id)!; + const [record] = await workerRecords(paseoHome); + + // A kill cleanup whose process table read fails signals nothing and leaves the worker live. + const inspector = createTerminalWorkerProcessInspector(); + await expect( + reapTerminalWorkers({ + paseoHome, + inspector: { + ...inspector, + listProcesses: () => Promise.reject(new Error("ps timed out")), + }, + }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0); + + // Keep an ordinary reader open until the worker delivers the terminal exit. + // Windows cannot replace the record while this handle is open. The exit must + // still reach the controller, and publication must recover after release. + const recordFd = holdReader + ? openSync(terminalWorkerRecordPath(paseoHome, record!.pid), "r") + : null; + try { + writeFileSync(exitRequestPath, ""); + await waitForCondition(() => first.getTerminal(ending.id) === undefined, 10000); + expect(existsSync(exitObservedPath)).toBe(true); + expect(isProcessAlive(endingPid)).toBe(false); + } finally { + if (recordFd !== null) closeSync(recordFd); + } + await waitForCondition( + async () => !(await recordedTerminalPids(paseoHome)).has(ending.id), + 10000, + ); + expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0); + + await first.detach(); + manager = await startRealWorkerManager({ paseoHome }); + + expect(manager.getTerminal(remaining.id)).toBeUndefined(); + expect(await manager.getTerminals(cwd)).toEqual([]); + expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]); + const original = record!.terminals.find((entry) => entry.id === remaining.id)!; + // Startup can launch a new worker after cleanup and Windows can reuse the old PID. + // Require the original process to be gone, not the numeric PID to remain unused. + await expect + .poll( + async () => + !isProcessAlive(remainingPid) || + classifyTerminalProgramProcess(original, await inspector.inspect(remainingPid)).status === + "unrelated", + { timeout: 10000 }, + ) + .toBe(true); + }, +); + +// The worker's own `ps`, delayed: a program that exits while the worker reads its birth time +// lands its exit in the middle of creation. Only the worker launched while this PATH is set sees +// the shim; the PATH is restored as soon as it is up. +async function startWorkerWithSlowProcessTable( + delaySeconds: number, + options: { paseoHome?: string; requestTimeoutMs?: number } = {}, +): Promise { + const realPs = (process.env.PATH ?? "") + .split(delimiter) + .map((dir) => join(dir, "ps")) + .find((candidate) => existsSync(candidate)); + if (!realPs) throw new Error("ps not found on PATH"); + const shimDir = createTemporaryHome(); + writeFileSync(join(shimDir, "ps"), `#!/bin/sh\nsleep ${delaySeconds}\nexec ${realPs} "$@"\n`, { + mode: 0o755, + }); + const originalPath = process.env.PATH; + process.env.PATH = `${shimDir}${delimiter}${originalPath ?? ""}`; + try { + return await startRealWorkerManager(options); + } finally { + process.env.PATH = originalPath; + } +} + +async function expectCompletedShortCommand( + terminalManager: DaemonTerminalManager, + script: string, +): Promise { + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-short-")); + temporaryDirs.push(cwd); + + const session = await terminalManager.createTerminal({ + workspaceId: "ws-test", + cwd, + command: "/bin/sh", + args: ["-c", script], + }); + + await waitForCondition(() => session.getExitInfo() !== null, 10000); + expect(session.getExitInfo()?.exitCode).toBe(0); + expect(terminalManager.getTerminal(session.id)).toBeUndefined(); + expect(await terminalManager.getTerminals(cwd)).toEqual([]); + expect(await readReplay(terminalManager, session.id)).toBeNull(); +} + +it.skipIf(isPlatform("win32"))( + "creates a short command that finishes before creation returns, with its exit", + async () => { + manager = await startRealWorkerManager(); + for (let attempt = 0; attempt < 3; attempt += 1) { + await expectCompletedShortCommand(manager, "echo short; exit 0"); + } + }, +); + +it.skipIf(isPlatform("win32"))( + "publishes the creation before an exit that arrives while the program is being recorded", + async () => { + manager = await startWorkerWithSlowProcessTable(0.3); + await expectCompletedShortCommand(manager, "sleep 0.1; echo short; exit 0"); + }, +); + it("starts the default shell through the worker and accepts quoted commands", async () => { - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-shell-")); temporaryDirs.push(cwd); const markerPath = join(cwd, "shell quoted marker.txt"); @@ -518,7 +984,7 @@ it("lists subdirectory terminals when querying the workspace root", async () => const subdirCwd = join(rootCwd, "apps", "mobile"); mkdirSync(subdirCwd, { recursive: true }); temporaryDirs.push(rootCwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const created = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -536,7 +1002,7 @@ it("lists terminals locally without waiting on the worker", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 5, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -577,7 +1043,7 @@ it("includes only stamped terminals in workspace-scoped local reads", async () = const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 5, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -629,7 +1095,7 @@ it("rejects non-absolute cwd in getTerminals", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 5, - forkWorker: () => worker, + worker, }); await expect(manager.getTerminals("relative/path")).rejects.toThrow("cwd must be absolute path"); @@ -639,7 +1105,7 @@ it("surfaces worker activity changes via getActivity, onActivityChange, and term const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -702,7 +1168,7 @@ it("sets terminal activity through a worker request", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ type: "terminalCreated", @@ -735,7 +1201,7 @@ it("clears terminal attention through a worker request", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ type: "terminalCreated", @@ -766,7 +1232,7 @@ it("clears terminal attention through a worker request", async () => { it("clears finished attention on a real terminal", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-attention-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -802,7 +1268,7 @@ it("clears finished attention on a real terminal", async () => { it("removes worker terminals after killAndWait", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-")); temporaryDirs.push(cwd); - manager = createWorkerTerminalManager(); + manager = await startRealWorkerManager(); const session = trackTerminal( await manager.createTerminal({ workspaceId: "ws-test", @@ -827,7 +1293,7 @@ it("produces one terminals-changed snapshot per title change", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -864,7 +1330,7 @@ it("produces one terminals-changed snapshot and one contribution event per activ const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); worker.emitWorkerMessage({ @@ -914,7 +1380,7 @@ it("removes a killed worker terminal from terminalExit without duplicate snapsho const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); const workingActivity = { state: "working" as const, changedAt: 1000 }; @@ -973,7 +1439,7 @@ it("does not resurrect a terminal that exited before its create response was han const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; @@ -1026,7 +1492,7 @@ it("replays the exit to a listener attached after a create that lost the race", const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); const created = manager.createTerminal({ @@ -1074,7 +1540,7 @@ it("rejects createTerminal and registers nothing when the worker cannot start th const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, - forkWorker: () => worker, + worker, }); const snapshots: Array<{ cwd: string; terminalIds: string[] }> = []; @@ -1104,3 +1570,1091 @@ it("rejects createTerminal and registers nothing when the worker cannot start th expect(await manager.getTerminals("/workspace")).toEqual([]); expect(snapshots).toEqual([]); }); + +// The live worker records in a home, by worker pid. +async function workerRecords(paseoHome: string): Promise { + return (await listTerminalWorkerRecords(paseoHome)).flatMap((file) => + "record" in file && !file.record.closing ? [file.record] : [], + ); +} + +// A worker launched while the override is set reports that protocol version; the variable is +// restored as soon as it is up, so later workers report the build's own. +async function startManagerWithWorkerProtocol( + paseoHome: string, + protocolVersion: number, +): Promise { + const previous = process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV] = String(protocolVersion); + try { + return await startRealWorkerManager({ paseoHome }); + } finally { + if (previous === undefined) delete process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + else process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV] = previous; + } +} + +// A terminal running an idle program in a worker that reports `protocolVersion`, left running +// after its daemon lets go (stop --keep-terminals). +async function leaveWorkerWithTerminal( + paseoHome: string, + protocolVersion: number, +): Promise<{ terminalId: string; programPid: number; workerPid: number; cwd: string }> { + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-protocol-")); + temporaryDirs.push(cwd); + const previous = await startManagerWithWorkerProtocol(paseoHome, protocolVersion); + const session = await previous.createTerminal({ + workspaceId: "ws-protocol", + cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + const [record] = await workerRecords(paseoHome); + await previous.detach(); + return { + terminalId: session.id, + programPid: (await recordedTerminalPids(paseoHome)).get(session.id)!, + workerPid: record!.pid, + cwd, + }; +} + +it.each([1, 4])( + "hard cuts unreleased worker protocol %s, closing its terminals", + async (version) => { + const paseoHome = createTemporaryHome(); + const old = await leaveWorkerWithTerminal(paseoHome, version); + + manager = await startRealWorkerManager({ paseoHome }); + + await waitForCondition( + () => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid), + 10000, + ); + expect(manager.getTerminal(old.terminalId)).toBeUndefined(); + const workers = await workerRecords(paseoHome); + expect(workers).toHaveLength(1); + expect(workers[0]!.protocolVersion).toBe(5); + }, +); + +it("leaves a worker from a newer Paseo running, reports it incompatible, and closes it on a kill stop", async () => { + const paseoHome = createTemporaryHome(); + const newer = await leaveWorkerWithTerminal(paseoHome, 6); + + manager = await startRealWorkerManager({ paseoHome }); + + const unavailable = manager.getUnavailableError?.(); + expect(unavailable).toBeInstanceOf(TerminalWorkerIncompatibleError); + expect(unavailable?.message).toContain("worker protocol 6"); + await expect(manager.getTerminals(newer.cwd)).rejects.toBeInstanceOf( + TerminalWorkerIncompatibleError, + ); + await expect( + manager.createTerminal({ workspaceId: "ws-protocol", cwd: newer.cwd }), + ).rejects.toBeInstanceOf(TerminalWorkerIncompatibleError); + expect(isProcessAlive(newer.programPid)).toBe(true); + expect((await workerRecords(paseoHome)).map((record) => record.pid)).toEqual([newer.workerPid]); + + await manager.closeTerminals(); + + expect(isProcessAlive(newer.workerPid)).toBe(false); + expect(isProcessAlive(newer.programPid)).toBe(false); + expect(await workerRecords(paseoHome)).toEqual([]); +}); + +// A worker launched on its own, as two daemons that each started one would leave it: running an +// idle program in a terminal, with no daemon attached. +async function launchDetachedWorkerWithTerminal( + paseoHome: string, + protocolVersion?: number, +): Promise<{ terminalId: string; workerPid: number; cwd: string }> { + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-detached-")); + temporaryDirs.push(cwd); + const previous = process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + if (protocolVersion !== undefined) { + process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV] = String(protocolVersion); + } + let launched: Awaited>; + try { + launched = await launchTerminalWorker({ paseoHome, paseoVersion: "0.0.0-test" }); + } finally { + if (previous === undefined) delete process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV]; + else process.env[TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV] = previous; + } + const worker = createWorkerTerminalManager({ + worker: await connectTerminalWorker({ + socketPath: launched.record.socketPath, + secret: launched.record.secret, + }), + }); + const session = await worker.createTerminal({ + workspaceId: "ws-detached", + cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }); + await worker.detach(); + return { terminalId: session.id, workerPid: launched.record.pid, cwd }; +} + +// Worker pids alive in a home, from every record, closing ones included. +async function liveWorkerPids(paseoHome: string): Promise { + return (await listTerminalWorkerRecords(paseoHome)) + .flatMap((file) => ("record" in file ? [file.record.pid] : [])) + .filter(isProcessAlive) + .sort(); +} + +it.skipIf(isPlatform("win32"))( + "counts live workers whose cleanup could not be confirmed and never starts a third", + async () => { + const paseoHome = createTemporaryHome(); + const first = await launchDetachedWorkerWithTerminal(paseoHome); + const second = await launchDetachedWorkerWithTerminal(paseoHome); + // A kill cleanup that cannot read the process table signals nothing: both workers keep + // running with their records marked closing. + const inspector = createTerminalWorkerProcessInspector(); + await expect( + reapTerminalWorkers({ + paseoHome, + gracefulMs: 0, + inspector: { ...inspector, listProcesses: () => Promise.reject(new Error("ps timed out")) }, + }), + ).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError); + expect((await readUnconfirmedTerminalCleanup(paseoHome)).map((entry) => entry.role)).toContain( + "worker", + ); + + // The daemon's own retry fails the same way: only the full process table is unreadable. + const realPs = (process.env.PATH ?? "") + .split(delimiter) + .map((dir) => join(dir, "ps")) + .find((candidate) => existsSync(candidate)); + if (!realPs) throw new Error("ps not found on PATH"); + const shimDir = createTemporaryHome(); + writeFileSync( + join(shimDir, "ps"), + `#!/bin/sh\nif [ "$1" = "-A" ]; then echo "process table unavailable" >&2; exit 1; fi\nexec ${realPs} "$@"\n`, + { mode: 0o755 }, + ); + const originalPath = process.env.PATH; + process.env.PATH = `${shimDir}${delimiter}${originalPath ?? ""}`; + try { + manager = await startRealWorkerManager({ paseoHome }); + } finally { + process.env.PATH = originalPath; + } + + expect(await liveWorkerPids(paseoHome)).toEqual([first.workerPid, second.workerPid].sort()); + await expect( + manager.createTerminal({ workspaceId: "ws-detached", cwd: first.cwd }), + ).rejects.toThrow("2 terminal workers are already running"); + expect(await liveWorkerPids(paseoHome)).toEqual([first.workerPid, second.workerPid].sort()); + }, +); + +it("replays bounded ANSI chunks independently of live output and reconstructs the revision cut", async () => { + const cwd = createTemporaryHome(); + const finishBefore = join(cwd, "finish-before"); + manager = await startRealWorkerManager(); + const session = trackTerminal( + await manager.createTerminal({ + cwd, + workspaceId: "ws-replay", + rows: 8, + cols: 120, + ...nodeTerminalCommand(` + for (let i = 0; i < 800; i++) process.stdout.write("\\x1b[31m" + String(i).padStart(4, "0") + ":" + "x".repeat(100) + "\\x1b[0m\\r\\n"); + require("node:readline").createInterface({ input: process.stdin }).on("line", line => { + if (line !== "before") { process.stdout.write("LIVE-AFTER-REPLAY\\r\\n"); return; } + process.stdout.write("PRE-CUT"); + const finish = setInterval(() => { + if (!require("node:fs").existsSync(${JSON.stringify(finishBefore)})) return; + clearInterval(finish); + process.stdout.write("\\r\\n"); + }, 5); + }); + setInterval(() => {}, 1000); + `), + }), + ); + await waitForCondition( + async () => + (await manager!.captureTerminal(session.id)).lines.some((line) => line.startsWith("0799:")), + 10000, + ); + const events: string[] = []; + const replayEvents: string[] = []; + const liveOutputs: Array<{ data: string; revision?: number }> = []; + const chunks: Uint8Array[] = []; + let live = ""; + const unsubscribe = session.subscribe((event) => { + if (event.type === "output") { + events.push("live"); + liveOutputs.push(event); + live += event.data; + } + }); + session.send({ type: "input", data: "before\r" }); + await waitForCondition(() => live.includes("PRE-CUT"), 10000); + // A PTY may deliver the marker and its trailing newline separately. Finish + // that output before replay, so the two captures must describe the same cut. + writeFileSync(finishBefore, ""); + await waitForCondition(async () => { + const { lines } = await manager!.captureTerminal(session.id); + return lines.at(-2) === "PRE-CUT" && lines.at(-1) === ""; + }, 10000); + const expected = await manager.captureTerminal(session.id); + const metadata = await manager.replayTerminal(session.id, undefined, (event) => { + events.push(event.type); + replayEvents.push(event.type); + if (event.type === "begin") session.send({ type: "input", data: "go\r" }); + if (event.type === "data") chunks.push(event.data); + }); + await waitForCondition(() => live.includes("LIVE-AFTER-REPLAY"), 10000); + unsubscribe(); + // Existing live observers are independent of replay. Pre-cut output may also be + // flushed by capture before begin; only the replay events have begin/data/end order. + expect(events[0]).toBe("live"); + expect(replayEvents[0]).toBe("begin"); + expect(replayEvents.at(-1)).toBe("end"); + expect(replayEvents.slice(1, -1)).toEqual(chunks.map(() => "data")); + const beforeCut = liveOutputs.filter((output) => output.data.includes("PRE-CUT")); + const afterCut = liveOutputs.filter((output) => output.data.includes("LIVE-AFTER-REPLAY")); + expect(beforeCut).toHaveLength(1); + expect(afterCut).toHaveLength(1); + expect(beforeCut[0]!.revision).toBeDefined(); + expect(beforeCut[0]!.revision).toBeLessThanOrEqual(metadata!.revision); + expect(afterCut[0]!.revision).toBeGreaterThan(metadata!.revision); + expect(live.match(/LIVE-AFTER-REPLAY/g)).toHaveLength(1); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.byteLength <= 64 * 1024)).toBe(true); + expect(metadata?.rows).toBe(8); + expect(metadata?.cols).toBe(120); + const terminal = new xterm.Terminal({ + rows: 8, + cols: 120, + scrollback: 1000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => terminal.write(Buffer.concat(chunks), resolve)); + const buffer = terminal.buffer.active; + expect( + Array.from({ length: buffer.length }, (_, i) => buffer.getLine(i)!.translateToString(true)), + ).toEqual(expected.lines); + expect(expected.lines.filter((line) => line.includes("PRE-CUT"))).toHaveLength(1); + expect(expected.lines.some((line) => line.includes("LIVE-AFTER-REPLAY"))).toBe(false); + expect(buffer.getLine(0)!.getCell(0)!.getFgColor()).toBe(1); + } finally { + terminal.dispose(); + } +}); + +it("completes a replay larger than the socket budget while a sibling stays usable", async () => { + const paseoHome = createTemporaryHome(); + const overflowProgress = join(paseoHome, "overflow-progress"); + manager = await startRealWorkerManager({ paseoHome }); + const terminal = trackTerminal( + await manager.createTerminal({ + cwd: paseoHome, + workspaceId: "ws-wide", + rows: 4, + cols: 1024, + ...nodeTerminalCommand(` + const row = Array.from({length: 1000}, (_, i) => "\\x1b[" + (31 + i % 2) + "mX").join(""); + for (let i = 0; i < 900; i++) process.stdout.write(row + "\\r\\n"); + // Seeing the marker must mean no trailing newline can still add history. + process.stdout.write("READY-WIDE"); + process.stdin.on("data", data => { if (data.toString().includes("small")) { process.stdout.write("SAME-TERMINAL-LIVE\\r\\n"); return; } process.stdout.write("q".repeat(8 * 1024 * 1024) + "\\r\\nRECOVERED-LIVE\\r\\n", () => require("node:fs").writeFileSync(${JSON.stringify(overflowProgress)}, "done")); }); + setInterval(() => {}, 1000); + `), + }), + ); + const siblingProgress = join(paseoHome, "sibling-progress"); + const sibling = trackTerminal( + await manager.createTerminal({ + cwd: paseoHome, + workspaceId: "ws-wide", + ...nodeTerminalCommand(` + let count = 0; + process.stdin.on("data", () => { + count++; + process.stdout.write((count > 1 ? "z".repeat(1024 * 1024) : "") + "SIBLING-ALIVE\\r\\n", () => require("node:fs").writeFileSync(${JSON.stringify(siblingProgress)}, String(count))); + }); + setInterval(() => {}, 1000); + `), + }), + ); + await waitForCondition( + async () => + (await manager!.captureTerminal(terminal.id)).lines.some((line) => + line.includes("READY-WIDE"), + ), + 20000, + ); + const seen: string[] = []; + const unsubscribe = sibling.subscribe((event) => { + if (event.type === "output") seen.push(event.data); + }); + let bytes = 0; + const metadata = await manager.replayTerminal(terminal.id, undefined, (event) => { + if (event.type === "data") bytes += event.data.byteLength; + if (event.type === "begin") { + sibling.send({ type: "input", data: "go\r" }); + } + }); + expect(bytes).toBeGreaterThan(4 * 1024 * 1024); + expect(metadata?.cols).toBe(1024); + await waitForCondition(() => seen.join("").includes("SIBLING-ALIVE"), 10000); + unsubscribe(); + expect(manager.getTerminal(sibling.id)).toBe(sibling); + const truncated = await manager.replayTerminal(terminal.id, { scrollbackLines: 200 }, () => {}); + expect(metadata!.retainedHistoryLines).toBeGreaterThan(200); + expect(truncated!.retainedHistoryLines).toBe(metadata!.retainedHistoryLines); + const concurrent = await Promise.all( + [0, 1].map(() => manager!.replayTerminal(terminal.id, { scrollbackLines: 200 }, () => {})), + ); + expect(concurrent.map((replay) => replay!.retainedHistoryLines)).toEqual([ + metadata!.retainedHistoryLines, + metadata!.retainedHistoryLines, + ]); + await manager.detach(); + const [record] = await workerRecords(paseoHome); + const socket = createConnection(record!.socketPath); + let began = false; + let ended = false; + let completed = false; + let resumedBytes = 0; + let cancelledReplayBegan = false; + let existingLiveOutput = ""; + const replayOrder: string[] = []; + const framed = attachFramedSocket(socket, (value) => { + const message = value as TerminalWorkerToParentMessage | { type: "helloAck" }; + if ( + message.type === "terminalMessage" && + message.terminalId === terminal.id && + message.message.type === "output" + ) { + existingLiveOutput += message.message.data; + replayOrder.push("live"); + } + if (message.type === "helloAck") { + framed.send({ type: "replayTerminal", terminalId: terminal.id, requestId: "stalled-replay" }); + } else if (message.type === "terminalReplay") { + if (message.requestId === "cancelled-replay") { + if (message.event.type === "begin") { + cancelledReplayBegan = true; + socket.pause(); + } + return; + } + if (message.event.type === "begin") { + began = true; + socket.pause(); + } + if (message.event.type === "data") resumedBytes += message.event.data.byteLength; + if (message.event.type === "end") { + ended = true; + replayOrder.push("end"); + } + } else if (message.type === "response" && message.requestId === "stalled-replay") + completed = message.ok; + }); + try { + framed.send({ type: "hello", secret: record!.secret, protocolVersion: 5 }); + await waitForCondition(() => began, 10000); + framed.send({ + type: "send", + terminalId: terminal.id, + requestId: "same-terminal-input", + message: { type: "input", data: "small\r" }, + }); + framed.send({ + type: "send", + terminalId: sibling.id, + requestId: "sibling-input", + message: { type: "input", data: "again\r" }, + }); + await waitForCondition( + () => existsSync(siblingProgress) && readFileSync(siblingProgress, "utf8") === "2", + 10000, + ); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(completed).toBe(false); + expect(socket.destroyed).toBe(false); + socket.resume(); + await waitForCondition(() => completed, 10000); + expect(ended).toBe(true); + expect(resumedBytes).toBe(bytes); + await waitForCondition(() => existingLiveOutput.includes("SAME-TERMINAL-LIVE"), 10000); + expect(existingLiveOutput.match(/SAME-TERMINAL-LIVE/g)).toHaveLength(1); + expect(replayOrder.indexOf("live")).toBeLessThan(replayOrder.indexOf("end")); + framed.send({ type: "replayTerminal", terminalId: terminal.id, requestId: "cancelled-replay" }); + await waitForCondition(() => cancelledReplayBegan, 10000); + // Replace a controller while its large replay is socket-paced. The original + // request must never leak frames or completion onto the replacement connection. + manager = await startRealWorkerManager({ paseoHome }); + socket.resume(); + await waitForCondition(() => socket.destroyed, 10000); + expect((await readReplay(manager, terminal.id))?.state.cols).toBe(1024); + } finally { + socket.destroy(); + } + expect( + (await manager!.captureTerminal(terminal.id)).lines.some((line) => line.includes("READY-WIDE")), + ).toBe(true); + const exitingReplay = await manager.replayTerminal( + terminal.id, + { scrollbackLines: 200 }, + (event) => { + if (event.type === "begin") manager!.killTerminal(terminal.id); + }, + ); + expect(exitingReplay!.cols).toBe(1024); + await waitForCondition(() => manager!.getTerminal(terminal.id) === undefined, 10000); + expect(manager.getTerminal(sibling.id)?.id).toBe(sibling.id); +}, 60000); + +function terminalViewers( + terminalManager: DaemonTerminalManager, + terminalId: string, + chunked = true, + getClientBufferedAmount?: (source: object) => number | null, +) { + const frames: Array<{ source: object; frame: TerminalStreamFrame }> = []; + const messages: Array<{ source: object; message: SessionOutboundMessage }> = []; + let blockedSource: object | null = null; + let started = false; + let finish!: (error?: Error) => void; + const block = new Promise((resolve, reject) => { + finish = (error) => (error ? reject(error) : resolve()); + }); + const delivery = new SessionDelivery( + (source, message) => { + messages.push({ source, message }); + }, + async (source, bytes) => { + const frame = decodeTerminalStreamFrame(bytes)!; + frames.push({ source, frame }); + if ( + source === blockedSource && + frame.opcode === (chunked ? TerminalStreamOpcode.ReplayBegin : TerminalStreamOpcode.Restore) + ) { + started = true; + await block; + } + }, + ); + const controller = new TerminalSessionController({ + terminalManager, + emit: (message) => { + delivery.reply(message); + }, + hasBinaryChannel: () => true, + supportsChunkedReplay: () => chunked, + getClientBufferedAmount, + isPathWithinRoot: () => true, + sessionLogger: pino({ level: "silent" }), + }); + return { + frames, + messages, + delivery, + controller, + block(source: object) { + blockedSource = source; + }, + started: () => started, + finish, + async attach( + source: object, + requestId: string, + restore: NonNullable = { + mode: "visible-snapshot", + scrollbackLines: 1000, + }, + ) { + delivery.attach(source, true); + const request = { + type: "subscribe_terminal_request" as const, + terminalId, + requestId, + restore, + }; + await delivery.request(source, request, () => controller.dispatch(request, delivery)); + }, + text(source: object) { + return frames + .filter((row) => row.source === source && row.frame.opcode === TerminalStreamOpcode.Output) + .map((row) => Buffer.from(row.frame.payload).toString("utf8")) + .join(""); + }, + ended(source: object) { + return messages.filter( + (row) => row.source === source && row.message.type === "terminal_stream_exit", + ); + }, + replayed(source: object) { + return frames.some( + (row) => row.source === source && row.frame.opcode === TerminalStreamOpcode.ReplayEnd, + ); + }, + }; +} + +it("isolates a failed app transport while another viewer of the same terminal stays live", async () => { + manager = await startRealWorkerManager(); + const session = trackTerminal( + await manager.createTerminal({ + cwd: createTemporaryHome(), + workspaceId: "two-viewers", + ...nodeTerminalCommand( + 'process.stdout.write("READY\\r\\n"); process.stdin.on("data", data => process.stdout.write("ECHO:" + data)); setInterval(() => {}, 1000);', + ), + }), + ); + await waitForCondition( + async () => + (await manager!.captureTerminal(session.id)).lines.some((line) => line.includes("READY")), + 10000, + ); + const viewers = terminalViewers(manager, session.id); + const desktop = {}, + phone = {}, + fresh = {}; + try { + await viewers.attach(desktop, "desktop"); + await waitForCondition(() => viewers.replayed(desktop), 10000); + await expect( + manager.replayTerminal(session.id, undefined, () => { + throw new Error("Consumer cancelled replay"); + }), + ).rejects.toThrow("Consumer cancelled replay"); + viewers.block(phone); + await viewers.attach(phone, "phone"); + await waitForCondition(viewers.started, 10000); + session.send({ type: "input", data: "BEFORE-FAILURE\r" }); + await waitForCondition(() => viewers.text(desktop).includes("ECHO:BEFORE-FAILURE"), 1500); + expect(viewers.ended(desktop)).toHaveLength(0); + expect(viewers.ended(phone)).toHaveLength(0); + viewers.finish(new Error("Phone socket closed")); + await viewers.delivery.detach(phone); + expect(viewers.ended(desktop)).toHaveLength(0); + session.send({ type: "input", data: "AFTER-FAILURE\r" }); + await waitForCondition(() => viewers.text(desktop).includes("ECHO:AFTER-FAILURE"), 10000); + expect(viewers.ended(desktop)).toHaveLength(0); + await viewers.attach(fresh, "fresh"); + await waitForCondition(() => viewers.replayed(fresh), 10000); + expect(manager.getTerminal(session.id)).toBe(session); + } finally { + viewers.finish(); + await viewers.delivery.close(); + } +}, 20000); + +it("keeps concurrent viewer replays and live output ordered behind slow app delivery", async () => { + manager = await startRealWorkerManager(); + const session = trackTerminal( + await manager.createTerminal({ + cwd: createTemporaryHome(), + workspaceId: "concurrent-delivery", + ...nodeTerminalCommand( + 'process.stdout.write("READY\\r\\n"); process.stdin.on("data", data => process.stdout.write("ECHO:" + data)); setInterval(() => {}, 1000);', + ), + }), + ); + await waitForCondition( + async () => (await manager!.captureTerminal(session.id)).lines.join("").includes("READY"), + 10000, + ); + const viewers = terminalViewers(manager, session.id); + const desktop = {}, + phone = {}, + concurrent = {}; + try { + await viewers.attach(desktop, "desktop"); + await waitForCondition(() => viewers.replayed(desktop), 10000); + viewers.block(phone); + await viewers.attach(phone, "phone"); + await waitForCondition(viewers.started, 10000); + await viewers.attach(concurrent, "concurrent"); + await waitForCondition(() => viewers.replayed(concurrent) && viewers.replayed(phone), 10000); + session.send({ type: "input", data: "DURING-DELIVERY\r" }); + await waitForCondition( + () => + viewers.text(phone).includes("ECHO:DURING-DELIVERY") && + viewers.text(desktop).includes("ECHO:DURING-DELIVERY"), + 3000, + ); + expect(viewers.text(phone).match(/ECHO:DURING-DELIVERY/g)).toHaveLength(1); + const order = viewers.frames + .filter((row) => row.source === phone) + .map((row) => row.frame.opcode); + expect(order.indexOf(TerminalStreamOpcode.Output)).toBeGreaterThan( + order.indexOf(TerminalStreamOpcode.ReplayEnd), + ); + expect(viewers.ended(phone)).toHaveLength(0); + expect(viewers.ended(desktop)).toHaveLength(0); + } finally { + viewers.finish(); + await viewers.delivery.close(); + } +}, 20000); + +it.each([true, false])( + "queues replay independently of app delivery past the worker deadline (chunked=%s)", + async (chunked) => { + const paseoHome = createTemporaryHome(); + // Slow program identification belongs to setup, not the replay request deadline. + // The POSIX shim deterministically exceeds 800 ms; Windows keeps its real OS path. + manager = isPlatform("win32") + ? await startRealWorkerManager({ paseoHome }) + : await startWorkerWithSlowProcessTable(1, { paseoHome }); + const created = await manager.createTerminal({ + cwd: paseoHome, + workspaceId: "slow-delivery", + ...nodeTerminalCommand( + 'process.stdout.write("READY\\r\\n"); process.stdin.on("data", data => process.stdout.write("ECHO:" + data)); setInterval(() => {}, 1000);', + ), + }); + await waitForCondition( + async () => (await manager!.captureTerminal(created.id)).lines.join("").includes("READY"), + 10000, + ); + await manager.detach(); + manager = await startRealWorkerManager({ paseoHome, requestTimeoutMs: 800 }); + const session = manager.getTerminal(created.id); + if (!session) throw new Error("Replay fixture did not adopt its prepared terminal"); + trackTerminal(session); + const viewers = terminalViewers(manager, session.id, chunked); + const phone = {}; + viewers.block(phone); + try { + await viewers.attach(phone, "slow-phone"); + await waitForCondition(viewers.started, 10000); + await new Promise((resolve) => setTimeout(resolve, 1100)); + expect(viewers.ended(phone)).toHaveLength(0); + expect((await manager.captureTerminal(session.id)).lines.join("")).toContain("READY"); + session.send({ type: "input", data: "AFTER-DEADLINE\r" }); + await waitForCondition(() => viewers.text(phone).includes("ECHO:AFTER-DEADLINE"), 3000); + } finally { + viewers.finish(); + await viewers.delivery.close(); + } + }, + 20000, +); + +it.each(["replacement", "socket-overflow"] as const)( + "recovers an attached restore viewer after worker %s", + async (loss) => { + const paseoHome = createTemporaryHome(); + const drained = join(paseoHome, "burst-drained"); + manager = await startRealWorkerManager({ paseoHome }); + const session = trackTerminal( + await manager.createTerminal({ + cwd: paseoHome, + workspaceId: "attached-reconnect", + ...nodeTerminalCommand( + `process.stdout.write("RETAINED\\r\\n"); process.stdin.on("data", data => { + if (data.toString().includes("BURST")) process.stdout.write("x".repeat(12 * 1024 * 1024) + "\\r\\nRETAINED-AFTER-LOSS\\r\\n", () => require("node:fs").writeFileSync(${JSON.stringify(drained)}, "done")); + else process.stdout.write("ECHO:" + data); + }); setInterval(() => {}, 1000);`, + ), + }), + ); + await waitForCondition( + async () => (await manager!.captureTerminal(session.id)).lines.join("").includes("RETAINED"), + 10000, + ); + const viewers = terminalViewers(manager, session.id); + const desktop = {}; + try { + await viewers.attach(desktop, "desktop"); + await waitForCondition(() => viewers.replayed(desktop), 10000); + viewers.frames.length = 0; + const [record] = await workerRecords(paseoHome); + if (loss === "replacement") { + const competitor = await connectTerminalWorker({ + socketPath: record!.socketPath, + secret: record!.secret, + }); + await waitForCondition(() => !competitor.connected, 10000); + } else { + session.send({ type: "input", data: "BURST\r" }); + // Stall only the daemon event loop. The detached worker and PTY keep running, + // filling the bounded worker socket and forcing its disconnect/re-adoption path. + const stall = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + 10000; + while (!existsSync(drained) && Date.now() < deadline) Atomics.wait(stall, 0, 0, 25); + expect(existsSync(drained)).toBe(true); + } + await waitForCondition(() => viewers.replayed(desktop), 10000); + const replay = viewers.frames + .filter((row) => row.frame.opcode === TerminalStreamOpcode.ReplayData) + .map((row) => Buffer.from(row.frame.payload).toString("utf8")) + .join(""); + expect(replay).toContain("RETAINED"); + if (loss === "socket-overflow") expect(replay).toContain("RETAINED-AFTER-LOSS"); + expect((await workerRecords(paseoHome)).map((worker) => worker.pid)).toEqual([record!.pid]); + session.send({ type: "input", data: "RECOVERED\r" }); + await waitForCondition(() => viewers.text(desktop).includes("ECHO:RECOVERED"), 3000); + expect(viewers.ended(desktop)).toHaveLength(0); + expect(manager.getTerminal(session.id)).toBe(session); + } finally { + await viewers.delivery.close(); + } + }, + 30000, +); + +it.each([true, false])( + "keeps live modes after a replay captured enabled=%s while restoring its own modes first", + async (enabled) => { + const on = "\x1b[?1h\x1b[?2004h"; + const off = "\x1b[?1l\x1b[?2004l"; + const captured = enabled ? on : ""; + const latest = enabled ? "" : on; + const live = (enabled ? off : on) + "MODE-LIVE"; + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ worker, requestTimeoutMs: 1000 }); + worker.emitWorkerMessage({ + type: "terminalCreated", + terminal: { + id: "mode-terminal", + name: "Terminal", + cwd: "/tmp", + workspaceId: "modes", + activity: null, + }, + state: createTerminalState(), + }); + worker.emitWorkerMessage({ + type: "terminalMessage", + terminalId: "mode-terminal", + replayPreamble: captured, + message: { type: "snapshotReady", revision: 1 }, + }); + const session = manager.getTerminal("mode-terminal")!; + const viewers = terminalViewers(manager, session.id); + const restoring = {}, + following = {}; + try { + await viewers.attach(restoring, "restoring"); + await waitForCondition( + () => worker.sentMessages.some((message) => message.type === "replayTerminal"), + 1000, + ); + const request = worker.sentMessages.find((message) => message.type === "replayTerminal")!; + const metadata = { + rows: 30, + cols: 120, + retainedHistoryLines: 10, + revision: 1, + replayPreamble: captured, + }; + worker.emitWorkerMessage({ + type: "terminalReplay", + requestId: request.requestId, + event: { type: "begin", metadata }, + }); + worker.emitWorkerMessage({ + type: "terminalReplay", + requestId: request.requestId, + event: { type: "data", data: Buffer.from("CAPTURE-A") }, + }); + worker.emitWorkerMessage({ + type: "terminalMessage", + terminalId: session.id, + replayPreamble: latest, + message: { type: "output", data: live, revision: 2 }, + }); + expect(session.getReplayPreamble()).toBe(latest); + worker.emitWorkerMessage({ + type: "terminalReplay", + requestId: request.requestId, + event: { type: "data", data: Buffer.from("CAPTURE-B") }, + }); + worker.emitWorkerMessage({ + type: "terminalReplay", + requestId: request.requestId, + event: { type: "end" }, + }); + worker.emitWorkerMessage({ + type: "response", + requestId: request.requestId, + ok: true, + result: metadata, + }); + await waitForCondition(() => viewers.text(restoring).includes("MODE-LIVE"), 1000); + expect(session.getReplayPreamble()).toBe(latest); + expect(viewers.text(restoring)).toBe(captured + live); + const sequence = viewers.frames + .filter((row) => row.source === restoring) + .map((row) => row.frame.opcode); + expect(sequence.indexOf(TerminalStreamOpcode.Output)).toBeGreaterThan( + sequence.indexOf(TerminalStreamOpcode.ReplayEnd), + ); + const modes = new TerminalInputModeTracker(); + modes.feed(session.getReplayPreamble()); + expect(encodeTerminalKeyInput({ key: "ArrowUp" }, { inputMode: modes.getState() })).toBe( + enabled ? "\x1b[A" : "\x1bOA", + ); + expect(modes.getState().bracketedPaste).toBe(!enabled); + await viewers.attach(following, "live-following", { mode: "live" }); + await waitForCondition(() => latest === "" || viewers.text(following).length > 0, 1000); + expect(viewers.text(following)).toBe(latest); + expect( + worker.sentMessages.filter((message) => message.type === "replayTerminal"), + ).toHaveLength(1); + } finally { + await viewers.delivery.close(); + } + }, +); + +it.each([3000, 12050])( + "retains ten thousand history rows and restores exact demands after %s printed rows", + async (count) => { + manager = await startRealWorkerManager(); + const gate = createTemporaryHome(); + const releaseNewline = join(gate, "newline"); + const session = trackTerminal( + await manager.createTerminal({ + cwd: createTemporaryHome(), + workspaceId: "ten-thousand", + rows: 4, + cols: 80, + ...nodeTerminalCommand( + `const fs=require("node:fs"); + const watcher=fs.watch(${JSON.stringify(gate)},()=>{ + if(fs.existsSync(${JSON.stringify(releaseNewline)})){ + watcher.close();process.stdout.write("\\r\\nROWS-COMPLETE"); + } + }); + for(let i=0;i<${count};i++) process.stdout.write("\\x1b[31mrow-"+String(i).padStart(5,"0")+" 界é🙂\\x1b[0m"+(i===${count}-1?"":"\\r\\n")); + setInterval(()=>{},1000)`, + ), + }), + ); + await waitForCondition( + async () => + (await manager!.captureTerminal(session.id, { start: -5 })).lines + .join("") + .includes("row-" + String(count - 1).padStart(5, "0")), + 15000, + ); + // A visible final label can precede its trailing CRLF. Hold that transition + // deliberately, then observe content on the following row before capture. + writeFileSync(releaseNewline, "release"); + await waitForCondition( + async () => + (await manager!.captureTerminal(session.id, { start: -1 })).lines[0] === "ROWS-COMPLETE", + 15000, + ); + for (const history of [0, 200, 1000, 10000]) { + const chunks: Uint8Array[] = []; + const metadata = await manager.replayTerminal( + session.id, + { scrollbackLines: history }, + (event) => { + if (event.type === "data") chunks.push(event.data); + }, + ); + expect(metadata).toMatchObject({ + rows: 4, + cols: 80, + retainedHistoryLines: Math.min(10000, count - 3), + }); + expect(chunks.every((chunk) => chunk.byteLength <= 64 * 1024)).toBe(true); + const restored = new xterm.Terminal({ + rows: 4, + cols: 80, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => restored.write(Buffer.concat(chunks), resolve)); + const buffer = restored.buffer.active; + const labels = Array.from({ length: buffer.length }, (_, i) => + buffer.getLine(i)!.translateToString(true), + ).filter((line) => line.includes("row-")); + expect({ row: buffer.cursorY, col: buffer.cursorX }).toEqual({ row: 3, col: 13 }); + expect(buffer.getLine(buffer.length - 1)!.translateToString(true)).toBe("ROWS-COMPLETE"); + expect(labels).toHaveLength(Math.min(count, history + 3)); + expect(labels[0]).toContain("row-" + String(count - labels.length).padStart(5, "0")); + expect(labels.at(-1)).toContain("row-" + String(count - 1).padStart(5, "0") + " 界é🙂"); + } finally { + restored.dispose(); + } + } + }, + 30000, +); + +it.each([false, true])( + "shrinks real ten-thousand-row pressure recovery and preserves reattach (chunked=%s)", + async (chunked) => { + manager = await startRealWorkerManager(); + const session = trackTerminal( + await manager.createTerminal({ + cwd: createTemporaryHome(), + workspaceId: "pressure-history", + rows: 4, + cols: 120, + ...nodeTerminalCommand(` + process.stdin.setRawMode(true); + let row = 0; + function print(count) { + for(let i=0;i { + if(data.toString().includes("BURST")) { print(4000); process.stdout.write("BURST-DONE\\r\\n"); } + else process.stdout.write("ECHO:" + data.toString() + "\\r\\n"); + }); + setInterval(()=>{},1000); + `), + }), + ); + await waitForCondition( + async () => + (await manager!.captureTerminal(session.id, { start: -5 })).lines + .join("") + .includes("READY"), + 15000, + ); + const desktop = {}, + phone = {}, + fresh = {}; + let pressure = false; + const viewers = terminalViewers(manager, session.id, chunked, (source) => + source === phone && pressure ? 8 * 1024 * 1024 : 0, + ); + const restore = { mode: "full-snapshot" as const, scrollbackLines: 10000 }; + function payloads(source: object): Buffer[] { + const result: Buffer[] = []; + let chunks: Uint8Array[] = []; + for (const { source: owner, frame } of viewers.frames) { + if (owner !== source) continue; + if (frame.opcode === TerminalStreamOpcode.Restore) result.push(Buffer.from(frame.payload)); + if (frame.opcode === TerminalStreamOpcode.ReplayBegin) chunks = []; + if (frame.opcode === TerminalStreamOpcode.ReplayData) chunks.push(frame.payload); + if (frame.opcode === TerminalStreamOpcode.ReplayEnd) result.push(Buffer.concat(chunks)); + } + return result; + } + async function parse(payload: Uint8Array) { + const receiver = new xterm.Terminal({ + rows: 4, + cols: 120, + scrollback: 10000, + allowProposedApi: true, + }); + try { + await new Promise((resolve) => receiver.write(payload, resolve)); + const buffer = receiver.buffer.active; + return { + history: buffer.baseY, + text: Array.from({ length: buffer.length }, (_, i) => + buffer.getLine(i)!.translateToString(true), + ).join("\n"), + }; + } finally { + receiver.dispose(); + } + } + try { + await viewers.attach(desktop, "healthy", restore); + await viewers.attach(phone, "pressured", restore); + await waitForCondition( + () => payloads(phone).length === 1 && payloads(desktop).length === 1, + 15000, + ); + const initial = payloads(phone)[0]!; + expect((await parse(initial)).history).toBe(10000); + pressure = true; + session.send({ type: "input", data: "BURST" }); + await waitForCondition(() => payloads(phone).length >= 2, 15000); + pressure = false; + // The final burst marker may belong to the replay cut or to its live tail. + // Read the same replay/output exchange a renderer sees rather than assuming + // worker parsing and daemon request scheduling cut the burst at one point. + async function currentView(source: object) { + const frames = viewers.frames + .filter((row) => row.source === source) + .map((row) => row.frame); + const start = frames.findLastIndex( + (frame) => + frame.opcode === TerminalStreamOpcode.Restore || + frame.opcode === TerminalStreamOpcode.ReplayBegin, + ); + return parse( + Buffer.concat( + frames + .slice(start) + .filter( + (frame) => + frame.opcode === TerminalStreamOpcode.Restore || + frame.opcode === TerminalStreamOpcode.ReplayData || + frame.opcode === TerminalStreamOpcode.Output, + ) + .map((frame) => frame.payload), + ), + ); + } + await waitForCondition( + async () => + (await currentView(phone)).text.includes("BURST-DONE") && + (await currentView(desktop)).text.includes("BURST-DONE"), + 15000, + ); + const recovery = payloads(phone)[1]!; + const recovered = await parse(recovery); + expect(recovered.history).toBe(500); + expect(recovered.text).toContain("界é🙂"); + for (const source of [phone, desktop]) { + const indexes = [...(await currentView(source)).text.matchAll(/row-(\d+)/g)].map((match) => + Number(match[1]), + ); + expect(indexes.at(-1)).toBe(16049); + expect(indexes).toEqual(Array.from({ length: indexes.length }, (_, i) => indexes[0]! + i)); + } + expect(recovery.byteLength).toBeLessThan(initial.byteLength / 10); + expect(recovery.byteLength).toBeLessThan(256 * 1024); + expect(payloads(desktop)).toHaveLength(1); // The healthy peer keeps its full replay and live stream. + session.send({ type: "input", data: "AFTER-CATCH-UP" }); + await waitForCondition( + () => + viewers.text(phone).includes("ECHO:AFTER-CATCH-UP") && + viewers.text(desktop).includes("ECHO:AFTER-CATCH-UP"), + 10000, + ); + expect(viewers.text(phone).match(/ECHO:AFTER-CATCH-UP/g)).toHaveLength(1); + expect(viewers.text(desktop).match(/ECHO:AFTER-CATCH-UP/g)).toHaveLength(1); + await viewers.delivery.detach(phone); + await viewers.attach(fresh, "reattach", restore); + await waitForCondition(() => payloads(fresh).length === 1, 15000); + const reattached = await parse(payloads(fresh)[0]!); + expect(reattached.history).toBe(10000); + expect(reattached.text).toContain("row-16049"); + expect(reattached.text).toContain("ECHO:AFTER-CATCH-UP"); + expect(viewers.ended(desktop)).toHaveLength(0); + expect(viewers.ended(phone)).toHaveLength(0); + process.stdout.write( + JSON.stringify({ + chunked, + initialBytes: initial.byteLength, + recoveryBytes: recovery.byteLength, + reattachBytes: payloads(fresh)[0]!.byteLength, + retainedHistory: reattached.history, + recoveryHistory: recovered.history, + }) + "\n", + ); + } finally { + await viewers.delivery.close(); + } + }, + 45000, +); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 0a1adb2c44..9a82826cec 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -1,8 +1,12 @@ -import { fileURLToPath } from "node:url"; -import { fork } from "node:child_process"; +import type { TerminalReplaySink, TerminalReplayMetadata } from "./terminal-replay.js"; import { randomBytes, randomUUID } from "node:crypto"; +import { access } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import type { Logger } from "pino"; +import { createExternalProcessEnv } from "../server/paseo-env.js"; import { assertAbsolutePath, isSameOrDescendantPath } from "../server/path-utils.js"; -import type { TerminalState } from "@getpaseo/protocol/messages"; +import { writtenDuringThisBoot } from "../server/pid-lock.js"; +import type { WorkerTerminalState } from "./terminal-worker-protocol.js"; import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity"; import type { @@ -12,9 +16,29 @@ import type { TerminalCommandFinishedInfo, TerminalExitInfo, TerminalSession, - TerminalStateSnapshot, } from "./terminal.js"; +import { resolvePaseoCliExecutablePath } from "./paseo-cli-path.js"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; +import { connectTerminalWorker, type TerminalWorkerConnection } from "./terminal-worker-client.js"; +import { launchTerminalWorker } from "./terminal-worker-launch.js"; +import { classifyTerminalWorkerProcess } from "./terminal-worker-ownership.js"; +import { + createTerminalWorkerProcessInspector, + type TerminalWorkerProcessInspector, +} from "./terminal-worker-processes.js"; +import { reapTerminalWorkers } from "./terminal-worker-reaper.js"; +import { + listTerminalWorkerRecords, + terminalWorkerRecordPath, + type TerminalWorkerRecord, +} from "./terminal-worker-record.js"; +import { + createTerminalWorkerPool, + NoTerminalWorkerAvailableError, + TerminalWorkerIncompatibleError, + type TerminalWorkerPoolMember, +} from "./terminal-worker-pool.js"; +import { pruneTerminalWorkerRuntimes } from "./terminal-worker-runtime.js"; import type { TerminalActivityListener, TerminalActivityTransitionEvent, @@ -25,12 +49,16 @@ import type { TerminalsChangedEvent, TerminalsChangedListener, } from "./terminal-manager.js"; +import { + classifyTerminalWorkerProtocol, + type TerminalWorkerProtocolFit, +} from "./terminal-worker-protocol.js"; import type { + TerminalWorkerListResult, TerminalWorkerRequest, TerminalWorkerResponse, TerminalWorkerToParentMessage, WorkerCreateTerminalOptions, - TerminalWorkerStateResult, WorkerTerminalInfo, } from "./terminal-worker-protocol.js"; @@ -64,7 +92,7 @@ interface PendingRequest { interface WorkerTerminalRecord { info: RequiredWorkerTerminalInfo; - state: TerminalState; + state: WorkerTerminalState; activity: TerminalActivity | null; // Cached input-mode preamble from the worker (the authoritative tracker lives // in the worker process). Updated with streamed output and snapshot responses. @@ -78,49 +106,45 @@ interface WorkerTerminalRecord { session: TerminalSession; } -interface TerminalWorkerProcess { - connected: boolean; - killed: boolean; - send(message: TerminalWorkerRequest, callback: (error: Error | null) => void): boolean; - disconnect(): void; - kill(): boolean; - on(event: "message", listener: (message: TerminalWorkerToParentMessage) => void): this; - on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; +export interface TerminalCreateEnvironment { + baseEnv: Record; + paseoCliPath: string | null; } interface WorkerTerminalManagerOptions { + worker: TerminalWorkerConnection; + /** Reconnects a surviving worker; null means it is confirmed gone or the owner stopped. */ + reconnect?: () => Promise; requestTimeoutMs?: number; - forkWorker?: () => TerminalWorkerProcess; getTerminalActivityUrl?: () => string | null; + resolveCreateEnvironment?: () => TerminalCreateEnvironment; + // The kill path. The daemon passes the reaper, which verifies every program is gone; the + // default asks the connected worker to shut down. + closeTerminals?: () => Promise; +} + +/** The daemon's terminal manager: its terminals live in a worker that can outlive it. */ +export interface DaemonTerminalManager extends TerminalManager { + /** Registers every terminal the connected worker already runs. Resolves with their count. */ + adoptTerminals(): Promise; + /** Kills every terminal and verifies their programs ended. */ + closeTerminals(): Promise; + /** + * Lets go of the worker; its terminals keep running for the next daemon. With + * `afterPendingRequests`, requests already sent get their answers first. + */ + detach(options?: { afterPendingRequests?: boolean }): Promise; } function createActivityToken(): string { return randomBytes(32).toString("base64url"); } -function resolveWorkerUrl(): URL { - const currentUrl = import.meta.url; - if (currentUrl.endsWith(".ts")) { - return new URL("./terminal-worker-process.ts", currentUrl); - } - return new URL("./terminal-worker-process.js", currentUrl); -} - -function resolveWorkerExecArgv(): string[] { - if (!import.meta.url.endsWith(".ts")) { - return []; - } - const loaderUrl = new URL("./terminal-ts-loader.mjs", import.meta.url).href; - const importSource = [ - 'import { register } from "node:module";', - 'import { pathToFileURL } from "node:url";', - `register(${JSON.stringify(loaderUrl)}, pathToFileURL("./"));`, - ].join(" "); - return [ - "--experimental-strip-types", - "--import", - `data:text/javascript,${encodeURIComponent(importSource)}`, - ]; +function resolveDaemonTerminalCreateEnvironment(): TerminalCreateEnvironment { + return { + baseEnv: createExternalProcessEnv(process.env), + paseoCliPath: resolvePaseoCliExecutablePath(), + }; } function isResponse(message: TerminalWorkerToParentMessage): message is TerminalWorkerResponse { @@ -138,20 +162,18 @@ function cloneTerminalInfo(info: RequiredWorkerTerminalInfo): RequiredWorkerTerm }; } -function forkTerminalWorker(): TerminalWorkerProcess { - return fork(fileURLToPath(resolveWorkerUrl()), [], { - execArgv: resolveWorkerExecArgv(), - serialization: "advanced", - stdio: ["ignore", "ignore", "inherit", "ipc"], - }) as TerminalWorkerProcess; -} - export function createWorkerTerminalManager( - managerOptions: WorkerTerminalManagerOptions = {}, -): TerminalManager { - const worker = managerOptions.forkWorker ? managerOptions.forkWorker() : forkTerminalWorker(); + managerOptions: WorkerTerminalManagerOptions, +): DaemonTerminalManager { + let worker = managerOptions.worker; + let detached = false; + let reconnecting: Promise | null = null; const requestTimeoutMs = managerOptions.requestTimeoutMs ?? REQUEST_TIMEOUT_MS; + const resolveCreateEnvironment = + managerOptions.resolveCreateEnvironment ?? resolveDaemonTerminalCreateEnvironment; + const replaySinks = new Map(); const pendingRequests = new Map(); + const inFlight = new Set>(); const recordsById = new Map(); const terminalIdsByCwd = new Map>(); const terminalActivityTokenById = new Map(); @@ -162,8 +184,8 @@ export function createWorkerTerminalManager( const terminalActivityListeners = new Set(); const terminalWorkspaceContributionChangedListeners = new Set(); - let workerExited = false; - let workerShutdownTimer: ReturnType | null = null; + let workerDisconnected = false; + let closePromise: Promise | null = null; function emitTerminalsChanged(event: TerminalsChangedEvent): void { for (const listener of Array.from(terminalsChangedListeners)) { @@ -222,7 +244,7 @@ export function createWorkerTerminalManager( function registerRecord(input: { info: RequiredWorkerTerminalInfo; - state: TerminalState; + state: WorkerTerminalState; }): TerminalSession { const existing = recordsById.get(input.info.id); if (existing) { @@ -268,18 +290,11 @@ export function createWorkerTerminalManager( } sendBestEffortRequest({ type: "send", terminalId: record.info.id, message }); }, - subscribe( - listener: (msg: ServerMessage) => void, - options?: { initialSnapshot?: "state" | "ready" }, - ): () => void { + subscribe(listener: (msg: ServerMessage) => void): () => void { record.messageListeners.add(listener); - if (options?.initialSnapshot === "ready") { - queueMicrotask(() => { - if (record.messageListeners.has(listener)) { - listener({ type: "snapshotReady", revision: 0 }); - } - }); - } + queueMicrotask(() => { + if (record.messageListeners.has(listener)) listener({ type: "snapshotReady" }); + }); return () => { record.messageListeners.delete(listener); }; @@ -340,19 +355,8 @@ export function createWorkerTerminalManager( cols: record.state.cols, }; }, - getState(): TerminalState { - return record.state; - }, - getStateSnapshot(options?: { scrollbackLines?: number }): TerminalStateSnapshot { - const scrollbackLines = options?.scrollbackLines; - const scrollback = - typeof scrollbackLines === "number" - ? record.state.scrollback.slice(-scrollbackLines) - : record.state.scrollback; - return { - state: { ...record.state, scrollback }, - revision: 0, - }; + hasOutput(): boolean { + return record.state.hasOutput; }, getReplayPreamble(): string { return record.replayPreamble; @@ -420,9 +424,7 @@ export function createWorkerTerminalManager( if (!record) { return; } - if (message.message.type === "snapshot") { - record.state = message.message.state; - } + if (message.message.type === "output") record.state.hasOutput = true; record.replayPreamble = message.replayPreamble; for (const listener of Array.from(record.messageListeners)) { listener(message.message); @@ -587,46 +589,106 @@ export function createWorkerTerminalManager( } } - worker.on("message", (message: TerminalWorkerToParentMessage) => { - if (isResponse(message)) { - const pending = pendingRequests.get(message.requestId); - if (!pending) { + function attachWorker(): void { + const connection = worker; + connection.on("message", (message: TerminalWorkerToParentMessage) => { + if (message.type === "terminalReplay") { + // Consumption queues into the application's bounded transport synchronously. + // Never wait for app network delivery or accumulate a second replay queue here. + if (worker !== connection || !pendingRequests.has(message.requestId)) return; + try { + replaySinks.get(message.requestId)?.(message.event); + } catch (error) { + const pending = pendingRequests.get(message.requestId)!; + clearTimeout(pending.timeout); + pendingRequests.delete(message.requestId); + replaySinks.delete(message.requestId); + pending.reject(error instanceof Error ? error : new Error(String(error))); + } return; } - clearTimeout(pending.timeout); - pendingRequests.delete(message.requestId); - if (message.ok) { - pending.resolve(message.result); - } else { - pending.reject(new Error(message.error)); + if (isResponse(message)) { + const pending = pendingRequests.get(message.requestId); + if (!pending) { + return; + } + clearTimeout(pending.timeout); + pendingRequests.delete(message.requestId); + if (message.ok) { + pending.resolve(message.result); + } else { + pending.reject(new Error(message.error)); + } + return; } - return; - } - handleWorkerEvent(message); - }); + handleWorkerEvent(message); + }); - worker.on("exit", (code, signal) => { - workerExited = true; - if (workerShutdownTimer) { - clearTimeout(workerShutdownTimer); - workerShutdownTimer = null; - } - rejectPendingRequests(new Error(`Terminal worker exited (${signal ?? code ?? "unknown"})`)); - }); + connection.on("close", () => { + if (worker !== connection) return; + workerDisconnected = true; + rejectPendingRequests(new Error("Terminal worker connection closed")); + const reconnect = managerOptions.reconnect; + if (!detached && reconnect) { + reconnecting = (async () => { + const recovered = await reconnect(); + if (!recovered) { + for (const terminalId of Array.from(recordsById.keys())) { + handleTerminalExitEvent({ + type: "terminalExit", + terminalId, + info: { exitCode: null, signal: null, lastOutputLines: [] }, + }); + } + return; + } + if (detached) { + recovered.disconnect(); + return; + } + worker = recovered; + workerDisconnected = false; + attachWorker(); + await adoptTerminals(); + // Output emitted without a controller is retained in the worker's screen. Existing + // streams must fetch that screen before applying further live output. + for (const record of recordsById.values()) { + handleTerminalMessageEvent({ + type: "terminalMessage", + terminalId: record.info.id, + message: { type: "snapshotReady" }, + replayPreamble: record.replayPreamble, + }); + } + })().finally(() => { + reconnecting = null; + }); + void reconnecting.catch(() => {}); + } + }); + } + attachWorker(); - function sendRequest(input: TerminalWorkerRequestInput): Promise { - if (workerExited || !worker.connected) { + function sendRequest( + input: TerminalWorkerRequestInput, + sink?: TerminalReplaySink, + ): Promise { + if (reconnecting && workerDisconnected) + return reconnecting.then(() => sendRequest(input, sink)); + if (workerDisconnected || !worker.connected) { return Promise.reject(new Error("Terminal worker is not running")); } + const connection = worker; const requestId = randomUUID(); + if (sink) replaySinks.set(requestId, sink); const message = { ...input, requestId } as TerminalWorkerRequest; - return new Promise((resolve, reject) => { + const request = new Promise((resolve, reject) => { const timeout = setTimeout(() => { pendingRequests.delete(requestId); reject(new Error(`Terminal worker request timed out: ${input.type}`)); }, requestTimeoutMs); pendingRequests.set(requestId, { resolve, reject, timeout }); - worker.send(message, (error) => { + connection.send(message, (error) => { if (!error) { return; } @@ -635,6 +697,17 @@ export function createWorkerTerminalManager( reject(error); }); }); + // Tracks settlement only; the caller handles the rejection. + const settled = request.then( + () => undefined, + () => undefined, + ); + inFlight.add(settled); + void settled.finally(() => { + inFlight.delete(settled); + replaySinks.delete(requestId); + }); + return request; } function sendBestEffortRequest(input: TerminalWorkerRequestInput): void { @@ -645,6 +718,71 @@ export function createWorkerTerminalManager( }); } + function clearRecords(): void { + for (const terminalId of Array.from(recordsById.keys())) { + removeRecord(terminalId); + } + } + + function disconnectWorker(): Promise { + if (workerDisconnected || !worker.connected) { + return Promise.resolve(); + } + return new Promise((resolve) => { + worker.on("close", () => resolve()); + worker.disconnect(); + }); + } + + function closeTerminals(): Promise { + detached = true; + closePromise ??= (async () => { + await reconnecting; + if (managerOptions.closeTerminals) { + await disconnectWorker(); + clearRecords(); + await managerOptions.closeTerminals(); + return; + } + // Unverified: production passes the reaper, which confirms every program ended. + sendBestEffortRequest({ type: "shutdown" }); + await disconnectWorker(); + clearRecords(); + })(); + return closePromise; + } + + async function adoptTerminals(): Promise { + const listed = (await sendRequest({ type: "listTerminals" })) as TerminalWorkerListResult; + const cwds = new Set(); + const liveIds = new Set(listed.map((item) => item.terminal.id)); + for (const terminalId of Array.from(recordsById.keys())) { + if (!liveIds.has(terminalId)) + handleTerminalExitEvent({ + type: "terminalExit", + terminalId, + info: { exitCode: null, signal: null, lastOutputLines: [] }, + }); + } + for (const item of listed) { + const info = asRequiredWorkerTerminalInfo(item.terminal); + registerRecord({ info, state: item.state }); + const record = recordsById.get(info.id); + if (record) { + record.replayPreamble = item.replayPreamble; + } + // The agent hooks inside each shell keep the token they were started with. + if (item.activityToken) { + terminalActivityTokenById.set(info.id, item.activityToken); + } + cwds.add(info.cwd); + } + for (const cwd of cwds) { + emitTerminalsChanged({ cwd, terminals: listTerminalItemsForCwd(cwd) }); + } + return listed.length; + } + return { async getTerminals( cwd: string, @@ -688,6 +826,7 @@ export function createWorkerTerminalManager( const creation = { session: null as TerminalSession | null }; pendingCreationsById.set(terminalId, creation); try { + const environment = resolveCreateEnvironment(); await sendRequest({ type: "createTerminal", options: { @@ -695,6 +834,8 @@ export function createWorkerTerminalManager( id: terminalId, activityToken, activityUrl: terminalActivityUrl, + baseEnv: environment.baseEnv, + paseoCliPath: environment.paseoCliPath, }, }); if (!creation.session) { @@ -732,22 +873,12 @@ export function createWorkerTerminalManager( return recordsById.get(id)?.session; }, - async getTerminalState( - id: string, - options?: { scrollbackLines?: number }, - ): Promise { - const snapshot = (await sendRequest({ - type: "getTerminalState", - terminalId: id, - ...(options ? { options } : {}), - })) as TerminalWorkerStateResult; - if (snapshot && snapshot.replayPreamble !== undefined) { - const record = recordsById.get(id); - if (record) { - record.replayPreamble = snapshot.replayPreamble; - } - } - return snapshot; + async replayTerminal(id, options, sink): Promise { + const metadata = (await sendRequest( + { type: "replayTerminal", terminalId: id, options }, + sink, + )) as TerminalReplayMetadata | null; + return metadata; }, setTerminalTitle(id: string, title: string): boolean { @@ -812,25 +943,25 @@ export function createWorkerTerminalManager( }, killAll(): void { - void sendRequest({ type: "killAll" }) - .catch(() => { - // no-op - }) - .finally(() => { - if (worker.connected) { - worker.disconnect(); - } - if (!worker.killed && !workerShutdownTimer) { - workerShutdownTimer = setTimeout(() => { - worker.kill(); - }, 1000); - } - }); - for (const terminalId of Array.from(recordsById.keys())) { - removeRecord(terminalId); - } + void closeTerminals().catch(() => { + // The synchronous interface cannot report it; closeTerminals() callers see the error. + }); }, + closeTerminals, + + async detach(options?: { afterPendingRequests?: boolean }): Promise { + detached = true; + await reconnecting; + if (options?.afterPendingRequests) { + await Promise.allSettled(Array.from(inFlight)); + } + await disconnectWorker(); + clearRecords(); + }, + + adoptTerminals, + subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void { terminalsChangedListeners.add(listener); return () => { @@ -856,6 +987,342 @@ export function createWorkerTerminalManager( }; } -export function terminateWorkerTerminalManager(manager: TerminalManager): void { - manager.killAll(); +export interface StartWorkerTerminalManagerOptions { + paseoHome: string; + paseoVersion: string; + logger: Logger; + getTerminalActivityUrl?: () => string | null; + requestTimeoutMs?: number; +} + +// The current worker plus one draining a previous protocol. Every live worker counts, +// incompatible, unreachable, unidentified and closing ones included: the daemon never starts a +// third. +const MAX_TERMINAL_WORKERS = 2; +const DRAINED_WORKER_EXIT_TIMEOUT_MS = 10_000; + +interface LiveWorkerRecord { + path: string; + record: TerminalWorkerRecord; + fit: TerminalWorkerProtocolFit; +} + +/** + * Connects the daemon to its terminal workers. A worker of this daemon's protocol is adopted + * (restart, crash, `stop --keep-terminals`); one a protocol older is adopted to drain; anything + * older is shut down, its terminals closed; one from a newer Paseo is left running and the + * terminal service reports it as incompatible. A worker is launched only when none is current, + * none is incompatible and fewer than two run; when two run, the first terminal created after one + * of them exits launches it. Records that cannot be adopted (a cleanup that + * never finished, a dead worker whose programs survived it) go through the reaper first, so a + * daemon never adopts terminals someone asked to close. + */ +export async function startWorkerTerminalManager( + options: StartWorkerTerminalManagerOptions, +): Promise { + const logger = options.logger.child({ module: "terminal-worker" }); + const inspector = createTerminalWorkerProcessInspector(); + const log = (message: string, fields?: Record) => logger.info(fields, message); + const live: LiveWorkerRecord[] = []; + const toReap: string[] = []; + for (const file of await listTerminalWorkerRecords(options.paseoHome)) { + if ("error" in file) { + logger.warn({ recordPath: file.path, err: file.error }, "Unreadable terminal worker record"); + continue; + } + const { record } = file; + if ( + record.closing || + !writtenDuringThisBoot({ startedAt: record.createdAt, bootId: record.bootId }) + ) { + toReap.push(file.path); + continue; + } + const worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + if (worker.status === "owned") { + live.push({ + path: file.path, + record, + fit: classifyTerminalWorkerProtocol(record.protocolVersion), + }); + } else if (worker.status === "unknown") { + logger.warn( + { recordPath: file.path, pid: record.pid, reason: worker.reason }, + "Cannot identify terminal worker; leaving its record", + ); + } else { + // Its programs lost their PTY with the worker and cannot be adopted. + toReap.push(file.path); + } + } + + if (toReap.length > 0) { + await reapTerminalWorkers({ + paseoHome: options.paseoHome, + gracefulMs: 0, + recordPaths: toReap, + inspector, + log, + }).catch((error: unknown) => { + logger.warn({ err: error }, "Terminal cleanup of an unadoptable worker was not confirmed"); + }); + } + + const obsolete = live.filter((entry) => entry.fit === "obsolete"); + if (obsolete.length > 0) { + for (const entry of obsolete) { + logger.warn( + { + pid: entry.record.pid, + protocolVersion: entry.record.protocolVersion, + paseoVersion: entry.record.paseoVersion, + terminals: entry.record.terminals.length, + }, + "Closing a terminal worker two or more protocol versions old; its terminals close", + ); + } + await reapTerminalWorkers({ + paseoHome: options.paseoHome, + recordPaths: obsolete.map((entry) => entry.path), + inspector, + log, + }).catch((error: unknown) => { + logger.warn({ err: error }, "Closing an obsolete terminal worker was not confirmed"); + }); + } + + const closeAll = async () => { + const result = await reapTerminalWorkers({ paseoHome: options.paseoHome, inspector, log }); + logger.info(result, "Terminals closed"); + }; + const prune = async () => { + const result = await pruneTerminalWorkerRuntimes(options.paseoHome); + if (result.removed.length > 0) + log("Removed terminal worker copies", { removed: result.removed }); + for (const failure of result.failed) { + logger.warn(failure, "Cannot remove a terminal worker copy; the next start retries"); + } + }; + + const pruneAfterExit = async (workerPid: number) => { + if (await waitForWorkerRecordRemoval(options.paseoHome, workerPid)) { + await prune(); + return; + } + logger.warn({ pid: workerPid }, "Drained terminal worker has not exited yet"); + }; + + const newer = live.find((entry) => entry.fit === "newer"); + if (newer) { + const incompatible = new TerminalWorkerIncompatibleError({ + paseoVersion: newer.record.paseoVersion, + protocolVersion: newer.record.protocolVersion, + }); + logger.error({ pid: newer.record.pid }, incompatible.message); + await prune(); + return createTerminalWorkerPool({ + current: null, + draining: [], + incompatible, + closeAll, + }); + } + + const createMember = async ( + record: TerminalWorkerRecord, + connection: TerminalWorkerConnection, + ): Promise => { + let alive = true; + let resolveExit!: () => void; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + let recovery: Promise | null = null; + const recoveryState = { stopped: false }; + const reconnect = () => { + recovery ??= (async () => { + while (!recoveryState.stopped) { + const ownership = classifyTerminalWorkerProcess( + record, + await inspector.inspect(record.pid), + ); + if (ownership.status === "gone" || ownership.status === "unrelated") { + await reapTerminalWorkers({ + paseoHome: options.paseoHome, + recordPaths: [terminalWorkerRecordPath(options.paseoHome, record.pid)], + inspector, + log, + gracefulMs: 0, + }).catch((error) => + logger.warn({ err: error }, "Dead terminal worker cleanup was not confirmed"), + ); + alive = false; + resolveExit(); + return null; + } + try { + return await connectTerminalWorker({ + socketPath: record.socketPath, + secret: record.secret, + timeoutMs: 500, + }); + } catch { + await delay(100); + } + } + return null; + })().finally(() => { + recovery = null; + }); + return recovery; + }; + const manager = createWorkerTerminalManager({ + worker: connection, + reconnect, + ...(options.requestTimeoutMs === undefined + ? {} + : { requestTimeoutMs: options.requestTimeoutMs }), + ...(options.getTerminalActivityUrl + ? { getTerminalActivityUrl: options.getTerminalActivityUrl } + : {}), + closeTerminals: closeAll, + }); + const count = await manager.adoptTerminals(); + if (count > 0) { + logger.info({ pid: record.pid, terminals: count }, "Adopted terminals from a running worker"); + } + return { + manager, + workerPid: record.pid, + exited, + ready: async () => { + await recovery; + return alive && !recoveryState.stopped; + }, + stopRecovery: () => { + recoveryState.stopped = true; + }, + }; + }; + + let current: TerminalWorkerPoolMember | null = null; + const draining: TerminalWorkerPoolMember[] = []; + const adoptable = [ + ...live.filter((entry) => entry.fit === "current"), + ...live.filter((entry) => entry.fit === "previous"), + ]; + for (const entry of adoptable) { + let connection: TerminalWorkerConnection; + try { + connection = await connectTerminalWorker({ + socketPath: entry.record.socketPath, + secret: entry.record.secret, + }); + } catch (error) { + logger.warn( + { pid: entry.record.pid, err: error }, + "Cannot reach terminal worker; leaving it running", + ); + continue; + } + const member = await createMember(entry.record, connection); + if (entry.fit === "current" && !current) { + logger.info({ pid: entry.record.pid }, "Adopting running terminal worker"); + current = member; + } else { + // A previous protocol, or a second worker of this one: it keeps its terminals and takes + // no new ones. + logger.info( + { pid: entry.record.pid, protocolVersion: entry.record.protocolVersion }, + "Draining terminal worker; it exits after its last terminal", + ); + draining.push(member); + } + } + + // Counted from the records rather than from what startup drove: a worker a cleanup could not + // confirm gone keeps running, and a draining one exits on its own. + const startCurrent = async (): Promise => { + const running = await countRunningTerminalWorkers(options.paseoHome, inspector); + if (running >= MAX_TERMINAL_WORKERS) { + throw new NoTerminalWorkerAvailableError( + `${running} terminal workers are already running in ${options.paseoHome}; new terminals can open once one of them exits`, + ); + } + const launched = await launchTerminalWorker({ + paseoHome: options.paseoHome, + paseoVersion: options.paseoVersion, + }); + const connection = await connectTerminalWorker({ + socketPath: launched.record.socketPath, + secret: launched.record.secret, + }); + logger.info( + { pid: launched.record.pid, runtime: launched.record.runtime ?? null }, + "Launched terminal worker", + ); + return createMember(launched.record, connection); + }; + + await prune(); + if (!current) { + try { + current = await startCurrent(); + } catch (error) { + if (!(error instanceof NoTerminalWorkerAvailableError)) throw error; + logger.warn(error.message); + } + } + + return createTerminalWorkerPool({ + current, + draining, + incompatible: null, + startCurrent, + closeAll, + onDrained: (member) => { + log("Released drained terminal worker", { pid: member.workerPid }); + void pruneAfterExit(member.workerPid); + }, + }); +} + +// Every worker that may still run in this home, one per record: a worker and its cleanup record +// are listed as one. Unreadable records and workers that cannot be identified count; a worker +// confirmed gone, or a pid reused by another process, does not. +async function countRunningTerminalWorkers( + paseoHome: string, + inspector: TerminalWorkerProcessInspector, +): Promise { + let running = 0; + for (const file of await listTerminalWorkerRecords(paseoHome)) { + if ("error" in file) { + running += 1; + continue; + } + const { record } = file; + if (!writtenDuringThisBoot({ startedAt: record.createdAt, bootId: record.bootId })) continue; + const worker = classifyTerminalWorkerProcess(record, await inspector.inspect(record.pid)); + if (worker.status === "owned" || worker.status === "unknown") running += 1; + } + return running; +} + +// A worker deletes its own record as it exits. +async function waitForWorkerRecordRemoval(paseoHome: string, workerPid: number): Promise { + const recordPath = terminalWorkerRecordPath(paseoHome, workerPid); + const deadline = Date.now() + DRAINED_WORKER_EXIT_TIMEOUT_MS; + while ( + await access(recordPath).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false; + throw error; + }, + ) + ) { + if (Date.now() >= deadline) return false; + await delay(100); + } + return true; } diff --git a/packages/server/src/test-utils/main-thread-work.ts b/packages/server/src/test-utils/main-thread-work.ts new file mode 100644 index 0000000000..66bb55e491 --- /dev/null +++ b/packages/server/src/test-utils/main-thread-work.ts @@ -0,0 +1,108 @@ +import { createHook, executionAsyncResource } from "node:async_hooks"; +import { performance, PerformanceObserver, type PerformanceEntry } from "node:perf_hooks"; +import { MessagePort } from "node:worker_threads"; + +/** Measure main-thread work without charging idle IO or runner descheduling. */ +export async function measureMainThreadWork(label: string, action: () => T | Promise) { + // Node 22.20 supplies this API; the repository's older declarations omit it. + const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( + process, + "threadCpuUsage", + ); + let blockingMs = 0; + let filesystemRequests = 0; + let yields = 0; + let longestCallbackMs = 0; + let longestCallbackCpuMs = 0; + let longestGapMs = 0; + let longestGcMs = 0; + let gcCount = 0; + let workerEvents = 0; + let draining = false; + const startedAt = performance.now(); + let previous = startedAt; + let previousCpu = threadCpuUsage(); + let probe: ReturnType; + let drain: ReturnType; + const callbacks = new Map(); + const record = (wallMs: number, cpu: NodeJS.CpuUsage) => { + const cpuMs = (cpu.user + cpu.system) / 1_000; + // Windows CPU accounting is coarse. Cap charged CPU by the real span so a + // counter increment cannot inflate a short callback. Zero-CPU waits drop out. + blockingMs = Math.max(blockingMs, Math.min(wallMs, cpuMs)); + if (wallMs > longestCallbackMs) { + longestCallbackMs = wallMs; + longestCallbackCpuMs = cpuMs; + } + }; + const observeGc = (entries: readonly PerformanceEntry[]) => { + for (const entry of entries) { + if (entry.startTime < startedAt) continue; + gcCount++; + longestGcMs = Math.max(longestGcMs, entry.duration); + } + }; + const gc = new PerformanceObserver((list) => observeGc(list.getEntries())); + gc.observe({ entryTypes: ["gc"] }); + const activity = createHook({ + init(_id, type) { + if (type.startsWith("FSREQ")) filesystemRequests++; + if (!draining && type === "Immediate") yields++; + if (type === "WORKER") workerEvents++; + }, + before(id) { + const resource = executionAsyncResource(); + if (resource === probe || resource === drain) return; + if (resource instanceof MessagePort) workerEvents++; + callbacks.set(id, { wall: performance.now(), cpu: threadCpuUsage() }); + }, + after(id) { + const start = callbacks.get(id); + if (!start) return; + callbacks.delete(id); + record(performance.now() - start.wall, threadCpuUsage(start.cpu)); + }, + }).enable(); + const sample = () => { + const now = performance.now(); + const cpu = threadCpuUsage(); + const wallMs = now - previous; + longestGapMs = Math.max(longestGapMs, wallMs); + // Worker construction/message cloning can precede async_hooks.before. + if (workerEvents) { + blockingMs = Math.max( + blockingMs, + Math.min(wallMs, (cpu.user - previousCpu.user + cpu.system - previousCpu.system) / 1_000), + ); + } + workerEvents = 0; + previous = now; + previousCpu = cpu; + }; + probe = setInterval(sample, 1); + let result: T; + try { + // The caller's current callback began before the hook was installed. + const wall = performance.now(); + const cpu = threadCpuUsage(); + const pending = action(); + record(performance.now() - wall, threadCpuUsage(cpu)); + result = await pending; + draining = true; + await new Promise((resolve) => { + drain = setImmediate(resolve); + }); + sample(); + observeGc(gc.takeRecords()); + } finally { + clearInterval(probe); + activity.disable(); + gc.disconnect(); + } + // GC CPU inside callbacks is charged above. Elapsed GC spans, like elapsed + // callback spans, include preemption and remain diagnostics rather than gates. + process.stderr.write( + `${label}: ${JSON.stringify({ blockingMs, filesystemRequests, yields, longestCallbackMs, longestCallbackCpuMs, longestGcMs, gcCount, longestGapMs })}\n`, + ); + return { result, blockingMs, filesystemRequests, yields }; +} diff --git a/packages/server/src/utils/build-metadata-prompt.ts b/packages/server/src/utils/build-metadata-prompt.ts index 9f4f008308..b68f660515 100644 --- a/packages/server/src/utils/build-metadata-prompt.ts +++ b/packages/server/src/utils/build-metadata-prompt.ts @@ -1,8 +1,5 @@ -import { readPaseoConfigJson } from "./paseo-config-file.js"; -import { - PaseoConfigSchema, - type PaseoMetadataGeneration, -} from "@getpaseo/protocol/paseo-config-schema"; +import { readPaseoConfig } from "./paseo-config-file.js"; +import { type PaseoMetadataGeneration } from "@getpaseo/protocol/paseo-config-schema"; export type MetadataConfigKey = "title" | "branchName" | "commitMessage" | "pullRequest"; @@ -52,8 +49,8 @@ async function readProjectMetadataOverrides( } try { const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd); - const json = readPaseoConfigJson(repoRoot); - return PaseoConfigSchema.parse(json).metadataGeneration; + const result = await readPaseoConfig(repoRoot); + return result.ok ? result.config?.metadataGeneration : undefined; } catch { return undefined; } diff --git a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts index 576e9b0c80..efda5be92c 100644 --- a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts +++ b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js"; const tempDirs: string[] = []; @@ -302,3 +303,44 @@ describe("getCommitFileDiff", () => { expect(file?.deletions).toBe(0); }); }); + +it("identical commit-file requests from two requesters both retain syntax tokens", async () => { + const repoDir = initRepo(); + const original = "const value = Math.max(42, 7);\n".repeat(3_000); + commitFile(repoDir, "file.ts", original, "initial"); + commitFile(repoDir, "file.ts", original.replace("42", "43"), "edit"); + const input = { cwd: repoDir, sha: headSha(repoDir), path: "file.ts" }; + const results = await Promise.all([getCommitFileDiff(input), getCommitFileDiff(input)]); + for (const file of results) { + expect( + file?.hunks[0].lines + .find((line) => line.type === "add") + ?.tokens?.some((token) => token.style !== null), + ).toBe(true); + } +}); + +it("a disconnected requester cancels commit highlighting before its sources arrive", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "file.ts", "const value = 42;\n", "initial"); + commitFile(repoDir, "file.ts", "const value = 43;\n", "edit"); + const delivery = new SessionDelivery(() => {}); + const source = {}; + delivery.attach(source, true); + const request = { + type: "checkout.commits.file_diff.request" as const, + cwd: repoDir, + sha: headSha(repoDir), + path: "file.ts", + requestId: "disconnect", + }; + const results: Awaited>[] = []; + const pending = delivery.request(source, request, async () => { + results.push(await getCommitFileDiff({ ...request, signal: delivery.requestSignal })); + }); + await delivery.detach(source); + await pending; + expect( + results[0]?.hunks.flatMap((hunk) => hunk.lines).every((line) => line.tokens === undefined), + ).toBe(true); +}); diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index d614e32f9b..ee29579f55 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -172,7 +172,7 @@ describe("listCheckoutCommits", () => { mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true }); git(["worktree", "add", "-b", "feature", worktreeDir], repoDir); commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work"); - writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" }); + await writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" }); const { baseRef, commits } = await listCheckoutCommits({ cwd: worktreeDir, diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 3cc5c3dace..bd52b0e49f 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -2859,7 +2859,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -2899,7 +2899,7 @@ const x = 1; const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-by-agent"); mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); execFileSync("git", ["worktree", "add", workspaceDir, "placeholder"], { cwd: repoDir }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "placeholder", @@ -2936,7 +2936,7 @@ const x = 1; const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree"); mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/pinned", @@ -2975,7 +2975,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -2997,7 +2997,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -3062,7 +3062,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "old-change", @@ -3114,7 +3114,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/gitlab-mr", @@ -3128,7 +3128,9 @@ const x = 1; const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets }); await renameCurrentBranch(workspaceCwd, "feature/renamed"); - expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toMatchObject({ + expect( + (await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget, + ).toMatchObject({ localBranchName: "feature/renamed", }); const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome }); @@ -3164,7 +3166,7 @@ const x = 1; execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], { cwd: repoDir, }); - writePaseoWorktreeMetadata(workspaceDir, { + await writePaseoWorktreeMetadata(workspaceDir, { baseRefName: "main", changeRequestLookupTarget: { headRef: "feature/placeholder", @@ -3174,7 +3176,7 @@ const x = 1; await renameCurrentBranch(workspaceDir, "feature/generated"); - expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toEqual({ + expect((await readPaseoWorktreeMetadata(workspaceDir))?.changeRequestLookupTarget).toEqual({ headRef: "feature/generated", localBranchName: "feature/generated", }); @@ -3951,7 +3953,7 @@ const x = 1; cwd: worktree.worktreePath, }); - const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }); @@ -3970,7 +3972,7 @@ const x = 1; paseoHome, }); - const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(worktree.worktreePath); rmSync(metadataPath, { force: true }); const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index fbbb494b4a..358bd6eb69 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,7 +1,15 @@ import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { resolve, dirname, basename } from "path"; -import { existsSync, realpathSync } from "fs"; -import { open as openFile, readFile, stat as statFile, mkdtemp, copyFile, rm } from "fs/promises"; +import { + open as openFile, + readFile, + stat as statFile, + mkdtemp, + copyFile, + realpath, + access, + rm, +} from "fs/promises"; import { tmpdir } from "node:os"; import { setImmediate } from "node:timers/promises"; import { TTLCache } from "@isaacs/ttlcache"; @@ -37,6 +45,8 @@ import { unquoteGitPath } from "./git-quoted-path.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { branchNameFromRef, + findPaseoWorktreeRootForCwd, + hasPaseoWorktreeMetadata, getPaseoWorktreeChangeRequestHintForBranch, type PaseoWorktreeMetadata, readPaseoWorktreeMetadata, @@ -1104,7 +1114,7 @@ async function getMainRepoRootFromCommonDir( if (!commonDir) { throw new Error("Not in a git repository"); } - const normalized = realpathSync(commonDir); + const normalized = await realpath(commonDir); if (basename(normalized) === ".git") { return dirname(normalized); @@ -1118,9 +1128,17 @@ async function getMainRepoRootFromCommonDir( }, ); const worktrees = parseWorktreeList(worktreeOut); + const paseoPaths = new Set( + ( + await Promise.all( + worktrees.map(async (wt) => ((await hasPaseoWorktreeMetadata(wt.path)) ? wt.path : null)), + ) + ).filter((p) => p !== null), + ); const nonBareNonPaseo = worktrees.filter( (wt) => !wt.isBare && + !paseoPaths.has(wt.path) && !isPaseoWorktreePath(wt.path, { paseoHome: context?.paseoHome, worktreesRoot: context?.worktreesRoot, @@ -1229,7 +1247,7 @@ export async function renameCurrentBranch( const currentBranch = await getCurrentBranch(cwd); if (currentBranch) { - rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch); + await rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch); } return { previousBranch, currentBranch }; } @@ -1249,7 +1267,7 @@ async function getPaseoWorktreeForCwd( options: PaseoWorktreeLookupOptions = {}, ): Promise { // Fast-path reject: non-worktree paths do not need expensive ownership checks. - if (!/[\\/]worktrees[\\/]/.test(cwd)) { + if (!/[\\/]worktrees[\\/]/.test(cwd) && (await findPaseoWorktreeRootForCwd(cwd)) === null) { return { isPaseoOwnedWorktree: false }; } @@ -1274,8 +1292,8 @@ function storedBaseRefFromMetadata(metadata: PaseoWorktreeMetadata | null): stri return metadata?.baseRef ?? metadata?.baseRefName ?? null; } -function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null { - return storedBaseRefFromMetadata(readPaseoWorktreeMetadata(worktreeRoot)); +async function readPaseoWorktreeBaseRef(worktreeRoot: string): Promise { + return storedBaseRefFromMetadata(await readPaseoWorktreeMetadata(worktreeRoot)); } async function getStoredBaseRefForCwd( @@ -1549,7 +1567,12 @@ async function abortGitPullConflictState(cwd: string): Promise { const rebaseMergePath = resolve(gitDir, "rebase-merge"); const rebaseApplyPath = resolve(gitDir, "rebase-apply"); - if (existsSync(mergeHeadPath)) { + if ( + await access(mergeHeadPath).then( + () => true, + () => false, + ) + ) { try { await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); } catch { @@ -1557,7 +1580,16 @@ async function abortGitPullConflictState(cwd: string): Promise { } } - if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) { + if ( + (await access(rebaseMergePath).then( + () => true, + () => false, + )) || + (await access(rebaseApplyPath).then( + () => true, + () => false, + )) + ) { try { await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 }); } catch { @@ -2078,7 +2110,7 @@ export async function getCheckoutSnapshotFacts( } const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree - ? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot) + ? await readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot) : null; const storedBaseRef = storedBaseRefFromMetadata(paseoWorktreeMetadata); const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd, context)); @@ -2719,10 +2751,12 @@ export async function getCommitFileDiff({ cwd, sha, path, + signal, }: { cwd: string; sha: string; path: string; + signal?: AbortSignal; }): Promise { const worktreeRoot = await requireGitWorktreeRoot(cwd); const renames = await getCommitRenames(worktreeRoot, sha); @@ -2750,6 +2784,7 @@ export async function getCommitFileDiff({ } const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, { + signal, getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path), getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path), }); @@ -3132,6 +3167,7 @@ export function warmCheckoutShortstatInBackground( } interface AppendStructuredTrackedDiffsInput { + signal?: AbortSignal; cwd: string; trackedChanges: CheckoutFileChange[]; trackedNumstatByPath: Map; @@ -3147,6 +3183,7 @@ interface AppendStructuredTrackedDiffsInput { } async function buildHighlightedTrackedDiffFile(input: { + signal?: AbortSignal; cwd: string; change: CheckoutFileChange; parsedFile: ParsedDiffFile; @@ -3169,6 +3206,7 @@ async function buildHighlightedTrackedDiffFile(input: { : null, ]); const highlightedFile = await highlightDiffWithFileContent(parsedFile, cwd, { + signal: input.signal, oldFileContent, newFileContent, }); @@ -3254,6 +3292,7 @@ async function appendStructuredTrackedDiffs( const parsedFile = parsedTrackedByPath.get(change.path); if (parsedFile) { const file = await buildHighlightedTrackedDiffFile({ + signal: input.signal, cwd, change, parsedFile, @@ -3295,6 +3334,7 @@ async function appendStructuredTrackedDiffs( } interface ProcessUntrackedChangeInput { + signal?: AbortSignal; cwd: string; change: CheckoutFileChange; ignoreWhitespace: boolean; @@ -3345,7 +3385,7 @@ async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promi } appendDiff(text); - const parsed = await parseAndHighlightDiff(text, cwd); + const parsed = await parseAndHighlightDiff(text, cwd, { signal: input.signal }); const parsedFile = parsed[0] ?? ({ @@ -3541,6 +3581,7 @@ export async function getCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, context?: CheckoutContext, + signal?: AbortSignal, ): Promise { cwd = await requireGitWorktreeRoot(cwd); @@ -3552,10 +3593,15 @@ export async function getCheckoutDiff( const index = compare.mode === "all" ? await prepareAllChangesIndex(cwd, refsForDiff.baseRef) : null; try { - return await readCheckoutDiff(cwd, compare, { - ...refsForDiff, - indexEnv: index ? { GIT_INDEX_FILE: index.indexFile } : undefined, - }); + return await readCheckoutDiff( + cwd, + compare, + { + ...refsForDiff, + indexEnv: index ? { GIT_INDEX_FILE: index.indexFile } : undefined, + }, + signal, + ); } finally { await index?.cleanup(); } @@ -3565,6 +3611,7 @@ async function readCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, refsForDiff: CheckoutDiffRefs, + signal?: AbortSignal, ): Promise { const ignoreWhitespace = compare.ignoreWhitespace === true; let effectiveRefsForDiff = refsForDiff; @@ -3625,6 +3672,7 @@ async function readCheckoutDiff( if (compare.includeStructured) { const didAppendTrackedDiffs = await appendStructuredTrackedDiffs({ + signal, cwd, trackedChanges, trackedNumstatByPath: trackedDiff.trackedNumstatByPath, @@ -3652,6 +3700,7 @@ async function readCheckoutDiff( break; } const didAppendUntrackedDiff = await processUntrackedChange({ + signal, cwd, change, ignoreWhitespace, diff --git a/packages/server/src/utils/paseo-config-file.test.ts b/packages/server/src/utils/paseo-config-file.test.ts index 02cbd90542..6a7e43c757 100644 --- a/packages/server/src/utils/paseo-config-file.test.ts +++ b/packages/server/src/utils/paseo-config-file.test.ts @@ -1,15 +1,63 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { writeFile, truncate, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { build } from "esbuild"; import { isPlatform } from "../test-utils/platform.js"; -import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js"; +import { + readPaseoConfig, + PaseoConfigSchema, + PaseoConfigRawSchema, + getWorktreeSetupCommands, + getWorktreeTeardownCommands, +} from "./worktree.js"; import { readPaseoConfigForEdit, statPaseoConfigPath, writePaseoConfigForEdit, } from "./paseo-config-file.js"; +let probeDirectory: string; +let probePath: string; + +beforeAll(async () => { + probeDirectory = mkdtempSync(join(tmpdir(), "paseo-config-probe-")); + probePath = join(probeDirectory, "config-responsiveness.mjs"); + // Compile before starting the measured process. A runtime TypeScript loader + // owns MessagePorts that the responsiveness probe otherwise charges as work. + await build({ + entryPoints: [ + fileURLToPath(new URL("./test-fixtures/config-responsiveness.ts", import.meta.url)), + ], + outfile: probePath, + bundle: true, + platform: "node", + format: "esm", + }); +}); + +afterAll(() => { + rmSync(probeDirectory, { recursive: true, force: true }); +}); + +async function measureConfigRead(directory: string) { + const { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, directory], { + timeout: 15_000, + }); + process.stderr.write(stderr); + return JSON.parse(stdout) as { + result: Awaited>; + blockingMs: number; + filesystemRequests: number; + yields: number; + workerMessages: number; + }; +} + describe("paseo config file substrate", () => { let tempDir: string; @@ -21,16 +69,185 @@ describe("paseo config file substrate", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("returns null config and revision when paseo.json is missing", () => { - const result = readPaseoConfigForEdit(tempDir); + it("rejects an oversized config before parsing its otherwise valid JSON", async () => { + writeFileSync( + join(tempDir, "paseo.json"), + JSON.stringify({ extra: "x".repeat(2 * 1024 * 1024) }), + ); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: false, + error: { code: "invalid_project_config" }, + }); + }); + + it("rejects a sparse oversized file without allocating its contents", async () => { + const configPath = join(tempDir, "paseo.json"); + await writeFile(configPath, ""); + await truncate(configPath, 1024 * 1024 * 1024); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: false, + error: { code: "invalid_project_config" }, + }); + }); + + it.skipIf(isPlatform("win32"))("rejects a FIFO without waiting for a writer", async () => { + await promisify(execFile)("mkfifo", [join(tempDir, "paseo.json")]); + const modulePath = fileURLToPath(new URL("./paseo-config-file.ts", import.meta.url)); + const script = `import { readPaseoConfigForEdit } from ${JSON.stringify(modulePath)}; + const result = await readPaseoConfigForEdit(${JSON.stringify(tempDir)}); + if (JSON.stringify(result) !== JSON.stringify({ok:false,error:{code:"invalid_project_config"}})) process.exit(1);`; + // Isolate the regression: the old sync reader blocks on the pipe and must be killed. + await promisify(execFile)( + process.execPath, + ["--import", "tsx", "--input-type=module", "-e", script], + { timeout: 2_000 }, + ); + }); + + it("excludes test-runner and loader activity from the config read budget", async () => { + await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } })); + const unrelatedWork = new Promise((resolve) => { + setImmediate(() => { + const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get( + process, + "threadCpuUsage", + ); + const start = threadCpuUsage(); + let elapsed: NodeJS.CpuUsage; + do { + elapsed = threadCpuUsage(start); + } while (elapsed.user + elapsed.system < 60_000); + resolve(); + }); + }); + const { result, blockingMs, filesystemRequests, workerMessages } = + await measureConfigRead(tempDir); + await unrelatedWork; + expect(workerMessages).toBe(0); + expect(result).toEqual({ + ok: true, + config: { worktree: { setup: "npm ci" } }, + revision: await statPaseoConfigPath(tempDir), + }); + expect(filesystemRequests).toBeGreaterThan(0); + expect(blockingMs).toBeLessThan(20); + }); + + it("keeps main-thread work below 20ms while admitting large configs", async () => { + const config = { + scripts: Object.fromEntries( + Array.from({ length: 7_000 }, (_, i) => [`script${i}`, { command: "echo hi" }]), + ), + worktree: { setup: "npm ci" }, + }; + await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config)); + const { result, blockingMs, filesystemRequests, yields, workerMessages } = + await measureConfigRead(tempDir); + expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) }); + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBeGreaterThan(0); + expect(yields).toBeGreaterThan(0); + expect(workerMessages).toBe(0); + }); + + it("rejects a 32MiB config with main-thread work below 20ms", async () => { + await writeFile( + join(tempDir, "paseo.json"), + JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }), + ); + const { result, blockingMs, filesystemRequests, workerMessages } = + await measureConfigRead(tempDir); + expect(blockingMs).toBeLessThan(20); + expect(filesystemRequests).toBeGreaterThan(0); + expect(workerMessages).toBe(0); + expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } }); + }); + + it.each([ + { + scripts: Object.fromEntries( + Array.from({ length: 130 }, (_, i) => [ + `script${i}`, + { command: "echo hi", extra: { keep: true } }, + ]), + ), + }, + { + scripts: { valid: { command: "echo hi" }, invalid: 42 }, + worktree: { setup: ["one", "", 42] }, + }, + { scripts: ["invalid record"], worktree: { setup: "echo hi" } }, + JSON.parse( + '{"scripts":{"__proto__":{"command":"echo hi"},"constructor":{"command":"echo hi"}}}', + ), + ])("preserves edit and runtime schema behavior with grouped validation (%#)", async (config) => { + await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config)); + const expected = PaseoConfigRawSchema.safeParse(config); + expect(await readPaseoConfigForEdit(tempDir)).toEqual( + expected.success + ? { ok: true, config: expected.data, revision: await statPaseoConfigPath(tempDir) } + : { ok: false, error: { code: "invalid_project_config" } }, + ); + expect(await readPaseoConfig(tempDir)).toEqual({ + ok: true, + config: PaseoConfigSchema.parse(config), + }); + }); + + it("refreshes snapshots after an external edit, repair, and removal", async () => { + const configPath = join(tempDir, "paseo.json"); + await writeFile(configPath, JSON.stringify({ worktree: { setup: "old" } })); + const original = await readPaseoConfig(tempDir); + expect(original).toEqual({ ok: true, config: { worktree: { setup: ["old"], teardown: [] } } }); + await writeFile(configPath, "{ malformed"); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: false, + error: { code: "invalid_project_config" }, + }); + await writeFile(configPath, JSON.stringify({ worktree: { setup: "repaired" } })); + expect(await readPaseoConfig(tempDir)).toEqual({ + ok: true, + config: { worktree: { setup: ["repaired"], teardown: [] } }, + }); + await rm(configPath); + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ + ok: true, + config: null, + revision: null, + }); + }); + + it("serializes competing edits so exactly one matching revision commits", async () => { + const results = await Promise.all( + ["first", "second"].map((setup) => + writePaseoConfigForEdit({ + repoRoot: tempDir, + config: { worktree: { setup } }, + expectedRevision: null, + }), + ), + ); + const saved = await readPaseoConfigForEdit(tempDir); + expect(results[0]).toEqual(saved); + expect(results[1]).toEqual({ + ok: false, + error: { + code: "stale_project_config", + currentRevision: await statPaseoConfigPath(tempDir), + }, + }); + }); + + it("returns null config and revision when paseo.json is missing", async () => { + const result = await readPaseoConfigForEdit(tempDir); expect(result).toEqual({ ok: true, config: null, revision: null }); }); - it("returns invalid_project_config for invalid JSON", () => { + it("returns invalid_project_config for invalid JSON", async () => { writeFileSync(join(tempDir, "paseo.json"), "{ invalid json\n"); - const result = readPaseoConfigForEdit(tempDir); + const result = await readPaseoConfigForEdit(tempDir); expect(result).toEqual({ ok: false, @@ -38,7 +255,7 @@ describe("paseo config file substrate", () => { }); }); - it("preserves raw lifecycle string and array forms with a revision token", () => { + it("preserves raw lifecycle string and array forms with a revision token", async () => { writeFileSync( join(tempDir, "paseo.json"), JSON.stringify({ @@ -49,7 +266,7 @@ describe("paseo config file substrate", () => { }), ); - const result = readPaseoConfigForEdit(tempDir); + const result = await readPaseoConfigForEdit(tempDir); expect(result).toEqual({ ok: true, @@ -59,11 +276,11 @@ describe("paseo config file substrate", () => { teardown: ["npm run clean", "npm run reset"], }, }, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); }); - it("keeps runtime lifecycle commands normalized for execution", () => { + it("keeps runtime lifecycle commands normalized for execution", async () => { writeFileSync( join(tempDir, "paseo.json"), JSON.stringify({ @@ -74,15 +291,15 @@ describe("paseo config file substrate", () => { }), ); - expect(getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]); - expect(getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]); + expect(await getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]); + expect(await getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]); }); - it("writes pretty JSON with a trailing newline when revision matches", () => { + it("writes pretty JSON with a trailing newline when revision matches", async () => { writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); - const expectedRevision = statPaseoConfigPath(tempDir); + const expectedRevision = await statPaseoConfigPath(tempDir); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: tempDir, config: { worktree: { setup: "npm install" } }, expectedRevision, @@ -91,7 +308,7 @@ describe("paseo config file substrate", () => { expect(result).toEqual({ ok: true, config: { worktree: { setup: "npm install" } }, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe( '{\n "worktree": {\n "setup": "npm install"\n }\n}\n', @@ -101,13 +318,13 @@ describe("paseo config file substrate", () => { // POSIX-only: Windows mtime granularity can collapse the two revisions in this fixture. it.skipIf(isPlatform("win32"))( "rejects stale writes when the current revision changed before rename", - () => { + async () => { writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); - const expectedRevision = statPaseoConfigPath(tempDir); + const expectedRevision = await statPaseoConfigPath(tempDir); writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } })); - const currentRevision = statPaseoConfigPath(tempDir); + const currentRevision = await statPaseoConfigPath(tempDir); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: tempDir, config: { worktree: { setup: "from editor" } }, expectedRevision, @@ -123,7 +340,7 @@ describe("paseo config file substrate", () => { }, ); - it("round-trips unknown top-level, worktree, and script-entry fields", () => { + it("round-trips unknown top-level, worktree, and script-entry fields", async () => { const config = { extraTop: { keep: true }, worktree: { @@ -139,7 +356,7 @@ describe("paseo config file substrate", () => { }, }; - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: tempDir, config, expectedRevision: null, @@ -148,20 +365,20 @@ describe("paseo config file substrate", () => { expect(result).toEqual({ ok: true, config, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); - expect(readPaseoConfigForEdit(tempDir)).toEqual({ + expect(await readPaseoConfigForEdit(tempDir)).toEqual({ ok: true, config, - revision: statPaseoConfigPath(tempDir), + revision: await statPaseoConfigPath(tempDir), }); }); - it("returns write_failed for filesystem write exceptions", () => { + it("returns write_failed for filesystem write exceptions", async () => { const fileRoot = join(tempDir, "not-a-directory"); writeFileSync(fileRoot, "file"); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: fileRoot, config: { worktree: { setup: "npm install" } }, expectedRevision: null, @@ -173,10 +390,10 @@ describe("paseo config file substrate", () => { }); }); - it("creates paseo.json when the file is still missing and expected revision is null", () => { + it("creates paseo.json when the file is still missing and expected revision is null", async () => { mkdirSync(join(tempDir, "nested")); - const result = writePaseoConfigForEdit({ + const result = await writePaseoConfigForEdit({ repoRoot: join(tempDir, "nested"), config: { scripts: { dev: { command: "npm run dev" } } }, expectedRevision: null, @@ -185,7 +402,7 @@ describe("paseo config file substrate", () => { expect(result).toEqual({ ok: true, config: { scripts: { dev: { command: "npm run dev" } } }, - revision: statPaseoConfigPath(join(tempDir, "nested")), + revision: await statPaseoConfigPath(join(tempDir, "nested")), }); }); }); diff --git a/packages/server/src/utils/paseo-config-file.ts b/packages/server/src/utils/paseo-config-file.ts index 9b31918e63..b8152c4f22 100644 --- a/packages/server/src/utils/paseo-config-file.ts +++ b/packages/server/src/utils/paseo-config-file.ts @@ -1,8 +1,14 @@ -import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { constants, type Stats } from "node:fs"; +import { open, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import pLimit from "p-limit"; +import { withTimeout } from "./promise-timeout.js"; +import { setImmediate as nextTurn } from "node:timers/promises"; import { randomUUID } from "node:crypto"; import { PaseoConfigRawSchema, + PaseoConfigSchema, + type PaseoConfig, type PaseoConfigRaw, type PaseoConfigRevision, type ProjectConfigRpcError, @@ -15,15 +21,18 @@ export { } from "@getpaseo/protocol/paseo-config-schema"; export const PASEO_CONFIG_FILE_NAME = "paseo.json"; +// Configuration is deliberately small. Bound both IO and main-thread JSON/schema work. +export const MAX_PASEO_CONFIG_BYTES = 256 * 1024; export type ReadPaseoConfigForEditResult = | { ok: true; config: PaseoConfigRaw | null; revision: PaseoConfigRevision | null } | { ok: false; error: ProjectConfigRpcError }; - +export type ReadPaseoConfigResult = + | { ok: true; config: PaseoConfig | null } + | { ok: false; configPath: string; error: unknown }; export type WritePaseoConfigForEditResult = | { ok: true; config: PaseoConfigRaw; revision: PaseoConfigRevision } | { ok: false; error: ProjectConfigRpcError }; - export interface WritePaseoConfigForEditInput { repoRoot: string; config: PaseoConfigRaw; @@ -34,96 +43,272 @@ export function resolvePaseoConfigPath(repoRoot: string): string { return join(repoRoot, PASEO_CONFIG_FILE_NAME); } -export function statPaseoConfigPath(repoRoot: string): PaseoConfigRevision | null { - const configPath = resolvePaseoConfigPath(repoRoot); - if (!existsSync(configPath)) { - return null; - } - const stats = statSync(configPath); - return { - mtimeMs: stats.mtimeMs, - size: stats.size, - }; +function revision(stats: Stats): PaseoConfigRevision { + return { mtimeMs: stats.mtimeMs, size: stats.size }; } -export function readPaseoConfigJson(repoRoot: string): unknown { - const configPath = resolvePaseoConfigPath(repoRoot); - if (!existsSync(configPath)) { - return null; - } - return JSON.parse(readFileSync(configPath, "utf8")); -} - -export function readPaseoConfigForEdit(repoRoot: string): ReadPaseoConfigForEditResult { +async function inspect(repoRoot: string): Promise { try { - const json = readPaseoConfigJson(repoRoot); - if (json === null) { - return { ok: true, config: null, revision: null }; + return await stat(resolvePaseoConfigPath(repoRoot)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +export async function statPaseoConfigPath(repoRoot: string): Promise { + const stats = await inspect(repoRoot); + return stats ? revision(stats) : null; +} + +function admit(stats: Stats): void { + if (!stats.isFile() || stats.size > MAX_PASEO_CONFIG_BYTES) { + throw new Error(`paseo.json must be a regular file of at most ${MAX_PASEO_CONFIG_BYTES} bytes`); + } +} + +function fingerprint(stats: Stats | null): string { + return stats + ? `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}` + : "missing"; +} + +// The record schema validates every script synchronously. Validate bounded groups +// through the same schemas so thousands of script entries do not monopolize a turn. +async function validateConfig(json: unknown): Promise<{ + edit: ReturnType; + runtime: PaseoConfig; +}> { + if (json === null || typeof json !== "object" || Array.isArray(json)) { + return { edit: PaseoConfigRawSchema.safeParse(json), runtime: PaseoConfigSchema.parse(json) }; + } + const scripts = (json as Record).scripts; + if (scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) { + return { edit: PaseoConfigRawSchema.safeParse(json), runtime: PaseoConfigSchema.parse(json) }; + } + const withoutScripts = { ...json, scripts: {} }; + let edit = PaseoConfigRawSchema.safeParse(withoutScripts); + const runtime = PaseoConfigSchema.parse(withoutScripts); + const entries = Object.entries(scripts); + if (entries.length > 32) await nextTurn(); + let lastYield = performance.now(); + for (let offset = 0; offset < entries.length; offset += 32) { + const group = { scripts: Object.fromEntries(entries.slice(offset, offset + 32)) }; + if (edit.success) { + const parsed = PaseoConfigRawSchema.safeParse(group); + if (parsed.success) Object.assign(edit.data.scripts!, parsed.data.scripts); + else edit = parsed; } - return { - ok: true, - config: PaseoConfigRawSchema.parse(json), - revision: statPaseoConfigPath(repoRoot), - }; + Object.assign(runtime.scripts!, PaseoConfigSchema.parse(group).scripts); + if (performance.now() - lastYield >= 2) { + await nextTurn(); + lastYield = performance.now(); + } + } + return { edit, runtime }; +} + +interface Snapshot { + fingerprint: string; + edit: ReadPaseoConfigForEditResult; + runtime: ReadPaseoConfigResult; +} + +/** Owns config admission, snapshots and daemon read/modify/write ordering for every consumer. */ +class ProjectConfigOwner { + // A stalled mount retains its slot until the OS returns; never consume the whole fs pool. + private readonly filesystemOperations = pLimit(2); + private readonly pendingAliases = new Map>(); + private readonly snapshots = new Map(); + private readonly pending = new Map< + string, + { kind: "read" | "write"; promise: Promise } + >(); + + async resolveRoot(repoRoot: string, activeRoots: Set): Promise { + const root = resolve(repoRoot); + if (activeRoots.has(root)) return root; + let pending = this.pendingAliases.get(root); + if (!pending) { + pending = this.filesystemOperations(() => realpath(root)).finally(() => + this.pendingAliases.delete(root), + ); + this.pendingAliases.set(root, pending); + } + try { + const alias = await withTimeout(pending, 5_000, "Project root unavailable"); + return activeRoots.has(alias) ? alias : null; + } catch { + return null; + } + } + + private async serialized( + root: string, + kind: "read" | "write", + operation: () => Promise, + ): Promise { + const previous = this.pending.get(root); + if (kind === "read" && previous?.kind === "read") return previous.promise as Promise; + const next = (async () => { + await previous?.promise.catch(() => undefined); + return this.filesystemOperations(operation); + })(); + this.pending.set(root, { kind, promise: next }); + try { + return await next; + } finally { + if (this.pending.get(root)?.promise === next) this.pending.delete(root); + } + } + + private remember(root: string, snapshot: Snapshot): Snapshot { + this.snapshots.delete(root); + this.snapshots.set(root, snapshot); + // Worktrees come and go; retain only a bounded set of reusable snapshots. + if (this.snapshots.size > 256) this.snapshots.delete(this.snapshots.keys().next().value!); + return snapshot; + } + + async read(repoRoot: string): Promise { + const root = resolve(repoRoot); + return withTimeout( + this.serialized(root, "read", () => this.load(root)), + 5_000, + "Project config unavailable", + ); + } + + private async load(root: string): Promise { + const stats = await inspect(root); + if (stats) admit(stats); + const key = fingerprint(stats); + const cached = this.snapshots.get(root); + if (cached?.fingerprint === key) return this.remember(root, cached); + let json: unknown = null; + if (stats) { + // O_NONBLOCK prevents a regular-file -> FIFO replacement between stat and open + // from waiting for a writer. Check the opened descriptor again before any read. + const handle = await open( + resolvePaseoConfigPath(root), + constants.O_RDONLY | constants.O_NONBLOCK, + ); + try { + const openedStats = await handle.stat(); + admit(openedStats); + const bytes = Buffer.alloc(MAX_PASEO_CONFIG_BYTES + 1); + let length = 0; + while (length < bytes.length) { + const { bytesRead } = await handle.read(bytes, length, bytes.length - length, null); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > MAX_PASEO_CONFIG_BYTES) throw new Error("paseo.json exceeds byte limit"); + // Never publish bytes under a revision that did not describe the opened file. + if (fingerprint(await handle.stat()) !== fingerprint(openedStats)) { + throw new Error("paseo.json changed while reading; retry the operation"); + } + try { + json = JSON.parse(bytes.toString("utf8", 0, length)); + } catch (error) { + return this.remember(root, { + fingerprint: key, + edit: { ok: false, error: { code: "invalid_project_config" } }, + runtime: { ok: false, configPath: resolvePaseoConfigPath(root), error }, + }); + } + if (fingerprint(openedStats) !== key) { + throw new Error("paseo.json was replaced while reading; retry the operation"); + } + } finally { + await handle.close(); + } + } + const validated = json === null ? null : await validateConfig(json); + const parsed = validated?.edit ?? null; + let edit: ReadPaseoConfigForEditResult = { ok: true, config: null, revision: null }; + if (parsed !== null) { + edit = parsed.success + ? { ok: true, config: parsed.data, revision: stats ? revision(stats) : null } + : { ok: false, error: { code: "invalid_project_config" } }; + } + return this.remember(root, { + fingerprint: key, + edit, + runtime: { ok: true, config: validated?.runtime ?? null }, + }); + } + + async write(input: WritePaseoConfigForEditInput): Promise { + const root = resolve(input.repoRoot); + return this.serialized(root, "write", async () => { + const { edit: parsed } = await validateConfig(input.config); + if (!parsed.success) return { ok: false, error: { code: "invalid_project_config" } }; + const content = `${JSON.stringify(parsed.data, null, 2)}\n`; + if (Buffer.byteLength(content) > MAX_PASEO_CONFIG_BYTES) { + return { ok: false, error: { code: "invalid_project_config" } }; + } + const tempPath = join(root, `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`); + try { + await writeFile(tempPath, content, { flag: "wx" }); + const savedRevision = revision(await stat(tempPath)); + const currentStats = await inspect(root); + if (currentStats) admit(currentStats); + const currentRevision = currentStats ? revision(currentStats) : null; + if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) { + return { ok: false, error: { code: "stale_project_config", currentRevision } }; + } + // Serialized against daemon edits. External writers still have the existing + // optimistic stat/rename race; revision tokens remain mtime + size. + await rename(tempPath, resolvePaseoConfigPath(root)); + this.snapshots.delete(root); + return { ok: true, config: parsed.data, revision: savedRevision }; + } catch { + return { ok: false, error: { code: "write_failed" } }; + } finally { + await rm(tempPath, { force: true }).catch(() => undefined); + } + }); + } +} + +const projectConfig = new ProjectConfigOwner(); + +export function resolveProjectConfigRoot( + repoRoot: string, + activeRoots: Set, +): Promise { + return projectConfig.resolveRoot(repoRoot, activeRoots); +} + +export async function readPaseoConfig(repoRoot: string): Promise { + try { + return (await projectConfig.read(repoRoot)).runtime; + } catch (error) { + return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error }; + } +} + +export async function readPaseoConfigForEdit( + repoRoot: string, +): Promise { + try { + return (await projectConfig.read(repoRoot)).edit; } catch { - return { - ok: false, - error: { code: "invalid_project_config" }, - }; + return { ok: false, error: { code: "invalid_project_config" } }; } } export function writePaseoConfigForEdit( input: WritePaseoConfigForEditInput, -): WritePaseoConfigForEditResult { - const parsed = PaseoConfigRawSchema.safeParse(input.config); - if (!parsed.success) { - return { ok: false, error: { code: "invalid_project_config" } }; - } - - const configPath = resolvePaseoConfigPath(input.repoRoot); - const tempPath = join( - input.repoRoot, - `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`, - ); - - try { - writeFileSync(tempPath, `${JSON.stringify(parsed.data, null, 2)}\n`); - const currentRevision = statPaseoConfigPath(input.repoRoot); - if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) { - removeTempPaseoConfig(tempPath); - return { - ok: false, - error: { code: "stale_project_config", currentRevision }, - }; - } - - renameSync(tempPath, configPath); - const revision = statPaseoConfigPath(input.repoRoot); - if (!revision) { - return { ok: false, error: { code: "write_failed" } }; - } - return { ok: true, config: parsed.data, revision }; - } catch { - removeTempPaseoConfig(tempPath); - return { ok: false, error: { code: "write_failed" } }; - } +): Promise { + return projectConfig.write(input); } function paseoConfigRevisionsEqual( left: PaseoConfigRevision | null, right: PaseoConfigRevision | null, ): boolean { - if (left === null || right === null) { - return left === right; - } + if (left === null || right === null) return left === right; return left.mtimeMs === right.mtimeMs && left.size === right.size; } - -function removeTempPaseoConfig(tempPath: string): void { - try { - rmSync(tempPath, { force: true }); - } catch { - // Best-effort cleanup only; callers need the original write outcome. - } -} diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index a0ec908fbb..45cb8b01b5 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -107,7 +107,7 @@ export function isRealpathInsideRoot(root: string, candidate: string): boolean { return getRealpathAwareRelativePath(root, candidate) !== null; } -function getRelativePathInsideRoot(root: string, candidate: string): string | null { +export function getRelativePathInsideRoot(root: string, candidate: string): string | null { const compareAsWindows = shouldCompareAsWindows(root, candidate); const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const normalizedRoot = normalizePathForComparison(root, compareAsWindows); @@ -175,7 +175,7 @@ function normalizePathForComparison(value: string, compareAsWindows: boolean): s return compareAsWindows ? normalized.toLowerCase() : normalized; } -function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string { +export function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string { const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value; const platformNormalized = platformPath.normalize(comparableValue); diff --git a/packages/server/src/utils/run-git-command.test.ts b/packages/server/src/utils/run-git-command.test.ts index 32844d0de8..5153b606dc 100644 --- a/packages/server/src/utils/run-git-command.test.ts +++ b/packages/server/src/utils/run-git-command.test.ts @@ -507,7 +507,6 @@ describe("runGitCommand", () => { command: "git", args: ["status", "--short"], cwd: process.cwd(), - cwdExists: true, timeout: 30_000, maxOutputBytes: 20 * 1024 * 1024, acceptExitCodes: [0, 1], @@ -520,7 +519,6 @@ describe("runGitCommand", () => { command: "git", args: ["status", "--short"], cwd: process.cwd(), - cwdExists: true, durationMs: expect.any(Number), exitCode: 0, signal: null, @@ -530,6 +528,9 @@ describe("runGitCommand", () => { }), "Git command closed", ); + for (const [record] of trace.mock.calls) { + expect(record).not.toHaveProperty("cwdExists"); + } }); it("rejects non-zero exit codes that are not accepted and frees the slot", async () => { diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index bdf7bc1f32..3a3cbf5b4c 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -1,5 +1,4 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { existsSync } from "node:fs"; import type { Logger } from "pino"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { @@ -311,7 +310,6 @@ function executeGitCommand( command: "git", args, cwd: options.cwd, - cwdExists: existsSync(options.cwd), timeout, maxOutputBytes, acceptExitCodes, diff --git a/packages/server/src/utils/test-fixtures/config-responsiveness.ts b/packages/server/src/utils/test-fixtures/config-responsiveness.ts new file mode 100644 index 0000000000..787ad88674 --- /dev/null +++ b/packages/server/src/utils/test-fixtures/config-responsiveness.ts @@ -0,0 +1,30 @@ +import { createHook, executionAsyncResource } from "node:async_hooks"; +import { stat } from "node:fs/promises"; +import { MessagePort } from "node:worker_threads"; +import assert from "node:assert/strict"; +import { setImmediate as nextTurn } from "node:timers/promises"; +import { measureMainThreadWork } from "../../test-utils/main-thread-work.js"; +import { readPaseoConfigForEdit } from "../paseo-config-file.js"; + +// The parent bundles this fixture before launching plain Node, keeping test +// runner callbacks and runtime loader messages outside the measured thread. +// Read the real file once, without warming the config snapshot or schema. +const directory = process.argv[2]; +assert.ok(directory, "Config directory is required"); +// The daemon has already used asynchronous filesystem APIs before reading a +// project config. Start libuv's filesystem worker pool outside the measurement: +// its first submission synchronously creates workers in a fresh Node process. +// Stat the probe itself so the config, snapshot and schemas remain cold. +await stat(process.argv[1]); +await nextTurn(); +let workerMessages = 0; +const activity = createHook({ + before() { + if (executionAsyncResource() instanceof MessagePort) workerMessages++; + }, +}).enable(); +const measured = await measureMainThreadWork("project config main-thread work", () => + readPaseoConfigForEdit(directory), +); +activity.disable(); +process.stdout.write(JSON.stringify({ ...measured, workerMessages })); diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index b977f7952b..fcb0454672 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -1,7 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; -import { isAbsolute, join, resolve } from "path"; +import { mkdir, open, realpath, rename, stat, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import pLimit from "p-limit"; +import { dirname, isAbsolute, join, resolve } from "path"; import { z } from "zod"; +const MAX_WORKTREE_METADATA_BYTES = 64 * 1024; + const ChangeRequestLookupTargetSchema = z.object({ headRef: z.string().min(1), headRepositoryOwner: z.string().min(1).optional(), @@ -90,75 +95,78 @@ function normalizeLegacyGitHubOwnerForBranch(owner: string): string | null { return /^[a-z0-9-]+$/.test(normalized) ? normalized : null; } -export function rebindPaseoWorktreeChangeRequestHint( +export async function rebindPaseoWorktreeChangeRequestHint( worktreeRoot: string, previousBranch: string, currentBranch: string, -): boolean { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); - const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch); - if (!metadata || !target) { - return false; - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const metadata = await readMetadataFile(root); + const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch); + if (!metadata || !target) { + return false; + } - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...metadata, - changeRequestLookupTarget: { - ...target, - ...(target.headRef === previousBranch && - !target.headRepositoryOwner && - target.changeRequestNumber === undefined - ? { headRef: currentBranch } - : {}), - localBranchName: currentBranch, - }, + await writePaseoWorktreeMetadataFile(root, { + ...metadata, + changeRequestLookupTarget: { + ...target, + ...(target.headRef === previousBranch && + !target.headRepositoryOwner && + target.changeRequestNumber === undefined + ? { headRef: currentBranch } + : {}), + localBranchName: currentBranch, + }, + }); + return true; }); - return true; } -export function pinPaseoWorktreeBranchIdentityIfMissing( +export async function pinPaseoWorktreeBranchIdentityIfMissing( worktreeRoot: string, branch: string, -): boolean { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); - if (!metadata || metadata.changeRequestLookupTarget) { - return false; - } - const target = createPaseoWorktreeChangeRequestHint({ - headRef: branch, - localBranchName: branch, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const metadata = await readMetadataFile(root); + if (!metadata || metadata.changeRequestLookupTarget) { + return false; + } + const target = createPaseoWorktreeChangeRequestHint({ + headRef: branch, + localBranchName: branch, + }); + await writePaseoWorktreeMetadataFile(root, { + ...metadata, + changeRequestLookupTarget: target, + }); + return true; }); - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...metadata, - changeRequestLookupTarget: target, - }); - return true; } -function getGitDirForWorktreeRoot(worktreeRoot: string): string { +async function getGitDirForWorktreeRoot(worktreeRoot: string): Promise { const gitPath = join(worktreeRoot, ".git"); - if (!existsSync(gitPath)) { - throw new Error(`Not a git repository: ${worktreeRoot}`); - } - - // In a worktree checkout, `.git` is a file containing `gitdir: `. - // In a normal checkout, `.git` is a directory. + let info; try { - const gitFileContent = readFileSync(gitPath, "utf8"); - const match = gitFileContent.match(/gitdir:\s*(.+)/); - if (match?.[1]) { - const raw = match[1].trim(); - return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw); - } - } catch { - // If `.git` is a directory, readFileSync will throw; fall through. + info = await stat(gitPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + throw new Error(`Not a git repository: ${worktreeRoot}`, { cause: error }); + throw error; + } + if (info.isDirectory()) return gitPath; + const gitFileContent = await readBoundedFile(gitPath); + const match = gitFileContent.match(/gitdir:\s*(.+)/); + if (match?.[1]) { + const raw = match[1].trim(); + return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw); } return gitPath; } -export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string { - const gitDir = getGitDirForWorktreeRoot(worktreeRoot); +export async function getPaseoWorktreeMetadataPath(worktreeRoot: string): Promise { + const gitDir = await getGitDirForWorktreeRoot(worktreeRoot); return join(gitDir, "paseo", "worktree.json"); } @@ -213,120 +221,199 @@ function assertValidBaseRef(value: string): void { } } -export function writePaseoWorktreeMetadata( +export async function writePaseoWorktreeMetadata( worktreeRoot: string, options: { baseRefName: string; baseRef?: string; changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint; }, -): void { - const baseRefName = normalizeBaseRefName(options.baseRefName); - assertValidBaseRef(baseRefName); - const baseRef = options.baseRef?.trim(); - if (baseRef) { - assertValidBaseRef(baseRef); - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const baseRefName = normalizeBaseRefName(options.baseRefName); + assertValidBaseRef(baseRefName); + const baseRef = options.baseRef?.trim(); + if (baseRef) { + assertValidBaseRef(baseRef); + } - const metadata: PaseoWorktreeMetadata = { - version: 1, - baseRefName, - ...(baseRef ? { baseRef } : {}), - ...(options.changeRequestLookupTarget - ? { changeRequestLookupTarget: options.changeRequestLookupTarget } - : {}), - }; - writePaseoWorktreeMetadataFile(worktreeRoot, metadata); -} - -export function writePaseoWorktreeRuntimeMetadata( - worktreeRoot: string, - options: { worktreePort: number }, -): void { - if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) { - throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`); - } - - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current) { - throw new Error("Cannot persist worktree runtime metadata: missing base metadata"); - } - - const next: PaseoWorktreeMetadata = { - ...current, - version: 2, - runtime: { - worktreePort: options.worktreePort, - }, - }; - writePaseoWorktreeMetadataFile(worktreeRoot, next); -} - -export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata( - worktreeRoot: string, - options: { placeholderBranchName: string }, -): void { - const placeholderBranchName = options.placeholderBranchName.trim(); - if (!placeholderBranchName) { - throw new Error("Placeholder branch name is required"); - } - - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current) { - throw new Error("Cannot persist first-agent branch auto-name metadata: missing base metadata"); - } - - writePaseoWorktreeMetadataFile(worktreeRoot, { - ...current, - version: 2, - firstAgentBranchAutoName: { - status: "pending", - placeholderBranchName, - }, + const metadata: PaseoWorktreeMetadata = { + version: 1, + baseRefName, + ...(baseRef ? { baseRef } : {}), + ...(options.changeRequestLookupTarget + ? { changeRequestLookupTarget: options.changeRequestLookupTarget } + : {}), + }; + await writePaseoWorktreeMetadataFile(root, metadata); }); } -export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted( +export async function writePaseoWorktreeRuntimeMetadata( + worktreeRoot: string, + options: { worktreePort: number }, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) { + throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`); + } + + const current = await readMetadataFile(root); + if (!current) { + throw new Error("Cannot persist worktree runtime metadata: missing base metadata"); + } + + const next: PaseoWorktreeMetadata = { + ...current, + version: 2, + runtime: { + worktreePort: options.worktreePort, + }, + }; + await writePaseoWorktreeMetadataFile(root, next); + }); +} + +export async function writePaseoWorktreeFirstAgentBranchAutoNameMetadata( + worktreeRoot: string, + options: { placeholderBranchName: string }, +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const placeholderBranchName = options.placeholderBranchName.trim(); + if (!placeholderBranchName) { + throw new Error("Placeholder branch name is required"); + } + + const current = await readMetadataFile(root); + if (!current) { + throw new Error( + "Cannot persist first-agent branch auto-name metadata: missing base metadata", + ); + } + + await writePaseoWorktreeMetadataFile(root, { + ...current, + version: 2, + firstAgentBranchAutoName: { + status: "pending", + placeholderBranchName, + }, + }); + }); +} + +export async function markPaseoWorktreeFirstAgentBranchAutoNameAttempted( worktreeRoot: string, options: { attemptedAt?: string } = {}, -): PaseoWorktreeMetadata | null { - const current = readPaseoWorktreeMetadata(worktreeRoot); - if (!current || current.version !== 2 || current.firstAgentBranchAutoName?.status !== "pending") { - return current; - } +): Promise { + return mutateMetadata(worktreeRoot, async (root) => { + const current = await readMetadataFile(root); + if ( + !current || + current.version !== 2 || + current.firstAgentBranchAutoName?.status !== "pending" + ) { + return current; + } - const next: PaseoWorktreeMetadata = { - ...current, - firstAgentBranchAutoName: { - status: "attempted", - placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName, - attemptedAt: options.attemptedAt ?? new Date().toISOString(), - }, - }; - writePaseoWorktreeMetadataFile(worktreeRoot, next); - return next; + const next: PaseoWorktreeMetadata = { + ...current, + firstAgentBranchAutoName: { + status: "attempted", + placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName, + attemptedAt: options.attemptedAt ?? new Date().toISOString(), + }, + }; + await writePaseoWorktreeMetadataFile(root, next); + return next; + }); } -export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - if (!existsSync(metadataPath)) { - return null; +export async function hasPaseoWorktreeMetadata(worktreeRoot: string): Promise { + try { + return (await readPaseoWorktreeMetadata(worktreeRoot)) !== null; + } catch { + return false; } - const parsed = JSON.parse(readFileSync(metadataPath, "utf8")); - return PaseoWorktreeMetadataSchema.parse(parsed); } -export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - const metadata = readPaseoWorktreeMetadata(worktreeRoot); +/** + * Filesystem-only search for a Paseo worktree root at or above cwd: the + * first directory containing a .git entry wins, and it only counts when + * its git admin dir holds the paseo/worktree.json marker. Lets callers + * cheaply rule out plain directories before running git ownership checks. + */ +export async function findPaseoWorktreeRootForCwd(cwd: string): Promise { + let current = resolve(cwd); + for (;;) { + if ( + await stat(join(current, ".git")).then( + () => true, + () => false, + ) + ) { + return (await hasPaseoWorktreeMetadata(current)) ? current : null; + } + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +export async function readPaseoWorktreeMetadata( + worktreeRoot: string, +): Promise { + const key = await resolveMetadataIdentity(worktreeRoot); + await metadataMutations.get(key)?.catch(() => {}); + return metadataIo(() => readMetadataFile(key)); +} + +async function readMetadataFile(worktreeRoot: string): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + try { + return PaseoWorktreeMetadataSchema.parse(JSON.parse(await readBoundedFile(metadataPath))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +// O_NONBLOCK lets us reject FIFOs by kind without hanging in open(). A fixed read also +// caps a regular file that grows between stat and read; readFile's whole-file loop does not. +async function readBoundedFile(path: string): Promise { + const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const info = await file.stat(); + const limit = MAX_WORKTREE_METADATA_BYTES; + if (!info.isFile() || info.size > limit) + throw new Error(`Invalid Paseo worktree metadata file: ${path}`); + const buffer = Buffer.alloc(limit + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await file.read(buffer, length, buffer.length - length); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > limit) throw new Error(`Invalid Paseo worktree metadata file: ${path}`); + return buffer.toString("utf8", 0, length); + } finally { + await file.close(); + } +} + +export async function requirePaseoWorktreeBaseRefName(worktreeRoot: string): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + const metadata = await readPaseoWorktreeMetadata(worktreeRoot); if (!metadata) { throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`); } return metadata.baseRefName; } -export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | null { - const metadata = readPaseoWorktreeMetadata(worktreeRoot); +export async function readPaseoWorktreeRuntimePort(worktreeRoot: string): Promise { + const metadata = await readPaseoWorktreeMetadata(worktreeRoot); if (!metadata) { return null; } @@ -336,13 +423,61 @@ export function readPaseoWorktreeRuntimePort(worktreeRoot: string): number | nul return null; } -function writePaseoWorktreeMetadataFile( +async function writePaseoWorktreeMetadataFile( worktreeRoot: string, metadata: PaseoWorktreeMetadata, -): void { - const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot); - mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true }); - const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); - renameSync(tempPath, metadataPath); +): Promise { + const metadataPath = await getPaseoWorktreeMetadataPath(worktreeRoot); + const contents = `${JSON.stringify(metadata, null, 2)}\n`; + if (Buffer.byteLength(contents) > MAX_WORKTREE_METADATA_BYTES) { + throw new Error(`Invalid Paseo worktree metadata file: ${metadataPath}`); + } + // Never recreate an archived Git directory while a queued mutation is pending. + try { + await mkdir(join(await getGitDirForWorktreeRoot(worktreeRoot), "paseo")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const tempPath = `${metadataPath}.${randomUUID()}.tmp`; + await writeFile(tempPath, contents, "utf8"); + await rename(tempPath, metadataPath); +} + +const metadataIo = pLimit(2); +const metadataMutations = new Map>(); + +// Only share in-flight identity resolution: a replaced symlink must not retain an old owner. +const metadataIdentities = new Map>(); + +function resolveMetadataIdentity(worktreeRoot: string): Promise { + const path = resolve(worktreeRoot); + const existing = metadataIdentities.get(path); + if (existing) return existing; + const identity = metadataIo(async () => { + try { + return await realpath(path); + } catch { + return path; + } + }).finally(() => { + if (metadataIdentities.get(path) === identity) metadataIdentities.delete(path); + }); + metadataIdentities.set(path, identity); + return identity; +} + +async function mutateMetadata( + worktreeRoot: string, + mutation: (root: string) => Promise, +): Promise { + const key = await resolveMetadataIdentity(worktreeRoot); + const previous = metadataMutations.get(key) ?? Promise.resolve(); + const pending = previous.catch(() => {}).then(() => metadataIo(() => mutation(key))); + metadataMutations.set(key, pending); + void pending + .finally(() => { + if (metadataMutations.get(key) === pending) metadataMutations.delete(key); + }) + .catch(() => {}); + return pending; } diff --git a/packages/server/src/utils/worktree-path.ts b/packages/server/src/utils/worktree-path.ts new file mode 100644 index 0000000000..d36abea762 --- /dev/null +++ b/packages/server/src/utils/worktree-path.ts @@ -0,0 +1,88 @@ +import { realpath, lstat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path"; +import { getRelativePathInsideRoot } from "./path.js"; + +async function normalizeAbsolute(path: string): Promise { + try { + return await realpath(path); + } catch { + return resolve(path); + } +} + +// Resolve a path that does not exist yet (the normal case for a worktree +// destination): realpath the nearest existing ancestor, then append the +// missing tail. A plain resolve() would let a symlinked parent smuggle +// the destination past the denylist — e.g. /alias -> /.git. +async function normalizeCandidatePath(path: string): Promise { + let current = resolve(path); + const missing: string[] = []; + while (!(await pathExists(current))) { + missing.unshift(basename(current)); + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + const resolvedAncestor = await normalizeAbsolute(current); + return missing.length > 0 ? join(resolvedAncestor, ...missing) : resolvedAncestor; +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +/** + * Validate a worktree destination. Throws + * with the reason on any violation; returns the normalized absolute path. + */ +export async function validateWorktreePath(options: { + candidate: string; + repoPath: string; +}): Promise { + const { candidate, repoPath } = options; + if (!isAbsolute(candidate)) { + throw new Error( + `Worktree hook returned a relative path '${candidate}'; it must be an absolute path`, + ); + } + const normalized = await normalizeCandidatePath(candidate); + const normalizedRepoPath = await normalizeAbsolute(repoPath); + + if (normalized === parse(normalized).root) { + throw new Error("Worktree hook returned the filesystem root, which is not allowed"); + } + if (normalized === (await normalizeAbsolute(homedir()))) { + throw new Error("Worktree hook returned the user's home directory, which is not allowed"); + } + if (normalized === normalizedRepoPath) { + throw new Error("Worktree hook returned the repository root itself, which is not allowed"); + } + if ( + [normalized, resolve(candidate)].some((path) => + path + .split(sep) + .some((part) => (process.platform === "win32" ? part.toLowerCase() : part) === ".git"), + ) + ) { + throw new Error(`Worktree hook returned a path inside a .git directory: ${normalized}`); + } + const relativeToCandidate = getRelativePathInsideRoot(normalized, normalizedRepoPath); + if (relativeToCandidate !== null && relativeToCandidate !== "") { + throw new Error(`Worktree hook returned an ancestor of the repository: ${normalized}`); + } + if ((await pathExists(normalized)) || (await pathExists(candidate))) { + throw new Error( + `Worktree hook returned a path that already exists: ${normalized}. Paseo creates the returned path.`, + ); + } + return normalized; +} diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index a550995fb3..35d59cce3f 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -1,5 +1,6 @@ // POSIX-only: git worktree and teardown shell fixtures /* eslint-disable max-nested-callbacks */ +import { validateWorktreePath } from "./worktree-path.js"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createWorktree as createWorktreePrimitive, @@ -21,7 +22,13 @@ import { type WorktreeConfig, } from "./worktree"; import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; -import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata } from "./worktree-metadata.js"; +import { + getPaseoWorktreeMetadataPath, + readPaseoWorktreeMetadata, + writePaseoWorktreeMetadata, + writePaseoWorktreeRuntimeMetadata, + writePaseoWorktreeFirstAgentBranchAutoNameMetadata, +} from "./worktree-metadata.js"; import { getCheckoutDiff, getCheckoutStatus, @@ -48,8 +55,8 @@ import { delimiter, dirname, join } from "path"; import { tmpdir } from "os"; import net from "node:net"; -function loadConfigForTest(repoRoot: string): PaseoConfig | null { - const result = readPaseoConfig(repoRoot); +async function loadConfigForTest(repoRoot: string): Promise { + const result = await readPaseoConfig(repoRoot); return result.ok ? result.config : null; } @@ -125,7 +132,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world")); expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); expect(existsSync(metadataPath)).toBe(true); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ @@ -153,7 +160,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ).resolves.toMatchObject({ allowed: true, worktreeRoot: join(worktreesRoot, projectHash) }); await expect( isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }), - ).resolves.toMatchObject({ allowed: false }); + ).resolves.toMatchObject({ allowed: true, worktreePath: result.worktreePath }); const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome, worktreesRoot }); expect(worktrees.map((entry) => entry.path)).toContain(result.worktreePath); @@ -256,7 +263,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { cwd: result.worktreePath, }); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, @@ -319,7 +326,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { // baseRefName must be the repo's default branch, not the checked-out branch // itself — otherwise the diff panel compares the branch against itself and // always shows no changes. - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, @@ -370,7 +377,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); expect(existsSync(result.worktreePath)).toBe(true); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" }); }); @@ -406,7 +413,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(result.branchName).toBe("main-1"); expect(existsSync(result.worktreePath)).toBe(true); - expect(readPaseoWorktreeMetadata(result.worktreePath)?.changeRequestLookupTarget).toEqual({ + expect( + (await readPaseoWorktreeMetadata(result.worktreePath))?.changeRequestLookupTarget, + ).toEqual({ headRef: "main-1", localBranchName: "main-1", }); @@ -459,7 +468,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { .trim(); expect(currentBranch).toBe("user/feature"); - const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadataPath = await getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ baseRefName: "main" }); }); @@ -566,7 +575,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { "from-origin\n", ); expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8")), + JSON.parse( + readFileSync(await getPaseoWorktreeMetadataPath(originResult.worktreePath), "utf8"), + ), ).toMatchObject({ baseRefName: "main" }); }); @@ -607,7 +618,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { // to the commit the worktree was cut from: "main" resolves local-first, which here is // a different commit than the fork's upstream. expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), + JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), ).toMatchObject({ baseRefName: "main", baseRef: "refs/remotes/upstream/main" }); // An untouched child must report no work of its own. Comparing against the wrong base @@ -694,7 +705,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); expect( - JSON.parse(readFileSync(getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), + JSON.parse(readFileSync(await getPaseoWorktreeMetadataPath(result.worktreePath), "utf8")), ).toMatchObject({ baseRefName: "release+hotfix", baseRef: "refs/remotes/upstream/release+hotfix", @@ -894,7 +905,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { worktreePath: result.worktreePath, branchName: result.branchName, }); - expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([ + expect(await getWorktreeSetupCommands(result.worktreePath)).toEqual([ 'greeting="hello from string setup"\necho "$greeting" > setup.log', ]); expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8").trim()).toBe( @@ -963,7 +974,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(readFileSync(join(repoDir, "setup-path.log"), "utf8").trim()).toBe("shim:ok"); }); - it("treats blank lifecycle strings as empty", () => { + it("treats blank lifecycle strings as empty", async () => { writeFileSync( join(repoDir, "paseo.json"), JSON.stringify({ @@ -974,11 +985,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - expect(getWorktreeSetupCommands(repoDir)).toEqual([]); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([]); + expect(await getWorktreeSetupCommands(repoDir)).toEqual([]); + expect(await getWorktreeTeardownCommands(repoDir)).toEqual([]); }); - it("filters non-string and blank entries from lifecycle arrays", () => { + it("filters non-string and blank entries from lifecycle arrays", async () => { writeFileSync( join(repoDir, "paseo.json"), JSON.stringify({ @@ -999,11 +1010,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - expect(getWorktreeSetupCommands(repoDir)).toEqual([ + expect(await getWorktreeSetupCommands(repoDir)).toEqual([ 'echo "first" > setup-array.log', 'echo "second" >> setup-array.log', ]); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([ + expect(await getWorktreeTeardownCommands(repoDir)).toEqual([ 'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', 'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', ]); @@ -1134,7 +1145,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ + expect(await getWorktreeTerminalSpecs(repoDir)).toEqual([ { name: "Dev Server", command: "npm run dev" }, { command: "cd packages/app && npm run dev" }, ]); @@ -1154,7 +1165,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }; writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); - expect(getWorktreeTerminalSpecs(repoDir)).toEqual([ + expect(await getWorktreeTerminalSpecs(repoDir)).toEqual([ { name: "Watch", command: "npm run watch" }, { command: "npm run test" }, ]); @@ -1172,7 +1183,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir)); + const scriptConfigs = getScriptConfigs(await loadConfigForTest(repoDir)); const typecheck = scriptConfigs.get("typecheck"); expect(typecheck).toEqual({ @@ -1196,7 +1207,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir)); + const scriptConfigs = getScriptConfigs(await loadConfigForTest(repoDir)); const server = scriptConfigs.get("server"); expect(server).toEqual({ @@ -1236,7 +1247,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }), ); - expect(getScriptConfigs(loadConfigForTest(repoDir))).toEqual( + expect(getScriptConfigs(await loadConfigForTest(repoDir))).toEqual( new Map([ ["valid", { command: "npm run valid" }], ["invalidType", { command: "npm run worker" }], @@ -1370,6 +1381,200 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); + describe("worktrees at explicit paths", () => { + let tempDir: string; + let repoDir: string; + let paseoHome: string; + + beforeEach(() => { + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-explicit-test-"))); + repoDir = join(tempDir, "test-repo"); + paseoHome = join(tempDir, "paseo-home"); + + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "hello\n"); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], { + cwd: repoDir, + }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("places, owns, lists, and deletes a worktree outside the legacy root", async () => { + // Sibling layout: /wt- + + const result = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "sibling-one", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/sibling" }, + paseoHome, + worktreePath: join(tempDir, "wt-sibling-one"), + }); + + expect(result.worktreePath).toBe(join(tempDir, "wt-sibling-one")); + expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); + expect(existsSync(await getPaseoWorktreeMetadataPath(result.worktreePath))).toBe(true); + + const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }); + expect(ownership.allowed).toBe(true); + expect(ownership.worktreePath).toBe(result.worktreePath); + // A subdirectory of the worktree resolves to the worktree root. + mkdirSync(join(result.worktreePath, "sub"), { recursive: true }); + const nested = await isPaseoOwnedWorktreeCwd(join(result.worktreePath, "sub"), { + paseoHome, + }); + expect(nested.worktreePath).toBe(result.worktreePath); + + const listed = await listPaseoWorktrees({ cwd: repoDir, paseoHome }); + expect(listed.map((entry) => entry.path)).toContain(result.worktreePath); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: result.worktreePath, paseoHome }); + expect(existsSync(result.worktreePath)).toBe(false); + }); + + it("removes branches fetched by the source plan when git worktree add fails", async () => { + // A branch that exists only on the remote: resolving a + // checkout-branch plan fetches it into a local branch. A script + // failure must not leave that branch behind, or the next attempt + // would uniquify to a different branch name. + const remoteDir = join(tempDir, "remote.git"); + execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { cwd: tempDir }); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["push", "origin", "main"], { cwd: repoDir }); + execFileSync("git", ["branch", "feature/remote-check"], { cwd: repoDir }); + execFileSync("git", ["push", "origin", "feature/remote-check"], { cwd: repoDir }); + execFileSync("git", ["branch", "-D", "feature/remote-check"], { cwd: repoDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); + + mkdirSync(join(tempDir, "occupied")); + writeFileSync(join(tempDir, "occupied", "keep"), "keep"); + await expect( + createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "remote-check", + source: { kind: "checkout-branch", branchName: "feature/remote-check" }, + paseoHome, + worktreePath: join(tempDir, "occupied"), + }), + ).rejects.toThrow(); + + const localBranches = execFileSync("git", ["branch", "--list", "feature/remote-check"], { + cwd: repoDir, + encoding: "utf8", + }); + expect(localBranches.trim()).toBe(""); + }); + + it("deletes only the registered destination nested beneath the legacy root", async () => { + const parent = join(paseoHome, "worktrees", "acme", "widgets"); + const worktreePath = join(parent, "topic"); + const siblingPath = join(parent, "sibling"); + await createWorktreePrimitive({ + cwd: repoDir, + paseoHome, + worktreeSlug: "topic", + worktreePath, + source: { kind: "branch-off", branchName: "topic", baseBranch: "main" }, + }); + await createWorktreePrimitive({ + cwd: repoDir, + paseoHome, + worktreeSlug: "sibling", + worktreePath: siblingPath, + source: { kind: "branch-off", branchName: "sibling", baseBranch: "main" }, + }); + mkdirSync(join(worktreePath, "nested")); + expect( + await isPaseoOwnedWorktreeCwd(join(worktreePath, "nested"), { paseoHome }), + ).toMatchObject({ allowed: true, worktreePath }); + await deletePaseoWorktree({ cwd: repoDir, paseoHome, worktreePath, registryProven: true }); + expect(existsSync(worktreePath)).toBe(false); + expect(readFileSync(join(siblingPath, "file.txt"), "utf8")).toBe("hello\n"); + }); + + it("rejects a destination under a symlink into .git", async () => { + const alias = join(tempDir, "git-alias"); + symlinkSync(join(repoDir, ".git"), alias, "dir"); + await expect( + validateWorktreePath({ candidate: join(alias, "new", "worktree"), repoPath: repoDir }), + ).rejects.toThrow("inside a .git directory"); + expect(existsSync(join(repoDir, ".git", "new"))).toBe(false); + }); + + it("uses an explicit worktreePath verbatim", async () => { + const explicitPath = join(tempDir, "explicit-wt"); + + const result = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "explicit-one", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/explicit" }, + paseoHome, + worktreePath: explicitPath, + }); + + expect(result.worktreePath).toBe(explicitPath); + expect(existsSync(join(explicitPath, "file.txt"))).toBe(true); + }); + + it("restore recreates at the recorded path", async () => { + const created = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "restorable", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/restorable" }, + paseoHome, + worktreePath: join(tempDir, "wt-sibling-one"), + }); + await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); + expect(existsSync(created.worktreePath)).toBe(false); + + const restored = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "restorable", + source: { kind: "restore", branchName: "feature/restorable", baseRef: "main" }, + paseoHome, + worktreePath: created.worktreePath, + }); + + expect(restored.worktreePath).toBe(created.worktreePath); + expect(existsSync(join(restored.worktreePath, "file.txt"))).toBe(true); + }); + + it("does not treat a copied marker in a non-worktree directory as ownership", async () => { + const created = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "marker-source", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/marker" }, + paseoHome, + worktreePath: join(tempDir, "wt-sibling-one"), + }); + const metadata = await readPaseoWorktreeMetadata(created.worktreePath); + expect(metadata).not.toBeNull(); + + // A plain directory with a .git file pointing at a copied admin dir + // that holds a paseo/worktree.json. Git does not list it as a + // worktree, so the copied marker must not prove ownership. + const fakeDir = join(tempDir, "fake-worktree"); + const fakeAdmin = join(tempDir, "fake-admin"); + mkdirSync(join(fakeAdmin, "paseo"), { recursive: true }); + mkdirSync(fakeDir, { recursive: true }); + writeFileSync(join(fakeAdmin, "paseo", "worktree.json"), JSON.stringify(metadata)); + writeFileSync(join(fakeDir, ".git"), `gitdir: ${fakeAdmin}\n`); + + const ownership = await isPaseoOwnedWorktreeCwd(fakeDir, { paseoHome }); + expect(ownership.allowed).toBe(false); + await expect( + deletePaseoWorktree({ cwd: repoDir, worktreePath: fakeDir, paseoHome }), + ).rejects.toThrow("Refusing to delete non-Paseo worktree"); + expect(existsSync(fakeDir)).toBe(true); + }); + }); + describe("paseo worktree manager", () => { let tempDir: string; let repoDir: string; @@ -1547,7 +1752,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); - expect(getWorktreeTeardownCommands(repoDir)).toEqual([ + expect(await getWorktreeTeardownCommands(repoDir)).toEqual([ 'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', ]); expect(readFileSync(join(repoDir, "teardown.log"), "utf8").trim()).toBe("teardown string"); @@ -1617,3 +1822,104 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); }); + +describe.skipIf(isPlatform("win32"))("async worktree metadata", () => { + it("serializes metadata mutations per worktree while another worktree progresses", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-order-")); + const first = join(root, "first"); + const second = join(root, "second"); + mkdirSync(join(first, ".git"), { recursive: true }); + mkdirSync(join(second, ".git"), { recursive: true }); + try { + await writePaseoWorktreeMetadata(first, { baseRefName: "main" }); + await writePaseoWorktreeMetadata(second, { baseRefName: "release" }); + const order: string[] = []; + const runtime = writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4101 }).then(() => + order.push("first-runtime"), + ); + const unrelated = writePaseoWorktreeRuntimeMetadata(second, { worktreePort: 4102 }).then(() => + order.push("second-runtime"), + ); + const naming = writePaseoWorktreeFirstAgentBranchAutoNameMetadata(first, { + placeholderBranchName: "topic", + }).then(() => order.push("first-naming")); + // A read issued while writes are pending observes their completed snapshot. + const snapshot = readPaseoWorktreeMetadata(first); + await Promise.all([runtime, unrelated, naming]); + expect(order.indexOf("first-runtime")).toBeLessThan(order.indexOf("first-naming")); + expect(await snapshot).toMatchObject({ + baseRefName: "main", + runtime: { worktreePort: 4101 }, + firstAgentBranchAutoName: { status: "pending", placeholderBranchName: "topic" }, + }); + expect(await readPaseoWorktreeMetadata(second)).toMatchObject({ + baseRefName: "release", + runtime: { worktreePort: 4102 }, + }); + const alias = join(root, "first-alias"); + symlinkSync(first, alias, "dir"); + await Promise.all([ + writePaseoWorktreeRuntimeMetadata(first, { worktreePort: 4103 }), + writePaseoWorktreeFirstAgentBranchAutoNameMetadata(alias, { + placeholderBranchName: "renamed", + }), + ]); + expect(await readPaseoWorktreeMetadata(first)).toMatchObject({ + runtime: { worktreePort: 4103 }, + firstAgentBranchAutoName: { placeholderBranchName: "renamed" }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("a queued metadata mutation cannot recreate an archived Git directory", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-archive-")); + mkdirSync(join(root, ".git")); + try { + await writePaseoWorktreeMetadata(root, { baseRefName: "main" }); + const pending = writePaseoWorktreeRuntimeMetadata(root, { worktreePort: 4101 }); + // The mutation is queued in a microtask, so archive wins this ordering. + rmSync(join(root, ".git"), { recursive: true }); + await expect(pending).rejects.toThrow("Not a git repository"); + expect(existsSync(join(root, ".git"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("recognizes a removed worktree under a symlinked ownership root", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-ownership-alias-")); + const actual = join(root, "actual"); + const alias = join(root, "worktrees"); + mkdirSync(actual); + symlinkSync(actual, alias, "dir"); + try { + const cwd = join(alias, "project", "removed"); + expect( + await isPaseoOwnedWorktreeCwd(cwd, { worktreesRoot: alias, knownGitCommonDir: null }), + ).toMatchObject({ allowed: true, worktreePath: cwd }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects oversized metadata and named pipes without reading them whole", async () => { + const root = mkdtempSync(join(tmpdir(), "worktree-metadata-admission-")); + mkdirSync(join(root, ".git", "paseo"), { recursive: true }); + try { + const path = await getPaseoWorktreeMetadataPath(root); + writeFileSync(path, JSON.stringify({ version: 1, baseRefName: "x".repeat(64 * 1024) })); + await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow( + "Invalid Paseo worktree metadata file", + ); + rmSync(path); + execFileSync("mkfifo", [path]); + await expect(readPaseoWorktreeMetadata(root)).rejects.toThrow( + "Invalid Paseo worktree metadata file", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 4d6f50bba6..f549b461f8 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -5,6 +5,7 @@ import { deletePaseoWorktree, isPaseoOwnedWorktreeCwd, mapWorkspaceCwdToWorktree, + runWorktreeTeardownCommands, slugify, type CreateWorktreeOptions, type WorktreeConfig, @@ -171,7 +172,15 @@ describe("paseo worktree manager", () => { expect(existsSync(created.worktreePath)).toBe(true); const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome }); - expect(ownership.allowed).toBe(true); + expect(ownership).toMatchObject({ allowed: true, worktreePath: created.worktreePath }); + // Windows may report the same root through native 8.3 and JS long spellings. + const nativeWorktreePath = realpathSync.native(created.worktreePath); + await expect( + runWorktreeTeardownCommands({ + worktreePath: created.worktreePath, + teardownCwd: nativeWorktreePath, + }), + ).resolves.toEqual([]); await expect( isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }), ).resolves.toMatchObject({ @@ -293,6 +302,22 @@ describe("paseo worktree manager", () => { expect(existsSync(created.worktreePath)).toBe(false); }); + it("deletes a worktree when Git reports its source through native and long path spellings", async () => { + const created = await createLegacyWorktreeForTest({ + branchName: "identity-delete", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "identity-delete", + paseoHome, + }); + await deletePaseoWorktree({ + cwd: realpathSync.native(repoDir), + worktreePath: created.worktreePath, + paseoHome, + }); + expect(existsSync(created.worktreePath)).toBe(false); + }); + it("is idempotent: deleting an already-absent worktree succeeds", async () => { const created = await createLegacyWorktreeForTest({ branchName: "idempotent-delete-branch", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index ca921fb219..2fc8bc05db 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,16 +1,17 @@ import { execFile } from "child_process"; import { promisify } from "util"; -import { constants as fsConstants, existsSync, mkdirSync, realpathSync, statSync } from "fs"; -import { copyFile, rm, stat } from "fs/promises"; +import { constants as fsConstants, realpath } from "fs"; +import { copyFile, mkdir, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; import stripAnsi from "strip-ansi"; +import pLimit from "p-limit"; import { buildStringCommandShellInvocation, createStringCommandShellEnv, } from "./string-command-shell.js"; -import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js"; +import { readPaseoConfig } from "./paseo-config-file.js"; export { PaseoConfigRawSchema, PaseoLifecycleCommandRawSchema, @@ -20,9 +21,11 @@ export { type PaseoConfig, type PaseoConfigRaw, } from "@getpaseo/protocol/paseo-config-schema"; -import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; +import { type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; import { createPaseoWorktreeChangeRequestHint, + hasPaseoWorktreeMetadata, + findPaseoWorktreeRootForCwd, normalizeBaseRefName, isQualifiedRef, type PaseoWorktreeChangeRequestHint, @@ -37,7 +40,12 @@ import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; -import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; +import { + expandTilde, + getRealpathAwareRelativePath, + getRelativePathInsideRoot, + isPathInsideRoot, +} from "./path.js"; import { terminateWithTreeKill } from "./tree-kill.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -212,6 +220,7 @@ export interface CreateWorktreeOptions { source: WorktreeSource; paseoHome?: string; worktreesRoot?: string; + worktreePath?: string; } export class BranchAlreadyCheckedOutError extends Error { @@ -246,21 +255,7 @@ export class InvalidGitBranchNameError extends Error { } } -export type ReadPaseoConfigResult = - | { ok: true; config: PaseoConfig | null } - | { ok: false; configPath: string; error: unknown }; - -export function readPaseoConfig(repoRoot: string): ReadPaseoConfigResult { - try { - const json = readPaseoConfigJson(repoRoot); - if (json === null) { - return { ok: true, config: null }; - } - return { ok: true, config: PaseoConfigSchema.parse(json) }; - } catch (error) { - return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error }; - } -} +export { readPaseoConfig, type ReadPaseoConfigResult } from "./paseo-config-file.js"; export function paseoConfigParseError(failure: { configPath: string; error: unknown }): Error { const detail = failure.error instanceof Error ? failure.error.message : String(failure.error); @@ -269,24 +264,26 @@ export function paseoConfigParseError(failure: { configPath: string; error: unkn }); } -function readPaseoConfigOrThrow(repoRoot: string): PaseoConfig | null { - const result = readPaseoConfig(repoRoot); +async function readPaseoConfigOrThrow(repoRoot: string): Promise { + const result = await readPaseoConfig(repoRoot); if (!result.ok) { throw paseoConfigParseError(result); } return result.config; } -export function getWorktreeSetupCommands(repoRoot: string): string[] { - return readPaseoConfigOrThrow(repoRoot)?.worktree?.setup ?? []; +export async function getWorktreeSetupCommands(repoRoot: string): Promise { + return (await readPaseoConfigOrThrow(repoRoot))?.worktree?.setup ?? []; } -export function getWorktreeTeardownCommands(repoRoot: string): string[] { - return readPaseoConfigOrThrow(repoRoot)?.worktree?.teardown ?? []; +export async function getWorktreeTeardownCommands(repoRoot: string): Promise { + return (await readPaseoConfigOrThrow(repoRoot))?.worktree?.teardown ?? []; } -export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] { - const terminals = readPaseoConfigOrThrow(repoRoot)?.worktree?.terminals; +export async function getWorktreeTerminalSpecs( + repoRoot: string, +): Promise { + const terminals = (await readPaseoConfigOrThrow(repoRoot))?.worktree?.terminals; if (!Array.isArray(terminals) || terminals.length === 0) { return []; } @@ -607,7 +604,7 @@ async function assertPortAvailable(port: number): Promise { async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise { try { const commonDir = await getGitCommonDir(worktreePath); - const normalizedCommonDir = normalizePathForOwnership(commonDir); + const normalizedCommonDir = await normalizePathForOwnership(commonDir); // Normal repo/worktree: common dir is /.git if (basename(normalizedCommonDir) === ".git") { return dirname(normalizedCommonDir); @@ -623,12 +620,12 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise< }); const topLevel = parseGitRevParsePath(stdout); if (topLevel) { - return normalizePathForOwnership(topLevel); + return await normalizePathForOwnership(topLevel); } } catch { // ignore } - return normalizePathForOwnership(worktreePath); + return await normalizePathForOwnership(worktreePath); } } @@ -641,7 +638,7 @@ export async function runWorktreeSetupCommands(options: { onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; }): Promise { // Read paseo.json from the worktree (it will have the same content as the source repo) - const setupCommands = getWorktreeSetupCommands(options.worktreePath); + const setupCommands = await getWorktreeSetupCommands(options.worktreePath); if (setupCommands.length === 0) { return []; } @@ -711,12 +708,12 @@ export async function resolveWorktreeRuntimeEnv(options: { const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); - let worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); + let worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath); if (worktreePort === null) { worktreePort = await getAvailablePort(); - const metadata = readPaseoWorktreeMetadata(options.worktreePath); + const metadata = await readPaseoWorktreeMetadata(options.worktreePath); if (metadata) { - writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); + await writePaseoWorktreeRuntimeMetadata(options.worktreePath, { worktreePort }); } } else { await assertPortAvailable(worktreePort); @@ -742,10 +739,10 @@ export async function runWorktreeTeardownCommands(options: { repoRootPath?: string; }): Promise { const teardownCwd = options.teardownCwd ?? options.worktreePath; - if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) { + if ((await getOwnershipRelativePath(options.worktreePath, teardownCwd)) === null) { throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`); } - const teardownCommands = getWorktreeTeardownCommands(teardownCwd); + const teardownCommands = await getWorktreeTeardownCommands(teardownCwd); if (teardownCommands.length === 0) { return []; } @@ -754,7 +751,7 @@ export async function runWorktreeTeardownCommands(options: { options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); const branchName = options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); - const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath); + const worktreePort = await readPaseoWorktreeRuntimePort(options.worktreePath); const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv( createExternalProcessEnv(process.env, { @@ -831,12 +828,12 @@ function deriveShortAlphanumericHash(value: string): string { export async function deriveWorktreeProjectHash(cwd: string): Promise { try { const commonDir = await getGitCommonDir(cwd); - const normalizedCommonDir = normalizePathForOwnership(commonDir); + const normalizedCommonDir = await normalizePathForOwnership(commonDir); const repoRoot = basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir; return deriveShortAlphanumericHash(repoRoot); } catch { - return deriveShortAlphanumericHash(normalizePathForOwnership(cwd)); + return deriveShortAlphanumericHash(await normalizePathForOwnership(cwd)); } } @@ -904,16 +901,46 @@ export function mapWorkspaceRelativeCwdToWorktree(input: { return mappedCwd; } -function normalizePathForOwnership(input: string): string { +// Keep the former realpathSync spelling on Windows: promises.realpath uses the +// native resolver and can return an 8.3 alias instead of the long path. +const resolveOwnershipPath = promisify(realpath); +const resolveNativeOwnershipPath = promisify(realpath.native); +const ownershipIdentityIo = pLimit(2); + +async function normalizePathForOwnership(input: string): Promise { try { - return realpathSync(input); + return await ownershipIdentityIo(() => resolveOwnershipPath(input)); } catch { return resolve(input); } } -function resolveRepoRootFromGitCommonDir(commonDir: string): string { - const normalizedCommonDir = normalizePathForOwnership(commonDir); +async function ownershipPathVariants(input: string): Promise { + const variants = new Set([input, await normalizePathForOwnership(input)]); + try { + variants.add(await ownershipIdentityIo(() => resolveNativeOwnershipPath(input))); + } catch { + // Missing descendants still match a lexical or established root spelling. + } + return [...variants]; +} + +// Keep lexical variants for missing paths: archive must still recognize a removed +// worktree below a symlinked worktrees root, whose full realpath no longer exists. +async function getOwnershipRelativePath(root: string, candidate: string): Promise { + const roots = await ownershipPathVariants(root); + const candidates = await ownershipPathVariants(candidate); + for (const rootPath of roots) { + for (const candidatePath of candidates) { + const relative = getRelativePathInsideRoot(rootPath, candidatePath); + if (relative !== null) return relative; + } + } + return null; +} + +async function resolveRepoRootFromGitCommonDir(commonDir: string): Promise { + const normalizedCommonDir = await normalizePathForOwnership(commonDir); return basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir; @@ -923,56 +950,93 @@ export async function isPaseoOwnedWorktreeCwd( cwd: string, options?: PaseoWorktreeOwnershipOptions, ): Promise { - const resolvedCwd = normalizePathForOwnership(cwd); + const resolvedCwd = await normalizePathForOwnership(cwd); // repoRoot is best-effort: git may be unreachable from the worktree (e.g. a // previous archive attempt removed the admin dir before the working tree // could be fully cleaned up). We still want to allow archiving in that case. let repoRoot: string | undefined; if (options?.knownGitCommonDir) { - repoRoot = resolveRepoRootFromGitCommonDir(options.knownGitCommonDir); + repoRoot = await resolveRepoRootFromGitCommonDir(options.knownGitCommonDir); } else if (options?.knownGitCommonDir === undefined) { try { const gitCommonDir = await getGitCommonDir(cwd); - repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); + repoRoot = await resolveRepoRootFromGitCommonDir(gitCommonDir); } catch { // ignore } } + // Worktrees placed at plugin-chosen paths are + // proven by the marker createWorktree writes unconditionally: a + // paseo/worktree.json in the worktree's git admin dir, with the path + // still registered in the repo's `git worktree list`. The marker alone + // is not proof — it lives in a git admin dir a user can copy into a + // hand-made directory that git does not know about. + if (options?.knownGitCommonDir !== null) { + const metadataProof = await resolveMetadataOwnedWorktree(cwd, repoRoot); + if (metadataProof) { + return { + allowed: true, + ...(repoRoot !== undefined ? { repoRoot } : {}), + worktreeRoot: metadataProof.worktreeRoot, + worktreePath: metadataProof.worktreePath, + }; + } + } + const worktreesBaseRoot = resolvePaseoWorktreesBaseRoot(options); - const relativePath = getRealpathAwareRelativePath(worktreesBaseRoot, resolvedCwd); + const relativePath = await getOwnershipRelativePath(worktreesBaseRoot, resolvedCwd); // Ownership is defined by the path living under //[/...]. // The / prefix is Paseo-private — nothing else writes there — so the // path shape alone is sufficient proof of ownership, even when git has already // forgotten about the worktree. - if (relativePath === null) { - return { - allowed: false, - ...(repoRoot !== undefined ? { repoRoot } : {}), - worktreePath: resolvedCwd, - }; + if (relativePath !== null) { + const parts = relativePath.split(sep).filter((part) => part.length > 0); + if (parts.length >= 2) { + const worktreesRoot = join(worktreesBaseRoot, parts[0]); + return { + allowed: true, + ...(repoRoot !== undefined ? { repoRoot } : {}), + worktreeRoot: worktreesRoot, + worktreePath: join(worktreesRoot, parts[1]), + }; + } } - const parts = relativePath.split(sep).filter((part) => part.length > 0); - if (parts.length < 2) { - return { - allowed: false, - ...(repoRoot !== undefined ? { repoRoot } : {}), - worktreePath: resolvedCwd, - }; - } - - const worktreesRoot = join(worktreesBaseRoot, parts[0]); return { - allowed: true, + allowed: false, ...(repoRoot !== undefined ? { repoRoot } : {}), - worktreeRoot: worktreesRoot, - worktreePath: join(worktreesRoot, parts[1]), + worktreePath: resolvedCwd, }; } +async function resolveMetadataOwnedWorktree( + cwd: string, + repoRoot: string | undefined, +): Promise<{ worktreePath: string; worktreeRoot: string } | null> { + try { + const root = await findPaseoWorktreeRootForCwd(cwd); + if (!root) return null; + const candidate = await normalizePathForOwnership(root); + if (repoRoot !== undefined && candidate === (await normalizePathForOwnership(repoRoot))) + return null; + const { stdout: listOutput } = await runGitCommand(["worktree", "list", "--porcelain"], { + cwd: candidate, + envOverlay: READ_ONLY_GIT_ENV, + }); + for (const entry of parseWorktreeList(listOutput)) { + if ((await normalizePathForOwnership(entry.path)) === candidate) { + return { worktreePath: candidate, worktreeRoot: dirname(candidate) }; + } + } + return null; + } catch { + return null; + } +} + type ParsedPaseoWorktreeInfo = Omit; function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] { @@ -1012,9 +1076,9 @@ function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] { return entries; } -function resolveWorktreeCreatedAtIso(worktreePath: string): string { +async function resolveWorktreeCreatedAtIso(worktreePath: string): Promise { try { - const stats = statSync(worktreePath); + const stats = await stat(worktreePath); const birthtimeMs = stats.birthtimeMs; const createdAtMs = Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs; @@ -1039,12 +1103,20 @@ export async function listPaseoWorktrees({ envOverlay: READ_ONLY_GIT_ENV, }); - return parseWorktreeList(stdout) - .map((entry) => Object.assign({}, entry, { path: normalizePathForOwnership(entry.path) })) - .filter((entry) => getRealpathAwareRelativePath(projectWorktreesRoot, entry.path) !== null) - .map((entry) => - Object.assign({}, entry, { createdAt: resolveWorktreeCreatedAtIso(entry.path) }), - ); + const mainCheckoutPath = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(cwd)); + const roots = await ownershipPathVariants(projectWorktreesRoot); + const worktrees: PaseoWorktreeInfo[] = []; + // Inspect foreign paths sequentially; a large worktree list must not consume the FS pool. + for (const entry of parseWorktreeList(stdout)) { + const path = await normalizePathForOwnership(entry.path); + if ( + !roots.some((root) => getRelativePathInsideRoot(root, path) !== null) && + !(path !== mainCheckoutPath && (await hasPaseoWorktreeMetadata(path))) + ) + continue; + worktrees.push({ ...entry, path, createdAt: await resolveWorktreeCreatedAtIso(path) }); + } + return worktrees; } export interface DeletePaseoWorktreeOptions { @@ -1055,6 +1127,30 @@ export interface DeletePaseoWorktreeOptions { worktreesRoot?: string; paseoHome?: string; worktreesBaseRoot?: string; + // The caller has a workspace registry record backing worktreePath (the + // record's cwd is this path). Registry membership is an ownership proof + // in its own right: it covers worktrees whose git admin dir is already + // gone, where the marker proof can no longer be verified. Only pass + // this when the record was matched against the path by the caller. + registryProven?: boolean; +} + +// Project scoping for delete: a proof for some other repo's worktree must +// not make this repo's delete remove it. Only refuses on a positive +// mismatch — when git has forgotten either side, the ownership proof the +// caller already established stands. +async function assertWorktreeBelongsToRepo(cwd: string, worktreePath: string): Promise { + let callerRepoRoot: string; + let targetRepoRoot: string; + try { + callerRepoRoot = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(cwd)); + targetRepoRoot = await resolveRepoRootFromGitCommonDir(await getGitCommonDir(worktreePath)); + } catch { + return; + } + if ((await getOwnershipRelativePath(callerRepoRoot, targetRepoRoot)) !== "") { + throw new Error("Refusing to delete non-Paseo worktree"); + } } export async function deletePaseoWorktree({ @@ -1065,6 +1161,7 @@ export async function deletePaseoWorktree({ worktreesRoot, paseoHome, worktreesBaseRoot, + registryProven, }: DeletePaseoWorktreeOptions): Promise { if (!worktreePath && !worktreeSlug) { throw new Error("worktreePath or worktreeSlug is required"); @@ -1083,22 +1180,27 @@ export async function deletePaseoWorktree({ } const requestedPath = worktreePath ?? join(resolvedWorktreesRoot, worktreeSlug!); - const resolvedRequested = normalizePathForOwnership(requestedPath); const ownership = await isPaseoOwnedWorktreeCwd(requestedPath, { paseoHome, worktreesRoot: worktreesBaseRoot, }); - const resolvedWorktree = - ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolvedRequested; - - const relativeWorktreePath = getRealpathAwareRelativePath( - resolvedWorktreesRoot, - resolvedWorktree, - ); - if (relativeWorktreePath === null || relativeWorktreePath === "") { + // Deletion is gated on the ownership proof set (legacy shape, the + // paseo/worktree.json marker verified against `git worktree list`, or a + // caller-asserted registry record), not on living under the per-project + // hashed root: plugin-placed worktrees live anywhere. + let resolvedWorktree: string; + if (registryProven) { + resolvedWorktree = await normalizePathForOwnership(requestedPath); + } else if (ownership.allowed && ownership.worktreePath) { + resolvedWorktree = ownership.worktreePath; + } else { throw new Error("Refusing to delete non-Paseo worktree"); } + if (cwd) { + await assertWorktreeBelongsToRepo(cwd, resolvedWorktree); + } + if (await pathExists(resolvedWorktree)) { for (const teardownCwd of teardownCwds ?? [resolvedWorktree]) { await runWorktreeTeardownCommands({ @@ -1195,6 +1297,67 @@ async function removeDirectoryWithRetries(path: string): Promise { } } +async function listLocalBranchNames(cwd: string): Promise> { + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname:short)", "refs/heads"], + { cwd }, + ); + return new Set( + stdout + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0), + ); +} + +// The one local branch a source plan can create while resolving: a +// checkout-branch plan fetches refs/heads/, and a +// change-request/PR plan fetches refs/heads/ (which is +// the plan's branchName). branch-off creates no local branch. Only this +// ref may be rolled back when creation fails — never any other +// branch that appeared in the meantime; other creators own those. +function planCreatedLocalBranch( + source: WorktreeSource, + sourcePlan: WorktreeSourcePlan, +): string | null { + switch (source.kind) { + case "checkout-branch": + return source.branchName; + case "checkout-change-request": + case "checkout-github-pr": + return sourcePlan.branchName; + default: + return null; + } +} + +async function readLocalBranchTip(cwd: string, branchName: string): Promise { + try { + const { stdout } = await runGitCommand( + ["rev-parse", "--verify", `refs/heads/${branchName}^{commit}`], + { cwd }, + ); + const tip = stdout.trim(); + return tip.length > 0 ? tip : null; + } catch { + return null; + } +} + +/** Resolve the default placement once, before presenting it to creation hooks. */ +export async function resolveDefaultWorktreePath( + options: Pick, +): Promise { + const original = join( + await getPaseoWorktreesRoot(options.cwd, options.paseoHome, options.worktreesRoot), + options.worktreeSlug, + ); + let candidate = original; + let suffix = 1; + while (await pathExists(candidate)) candidate = `${original}-${suffix++}`; + return candidate; +} + /** * Create a git worktree with proper naming conventions */ @@ -1204,56 +1367,73 @@ export const createWorktree = async ({ worktreeSlug, paseoHome, worktreesRoot, + worktreePath: explicitWorktreePath, }: CreateWorktreeOptions): Promise => { + const branchesBefore = + source.kind === "checkout-branch" || + source.kind === "checkout-change-request" || + source.kind === "checkout-github-pr" + ? await listLocalBranchNames(cwd) + : new Set(); const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); - let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); - mkdirSync(dirname(worktreePath), { recursive: true }); + const fetchedBranch = planCreatedLocalBranch(source, sourcePlan); + const fetchedTip = + fetchedBranch && !branchesBefore.has(fetchedBranch) + ? await readLocalBranchTip(cwd, fetchedBranch) + : null; + try { + let worktreePath = + explicitWorktreePath ?? + (await resolveDefaultWorktreePath({ cwd, paseoHome, worktreesRoot, worktreeSlug })); + await mkdir(dirname(worktreePath), { recursive: true }); - // Also handle worktree path collision - let finalWorktreePath = worktreePath; - let pathSuffix = 1; - while (existsSync(finalWorktreePath)) { - finalWorktreePath = `${worktreePath}-${pathSuffix}`; - pathSuffix++; - } - - // Primitive owner for `git worktree add`; callers route through createWorktreeCore. - await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], { - cwd, - timeout: 120_000, - }); - worktreePath = normalizePathForOwnership(finalWorktreePath); - - if (sourcePlan.pushRemote) { - await configureWorktreePushRemote({ + // Primitive owner for `git worktree add`; callers route through createWorktreeCore. + await runGitCommand(["worktree", "add", worktreePath, ...sourcePlan.addArguments], { cwd, - branchName: sourcePlan.branchName, - remote: sourcePlan.pushRemote, + timeout: 120_000, }); - } - if (sourcePlan.trackingRemote) { - await configureWorktreeTrackingRemote({ - cwd, - branchName: sourcePlan.branchName, - remote: sourcePlan.trackingRemote, + worktreePath = await normalizePathForOwnership(worktreePath); + + if (sourcePlan.pushRemote) { + await configureWorktreePushRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.pushRemote, + }); + } + if (sourcePlan.trackingRemote) { + await configureWorktreeTrackingRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.trackingRemote, + }); + } + + await writePaseoWorktreeMetadata(worktreePath, { + baseRefName: sourcePlan.metadataBaseRefName, + ...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}), + ...(sourcePlan.changeRequestLookupTarget + ? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget } + : {}), }); + + await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); + + return { + branchName: sourcePlan.branchName, + worktreePath, + comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName, + }; + } catch (error) { + if ( + fetchedBranch && + fetchedTip && + (await readLocalBranchTip(cwd, fetchedBranch)) === fetchedTip + ) { + await runGitCommand(["branch", "-D", fetchedBranch], { cwd }).catch(() => {}); + } + throw error; } - - writePaseoWorktreeMetadata(worktreePath, { - baseRefName: sourcePlan.metadataBaseRefName, - ...(sourcePlan.metadataBaseRef ? { baseRef: sourcePlan.metadataBaseRef } : {}), - ...(sourcePlan.changeRequestLookupTarget - ? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget } - : {}), - }); - - await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); - - return { - branchName: sourcePlan.branchName, - worktreePath, - comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName, - }; }; interface ResolveWorktreeSourcePlanOptions { diff --git a/packages/server/tsconfig.scripts.json b/packages/server/tsconfig.scripts.json index cc8e24efd4..6a75825b00 100644 --- a/packages/server/tsconfig.scripts.json +++ b/packages/server/tsconfig.scripts.json @@ -19,6 +19,11 @@ "declaration": false, "sourceMap": true }, - "include": ["scripts/supervisor-entrypoint.ts", "scripts/dev-runner.ts", "scripts/supervisor.ts"], + "include": [ + "scripts/supervisor-entrypoint.ts", + "scripts/dev-runner.ts", + "scripts/supervisor.ts", + "src/executable-resolution/which.d.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/packages/server/tsconfig.server.json b/packages/server/tsconfig.server.json index 9c868b42df..1c7a5c3579 100644 --- a/packages/server/tsconfig.server.json +++ b/packages/server/tsconfig.server.json @@ -22,7 +22,12 @@ "declarationMap": true, "sourceMap": true }, - "include": ["src/server/**/*", "src/services/**/*", "src/terminal/**/*"], + "include": [ + "src/server/**/*", + "src/services/**/*", + "src/terminal/**/*", + "src/executable-resolution/which.d.ts" + ], "exclude": [ "node_modules", "dist", diff --git a/packages/website/package.json b/packages/website/package.json index 852d40d0ab..b45ec39396 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.13.0", + "version": "0.15.0-beta.1", "private": true, "type": "module", "scripts": { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 7e34e35e5c..bae380d4c8 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -70,6 +70,14 @@ }, "additionalProperties": {} }, + "usage": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "browserTools": { "type": "object", "properties": { @@ -257,48 +265,6 @@ "providers": { "type": "object", "properties": { - "openai": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - }, - "stt": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, "local": { "type": "object", "properties": { @@ -552,6 +518,15 @@ } } }, + "idleRuntimeCleanup": { + "type": "boolean" + }, + "resumeOnStartup": { + "type": "boolean" + }, + "resumePrompt": { + "type": "string" + }, "catalogRefreshTimeoutMs": { "type": "integer", "exclusiveMinimum": 0, @@ -636,24 +611,57 @@ "type": "boolean" }, "stt": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - }, - "confidenceThreshold": { - "type": "number" + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] } }, "additionalProperties": false @@ -678,21 +686,57 @@ "additionalProperties": false }, "stt": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] }, "turnDetection": { "type": "object", @@ -704,29 +748,58 @@ "additionalProperties": false }, "tts": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "speakerId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "speed": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "voice": { - "type": "string", - "minLength": 1 - }, - "speakerId": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "speed": { - "type": "number" + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "voice": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] } }, "additionalProperties": false diff --git a/packages/website/src/docs.ts b/packages/website/src/docs.ts index 1325cd933f..896d86daf9 100644 --- a/packages/website/src/docs.ts +++ b/packages/website/src/docs.ts @@ -186,7 +186,13 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = fal // A section's top-level directory and its category name the same thing, so // only directories below it become collapsible groups. - if (stripTopDirectory) directories.shift(); + if ( + stripTopDirectory && + directories[0] && + doc.frontmatter.category?.toLowerCase().endsWith(directories[0].toLowerCase()) + ) { + directories.shift(); + } let current = nodes; let parent: Extract | undefined; diff --git a/packages/website/src/plugin-docs-navigation.test.ts b/packages/website/src/plugin-docs-navigation.test.ts index 0aeed8ff88..a3e537620a 100644 --- a/packages/website/src/plugin-docs-navigation.test.ts +++ b/packages/website/src/plugin-docs-navigation.test.ts @@ -1,7 +1,26 @@ import { describe, expect, it } from "vitest"; -import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect } from "./docs"; +import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs"; describe("plugin documentation", () => { + it.each(["en", "zh-CN"] as const)( + "nests local file copying under Git worktrees in %s", + (locale) => { + const workspaces = buildDocsNavTree(getDocs(locale)).find( + (node) => node.type === "category" && node.label === "Workspaces", + ); + expect(workspaces).toMatchObject({ + children: expect.arrayContaining([ + expect.objectContaining({ + type: "group", + href: "/docs/worktrees", + children: [expect.objectContaining({ href: "/docs/worktrees/worktreeinclude" })], + }), + ]), + }); + expect(getDoc("worktrees/worktreeinclude", locale)).toBeDefined(); + }, + ); + it("groups current guides by task without version navigation", () => { const plugins = buildDocsNavTree(getDocs("en")).find( (node) => node.type === "category" && node.label === "Plugins", @@ -14,11 +33,20 @@ describe("plugin documentation", () => { { type: "page", label: "Publishing", href: "/docs/plugins/publishing" }, { type: "page", label: "Provider plugins", href: "/docs/plugins/providers" }, { type: "page", label: "Migration", href: "/docs/plugins/migration" }, + { type: "page", label: "Hooks", href: "/docs/plugins/hooks" }, { type: "page", label: "Reference", href: "/docs/plugins/reference" }, ], }); }); + it.each(["en", "zh-CN"] as const)("resolves worktree placement hook links in %s", (locale) => { + const hooks = getDoc("plugins/hooks", locale)!; + expect(parseHeadings(hooks.content).map((heading) => heading.id)).toContain( + "worktreecreatebefore", + ); + expect(hooks.content).toContain("plugin-examples/worktree-placement"); + }); + it.each(["v0.7", "v0.8"])("redirects %s links to existing current pages", (version) => { for (const page of ["", "/index", "/reference", "/providers", "/migration"]) { const target = `/docs/plugins${page === "/index" ? "" : page}`; @@ -32,7 +60,7 @@ describe("plugin documentation", () => { }); it("serves canonical pages without redirect loops", () => { - for (const page of ["", "/publishing", "/reference", "/providers", "/migration"]) { + for (const page of ["", "/publishing", "/reference", "/providers", "/migration", "/hooks"]) { expect(getLegacyDocsRedirect(`/docs/plugins${page}`)).toBeUndefined(); expect(getLegacyDocsRedirect(`/docs/plugins${page}.md`)).toBeUndefined(); } diff --git a/packages/website/src/schedule-docs.test.ts b/packages/website/src/schedule-docs.test.ts index 015d85fe6c..76fca93287 100644 --- a/packages/website/src/schedule-docs.test.ts +++ b/packages/website/src/schedule-docs.test.ts @@ -4,7 +4,7 @@ import { expect, it } from "vitest"; const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs"); -it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])( +it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees/index.md"])( "%s makes schedule run retention discoverable", async (file) => { const markdown = await readFile(path.join(docsRoot, file), "utf8"); diff --git a/patches/@gorhom+bottom-sheet+5.2.14.patch b/patches/@gorhom+bottom-sheet+5.2.14.patch index f84d46f8fd..e0af7b8dcb 100644 --- a/patches/@gorhom+bottom-sheet+5.2.14.patch +++ b/patches/@gorhom+bottom-sheet+5.2.14.patch @@ -13,6 +13,27 @@ index ee51e76..dc1bc17 100644 if (_providedOnChange) { _providedOnChange(_index, _position, _type); } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js +index 973d05c..b484b5f 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js +@@ -86,7 +86,7 @@ function useAnimatedLayout(containerLayoutState, topInset, bottomInset, modal, s + }); + }, [state, verticalInset, modal]); + (0, _react.useEffect)(() => { +- _reactNative.Dimensions.addEventListener('change', ({ ++ const subscription = _reactNative.Dimensions.addEventListener('change', ({ + window + }) => { + state.modify(_state => { +@@ -96,6 +96,7 @@ function useAnimatedLayout(containerLayoutState, topInset, bottomInset, modal, s + return _state; + }); + }); ++ return () => subscription.remove(); + }, [state]); + //#endregion + diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js index e44ad86..96555ef 100644 --- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js @@ -54,6 +75,27 @@ index 94fabdb..60ca66a 100644 if (_providedOnChange) { _providedOnChange(_index, _position, _type); } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js b/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js +index b0eec93..67713a2 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js +@@ -82,7 +82,7 @@ export function useAnimatedLayout(containerLayoutState, topInset, bottomInset, m + }); + }, [state, verticalInset, modal]); + useEffect(() => { +- Dimensions.addEventListener('change', ({ ++ const subscription = Dimensions.addEventListener('change', ({ + window + }) => { + state.modify(_state => { +@@ -92,6 +92,7 @@ export function useAnimatedLayout(containerLayoutState, topInset, bottomInset, m + return _state; + }); + }); ++ return () => subscription.remove(); + }, [state]); + //#endregion + diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/index.js b/node_modules/@gorhom/bottom-sheet/lib/module/index.js index 8259637..eb057fe 100644 --- a/node_modules/@gorhom/bottom-sheet/lib/module/index.js @@ -101,6 +143,26 @@ index 2954b3f..7260656 100644 if (_providedOnChange) { _providedOnChange(_index, _position, _type); +diff --git a/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts b/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts +index 68c674d..564833c 100644 +--- a/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts ++++ b/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts +@@ -106,13 +106,14 @@ export function useAnimatedLayout( + [state, verticalInset, modal] + ); + useEffect(() => { +- Dimensions.addEventListener('change', ({ window }) => { ++ const subscription = Dimensions.addEventListener('change', ({ window }) => { + state.modify(_state => { + 'worklet'; + _state.window = window; + return _state; + }); + }); ++ return () => subscription.remove(); + }, [state]); + //#endregion + diff --git a/node_modules/@gorhom/bottom-sheet/src/index.ts b/node_modules/@gorhom/bottom-sheet/src/index.ts index 7aaea57..6a5f5a4 100644 --- a/node_modules/@gorhom/bottom-sheet/src/index.ts diff --git a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch index 3a61dc1d94..1568bd6039 100644 --- a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch +++ b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch @@ -1,4 +1,3 @@ -// UPSTREAM: https://github.com/software-mansion/enriched-markdown/pull/997 diff --git a/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp b/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp index 77465ad..94997f7 100644 --- a/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp @@ -40,9 +39,10 @@ index 0a1c6c2..5935112 100644 struct OnLinkLongPress { diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +index c12937c..8105833 100644 --- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt -@@ -95,7 +95,7 @@ +@@ -95,7 +95,7 @@ class EnrichedMarkdown( private var accessibilityLabels = AccessibilityLabels() private var textBreakStrategy: String = BreakStrategyUtils.DEFAULT_STRATEGY @@ -51,7 +51,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c private var onLinkLongPressCallback: ((String) -> Unit)? = null private var onTaskListItemPressCallback: ((Int, Boolean, String) -> Unit)? = null private var onLatexErrorCallback: LatexErrorReporter? = null -@@ -296,7 +296,7 @@ +@@ -296,7 +296,7 @@ class EnrichedMarkdown( } } @@ -60,7 +60,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c onLinkPressCallback = callback } -@@ -405,7 +405,7 @@ +@@ -405,7 +405,7 @@ class EnrichedMarkdown( val codeLinkRegex = inlineCodeLinkRegex val renderId = ++currentRenderId @@ -69,7 +69,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c try { val filtered = if (isStreaming) { -@@ -418,13 +418,13 @@ +@@ -418,13 +418,13 @@ class EnrichedMarkdown( if (renderableMarkdown.isEmpty()) { postToMain(renderId) { applyRenderedSegments(emptyList(), false) } @@ -85,7 +85,19 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c } val segments = splitASTIntoSegments(ast) -@@ -646,8 +646,12 @@ +@@ -478,6 +478,11 @@ class EnrichedMarkdown( + if (forceHeight || topologyChanged || heightBefore != heightAfter) { + MeasurementStore.invalidate(id) + requestLayout() ++ stateWrapper?.let { ++ val state = Arguments.createMap() ++ state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter) ++ it.updateState(state) ++ } + } + } + } +@@ -646,8 +651,12 @@ class EnrichedMarkdown( renderId: Long, action: () -> Unit, ) { @@ -320,19 +332,6 @@ index 0cfad74..df88c5d 100644 node: MarkdownASTNode, builder: SpannableStringBuilder, - onLinkPress: ((String) -> Unit)?, -+ onLinkPress: ((String, String) -> Unit)?, - onLinkLongPress: ((String) -> Unit)?, - factory: RendererFactory, - ) { -diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt -index 360a436..2bca5be 100644 ---- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt -+++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt -@@ -19,7 +19,7 @@ class LinkRenderer( - override fun render( - node: MarkdownASTNode, - builder: SpannableStringBuilder, -- onLinkPress: ((String) -> Unit)?, + onLinkPress: ((String, String) -> Unit)?, onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, @@ -593,29 +592,6 @@ index e190548..b2e6cf2 100644 isHorizontalScrollBarEnabled = true overScrollMode = View.OVER_SCROLL_NEVER importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO -diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt -index ff00069..f3e13aa 100644 ---- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt -+++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt -@@ -12,7 +12,7 @@ import com.swmansion.enriched.markdown.utils.text.extensions.applyBlockStyleFont - - class LinkSpan( - val url: String, -- private val onLinkPress: ((String) -> Unit)?, -+ private val onLinkPress: ((String, String) -> Unit)?, - private val onLinkLongPress: ((String) -> Unit)?, - private val styleCache: SpanStyleCache, - private val blockStyle: BlockStyle, -@@ -30,7 +30,8 @@ class LinkSpan( - return - } - -- onLinkPress?.invoke(url) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url) -+ val sourceType = if (keepsCodeFont) "inline-code" else if (recognizedLink) "text" else "markdown" -+ onLinkPress?.invoke(url, sourceType) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url, sourceType) - } - - fun onLongClick(widget: View): Boolean { diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt index b4d3cdc..4652b10 100644 --- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt @@ -1918,10 +1894,17 @@ index 75b509cd2..c68da0c05 100644 +{"version":3,"names":["createMd4cModule","_scriptName","document","currentScript","src","undefined","moduleArg","moduleRtn","Module","ENVIRONMENT_IS_WEB","ENVIRONMENT_IS_WORKER","arguments_","thisProgram","scriptDirectory","readAsync","readBinary","URL","href","url","response","fetch","credentials","ok","arrayBuffer","Error","status","out","console","log","bind","err","error","wasmBinary","ABORT","readyPromiseResolve","readyPromiseReject","wasmMemory","HEAP8","HEAPU8","HEAP16","HEAPU16","HEAP32","HEAPU32","HEAPF32","HEAPF64","HEAP64","HEAPU64","runtimeInitialized","updateMemoryViews","b","buffer","Int8Array","Int16Array","Uint8Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","BigInt64Array","BigUint64Array","preRun","length","addOnPreRun","shift","callRuntimeCallbacks","onPreRuns","initRuntime","wasmExports","postRun","addOnPostRun","onPostRuns","runDependencies","dependenciesFulfilled","addRunDependency","id","removeRunDependency","callback","abort","what","e","WebAssembly","RuntimeError","wasmBinaryFile","findWasmBinary","base64Decode","getBinarySync","file","ArrayBuffer","isView","getWasmBinary","binaryFile","instantiateArrayBuffer","imports","binary","instance","instantiate","reason","instantiateAsync","getWasmImports","env","wasmImports","wasi_snapshot_preview1","createWasm","receiveInstance","module","exports","assignWasmExports","receiveInstantiationResult","result","info","Promise","resolve","reject","mod","inst","ExitStatus","name","constructor","message","callbacks","cb","push","b64","b1","b2","i","j","bLength","output","base64ReverseLookup","charCodeAt","noExitRuntime","stackRestore","val","__emscripten_stack_restore","stackSave","_emscripten_stack_get_current","UTF8Decoder","TextDecoder","UTF8ArrayToString","heapOrArray","idx","maxBytesToRead","NaN","endIdx","endPtr","decode","subarray","str","u0","String","fromCharCode","u1","u2","ch","UTF8ToString","ptr","___assert_fail","condition","filename","line","func","ExceptionInfo","excPtr","set_type","type","get_type","set_destructor","destructor","get_destructor","set_caught","caught","get_caught","set_rethrown","rethrown","get_rethrown","init","set_adjusted_ptr","adjustedPtr","get_adjusted_ptr","exceptionLast","uncaughtExceptionCount","___cxa_throw","__abort_js","getHeapMax","alignMemory","size","alignment","Math","ceil","growMemory","pages","byteLength","grow","_emscripten_resize_heap","requestedSize","oldSize","maxHeapSize","cutDown","overGrownHeapSize","min","newSize","max","replacement","getCFunc","ident","writeArrayToMemory","array","set","lengthBytesUTF8","len","c","stringToUTF8Array","heap","outIdx","maxBytesToWrite","startIdx","u","codePointAt","stringToUTF8","outPtr","stackAlloc","sz","__emscripten_stack_alloc","stringToUTF8OnStack","ret","ccall","returnType","argTypes","args","opts","toC","string","arr","convertReturnValue","Boolean","cArgs","stack","converter","onDone","cwrap","numericArgs","every","numericRet","_parseMarkdown","__assert_fail","__cxa_throw","_abort_js","emscripten_resize_heap","run","doRun","setTimeout","preInit","default","define"],"sourceRoot":"../../../src","sources":["web/wasm/md4c.js"],"sourcesContent":["\"use strict\";\nvar createMd4cModule = (() => {\n var _scriptName = typeof document != 'undefined' ? document.currentScript?.src : undefined;\n return (\nasync function(moduleArg = {}) {\n var moduleRtn;\n\nvar Module=moduleArg;var ENVIRONMENT_IS_WEB=true;var ENVIRONMENT_IS_WORKER=false;var arguments_=[];var thisProgram=\"./this.program\";var scriptDirectory=\"\";var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(\".\",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:\"same-origin\"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+\" : \"+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var readyPromiseResolve,readyPromiseReject;var wasmMemory;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module[\"preRun\"]){if(typeof Module[\"preRun\"]==\"function\")Module[\"preRun\"]=[Module[\"preRun\"]];while(Module[\"preRun\"].length){addOnPreRun(Module[\"preRun\"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports[\"__wasm_call_ctors\"]()}function postRun(){if(Module[\"postRun\"]){if(typeof Module[\"postRun\"]==\"function\")Module[\"postRun\"]=[Module[\"postRun\"]];while(Module[\"postRun\"].length){addOnPostRun(Module[\"postRun\"].shift())}}callRuntimeCallbacks(onPostRuns)}var runDependencies=0;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module[\"monitorRunDependencies\"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module[\"monitorRunDependencies\"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module[\"onAbort\"]?.(what);what=\"Aborted(\"+what+\")\";err(what);ABORT=true;what+=\". Build with -sASSERTIONS for more info.\";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){return base64Decode(\"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NCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBgJAIAFFDQACQAJAIAEoAgQiBARAIAEoAgAiBw0BCyADQQA2AugBIANCADcD4AEMAQsgBEH4////B08NDwJAIARBC08EQCAEQQdyIghBAWoQfyEFIAMgCEH/////B2s2AugBIAMgBTYC4AEgAyAENgLkAQwBCyADIAQ6AOsBIANB4AFqIQULIAQEQCAFIAcgBPwKAAALIAQgBWpBADoAACADKALkASADLQDrASIEIATAQQBIG0UNACAGQcDcACADQeABahAaCwJAAkAgASgCFCIEBEAgASgCECIFDQELIANBADYC2AEgA0IANwPQAQwBCyAEQfj///8HTw0PAkAgBEELTwRAIARBB3IiB0EBahB/IQEgAyAHQf////8HazYC2AEgAyABNgLQASADIAQ2AtQBDAELIAMgBDoA2wEgA0HQAWohAQsgBARAIAEgBSAE/AoAAAsgASAEakEAOgAAIAMoAtQBIAMtANsBIgEgAcBBAEgbRQ0AIAZBzNwAIANB0AFqEBoLIAMsANsBQQBIBEAgAygC2AEaIAMoAtABEHILIAMsAOsBQQBODQAgAygC6AEaIAMoAuABEHILIAMgADYCzAEgAyAGNgLIASAAIAAoAgRBAWo2AgQgAyADKQLIATcDACACIAMQGCAAIAAoAgQiAkEBazYCBCACDQwgACAAKAIAKAIIEQEAIAAQfAwMC0E8EH8iAEIANwIQIABBBjYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AsQBIAMgAEEMajYCwAEgAyADKQLAATcDCCACIANBCGoQGAwLC0E8EH8iAEIANwIQIABBBzYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2ArwBIAMgAEEMajYCuAEgAyADKQK4ATcDECACIANBEGoQGAwKC0E8EH8iAEIANwIQIABBCTYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2ArQBIAMgAEEMajYCsAEgAyADKQKwATcDGCACIANBGGoQGAwJC0E8EH8iAEIANwIQIABBCjYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AqwBIAMgAEEMajYCqAEgAyADKQKoATcDICACIANBIGoQGAwIC0E8EH8iAEIANwIQIABBCDYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AqQBIAMgAEEMajYCoAEgAyADKQKgATcDKCACIANBKGoQGAwHC0E8EH8iAEIANwIQIABBCzYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCAAQQxqIQYCQCABRQ0AAkACQCABKAIEIgQEQCABKAIAIgcNAQsgA0EANgLoASADQgA3A+ABDAELIARB+P///wdPDQkCQCAEQQtPBEAgBEEHciIIQQFqEH8hBSADIAhB/////wdrNgLoASADIAU2AuABIAMgBDYC5AEMAQsgAyAEOgDrASADQeABaiEFCyAEBEAgBSAHIAT8CgAACyAEIAVqQQA6AAAgAygC5AEgAy0A6wEiBCAEwEEASBtFDQAgBkHA3AAgA0HgAWoQGgsCQAJAIAEoAhQiBARAIAEoAhAiBQ0BCyADQQA2AtgBIANCADcD0AEMAQsgBEH4////B08NCQJAIARBC08EQCAEQQdyIgdBAWoQfyEBIAMgB0H/////B2s2AtgBIAMgATYC0AEgAyAENgLUAQwBCyADIAQ6ANsBIANB0AFqIQELIAQEQCABIAUgBPwKAAALIAEgBGpBADoAACADKALUASADLQDbASIBIAHAQQBIG0UNACAGQczcACADQdABahAaCyADLADbAUEASARAIAMoAtgBGiADKALQARByCyADLADrAUEATg0AIAMoAugBGiADKALgARByCyADIAA2ApwBIAMgBjYCmAEgACAAKAIEQQFqNgIEIAMgAykCmAE3AzAgAiADQTBqEBggACAAKAIEIgJBAWs2AgQgAg0GIAAgACgCACgCCBEBACAAEHwMBgtBPBB/IgBCADcCECAAQRg2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKUASADIABBDGo2ApABIAMgAykCkAE3AzggAiADQThqEBgMBQtBPBB/IgBCADcCECAAQRk2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKMASADIABBDGo2AogBIAMgAykCiAE3A0AgAiADQUBrEBgMBAtBPBB/IgBCADcCECAAQRo2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKEASADIABBDGo2AoABIAMgAykCgAE3A0ggAiADQcgAahAYDAMLQTwQfyIAQgA3AhAgAEEbNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIAMgADYCfCADIABBDGo2AnggAyADKQJ4NwNQIAIgA0HQAGoQGAwCC0E8EH8iAEIANwIQIABBHDYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AnQgAyAAQQxqNgJwIAMgAykCcDcDWCACIANB2ABqEBgMAQtBPBB/IgBCADcCECAAQR02AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgJsIAMgAEEMajYCaCADIAMpAmg3A2AgAiADQeAAahAYCyADQfABaiQAIAkPCxAHAAvXGAEKfyMAQeAAayIDJAACQCACRQRAQQEhAAwBCwJAAkAgAA4JAgEBAQEBAQEAAQtBACEAIAJBADoAJCADQgA3AxggA0IANwMQIANCgICAgICAgMA/NwMgIANCADcDCAJAIAIoAiwgAiwAMyIEIARBAEgiARsiBUUNACACQShqIgYoAgAiByAGIAEbIQEDQCAAIAFqLQAAEFMEQCAAQQFqIgAgBUcNAQwCCwsgByAGIARBAEgbIABqLQAAQTxHDQACQCAAQQFqIgAgBU8NACAHIAYgBEEASBsiASAAai0AAEEvRg0BA0AgACABai0AABBTRQ0BIABBAWoiACAFRw0ACwwBCyAAIAVPDQAgByAGIARBAEgbIQcgACEBAkADQCABIAdqIgQtAAAQUw0BIAQtAABBPkYNASAELQAAQS9GDQEgAUEBaiIBIAVHDQALIAUhAQsgACABRg0AIANBCGogASAAaxCYASAAIAFJBEADQCADQQhqIAIoAiggBiACLAAzQQBIGyAAai0AABBiwBCdASAAQQFqIgAgAUcNAAsLIAEgAigCLCIIIAIsADMiCSAJQQBIGyIATw0AIAlBH3YhBSADQRRqIQogA0HQAGohCwNAIAYoAgAgBiAFQQFxGyEFA0AgASAFaiIELQAAEFMEQCABQQFqIgEgAEkNAQwDCwsgBC0AAEE+Rg0BIAQtAABBL0YNAQJAAkAgACABSwRAIAEhBAJAA0AgBCAFaiIHLQAAQT1GDQEgBy0AABBTDQEgBy0AAEE+Rg0BIActAABBL0YNASAEQQFqIgQgAEkNAAsgACEECyABIARHDQELIAFBAWohAQwBCyALQQA2AgAgA0IANwNIIANByABqIAQgAWsQmAEgASAESQRAA0AgA0HIAGogAigCKCAGIAIsADNBAEgbIAFqLQAAEGLAEJ0BIAFBAWoiASAESQ0ACwsCQAJ/AkAgBCACKAIsIAIsADMiACAAQQBIIgUbIgBPDQAgBigCACAGIAUbIQUCQANAIAQgBWoiAS0AABBTRQ0BIARBAWoiBCAARw0ACyAADAILIAEtAABBPUcNAAJAAkACQAJAAkADQCAAIAQiB0EBaiIETQ0BIAQgBWoiAS0AABBTDQALIANBQGsiCUEANgIAIANCADcDOAJAIAEtAABBIkYNACABLQAAQSdGDQAgBCEBA0AgASAFaiIJLQAAEFMNAyAJLQAAQT5GDQMgCS0AAEEvRg0DIAFBAWoiASAARw0ACyAAIQEMAgsgB0ECaiIHIQQCQCAAIAdNDQAgAS0AACEBA0AgBCAFai0AACABRg0BIARBAWoiBCAASQ0ACyAAIQQLIAAgB2siACAEIAdrIgEgACABSRsiAEH4////B08NAwJAAkAgAEELTwRAIABBB3IiCEEBahB/IQEgAyAIQf////8HazYCNCADIAE2AiwgAyAANgIwDAELIAMgADoANyADQSxqIQEgAEUNAQsgAEUNACABIAUgB2ogAPwKAAALIAAgAWpBADoAACADLABDQQBIBEAgAygCQBogAygCOBByCyAJIAMoAjQ2AgAgAyADKQIsNwM4IAQgBCACKAIsIAIsADMiACAAQQBIG0lqIQEMAgsgA0FAa0EANgIAIANCADcDOCAEIQELIAAgB00NAiAAIARrIgAgASAEayIHIAAgB0kbIgBB+P///wdPDQECQAJAIABBC08EQCAAQQdyIglBAWoQfyEHIAMgCUH/////B2s2AjQgAyAHNgIsIAMgADYCMAwBCyADIAA6ADcgA0EsaiEHIABFDQELIABFDQAgByAEIAVqIAD8CgAACyAAIAdqQQA6AAAgAywAQ0EASARAIAMoAkAaIAMoAjgQcgsgA0FAayADKAI0NgIAIAMgAykCLDcDOAsgAyADQcgAajYCXCADQSxqIAogA0HIAGogA0HcAGoQGQJAIAMoAiwiBUEUaiIAIANBOGpGDQAgAywAQyEEIAUsAB9BAE4EQCAEQQBOBEAgACADKQM4NwIAIAAgA0FAaygCADYCCAwHCyAAIAMoAjggAygCPBCcAQwBCyAAIAMoAjggA0E4aiAEQQBIIgUbIAMoAjwgBCAFGxCbAQsgAywAQ0EATg0EIAMoAkAaIAMoAjgQcgwECxAHAAtBCBCoAUGqDxCBASIAQdzbADYCACAAQejbAEEBEAAACyAECyEBIAMgA0HIAGo2AiwgA0E4aiAKIANByABqIANBLGoQGQJ/IAMoAjgiACwAH0EASARAIABBADYCGCAAKAIUDAELIABBADoAHyAAQRRqC0EAOgAACyADLABTQQBIBEAgAygCUBogAygCSBByCyACKAIsIQggAi0AMyEJCyABIAggCUH/AXEgCcBBAEgiBRsiAEkNAAsLQQAhAEEAIQUCQCADKAIMIAMsABMiASABQQBIG0EFRw0AIAMoAgggA0EIaiABQQBIG0H4DEEFEFUNACADQQM6AFMgA0EAOgBLIANBnxEvAAA7AUggA0GhES0AADoASgJ/IANBFGohCkEAIQkgA0HIAGoiBigCBCAGLAALIgggCEEASCIEGyIHIQggBigCACAGIAQbIgshBgJAIAciBEEESQ0AAn8gBEEEayIEQQRxBEAgByEBIAsMAQsgCygAAEGV08feBWwiBkEYdiAGc0GV08feBWwgB0GV08feBWxzIQggBCEBIAtBBGoLIQYgBEEESQ0AIAEhBANAIAYoAARBldPH3gVsIgFBGHYgAXNBldPH3gVsIAYoAABBldPH3gVsIgFBGHYgAXNBldPH3gVsIAhBldPH3gVsc0GV08feBWxzIQggBkEIaiEGIARBCGsiBEEDSw0ACwsCQAJAAkACQCAEQQFrDgMCAQADCyAGLQACQRB0IAhzIQgLIAYtAAFBCHQgCHMhCAsgCCAGLQAAc0GV08feBWwhCAsCQCAKKAIEIgFFDQAgCigCAAJ/IAhBDXYgCHNBldPH3gVsIgZBD3YgBnMiBCABQQFrcSABaSIIQQFNDQAaIAQgASAESw0AGiAEIAFwCyIMQQJ0aigCACIGRQ0AIAYoAgAiBkUNACABQQFrIQkgCEEBSyEKA0ACQAJAIAQgBigCBCIIRwRAAkAgCkUEQCAIIAlxIQgMAQsgASAISw0AIAggAXAhCAsgCCAMRw0CDAELIAYoAgwgBiwAEyIIIAhBAEgiCBsgB0cNACAGKAIIIAZBCGogCBsgCyAHEFUNACAGIQkMAwsgBigCACIGDQELC0EADAELIAkLIQEgAywAU0EASARAIAMoAlAaIAMoAkgQcgsgAUUNACABKAIYIAEsAB8iBCAEQQBIG0UNAEE8EH8iBUIANwIQIAVBITYCDCAFQcwXNgIAIAVCADcCBCAFQgA3AjQgBUKAgID8AzcCLCAFQgA3AhggBUIANwIgIAVBADYCKCADQQM6AFMgA0HyDS8AADsBSCADQfQNLQAAOgBKIANBADoASyADIANByABqNgIsIANBOGogBUEcaiADQcgAaiADQSxqEBkCQCADKAI4IgAgAUYNACABQRRqIQcgAEEUaiEEIAEsAB8hBiAALAAfQQBOBEAgBkEATgRAIAQgBykCADcCACAEIAcoAgg2AggMAgsgBCABKAIUIAEoAhgQnAEMAQsgBCABKAIUIAcgBkEASCIAGyABKAIYIAYgABsQmwELIAMsAFNBAEgEQCADKAJQGiADKAJIEHILIAVBDGohAAsgA0EUahAcIAMsABNBAEgEQCADKAIQGiADKAIIEHILAkAgAiwAM0EASARAIAIoAihBADoAACACQQA2AiwMAQsgAkEAOgAzIAJBADoAKAsCQCAARQ0AIAIoAgggAigCDCICRg0AIAJBCGsoAgAhAiADIAU2AgwgAyAANgIIIAJBJGogA0EIahAdAkAgAygCDCIARQ0AIAAgACgCBCICQQFrNgIEIAINACAAIAAoAgAoAggRAQAgABB8C0EAIQAMAgtBACEAIAVFDQEgBSAFKAIEIgJBAWs2AgQgAg0BIAUgBSgCACgCCBEBACAFEHwMAQtBACEAIAIoAgggAigCDEYNACACEBAgAigCDCIFIAIoAghrQQlJDQACQCAFQQRrKAIAIgFFDQAgASABKAIEIgRBAWs2AgQgBA0AIAEgASgCACgCCBEBACABEHwLIAIgBUEIazYCDAsgA0HgAGokACAAC5EmAQd/IwBBoAJrIgMkAAJAAkAgAkUEQEEBIQkMAQsCQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAAQQFrDhMCBAUGCAEHDwAKCwwNDg4QEAMJEAtBPBB/IgBCADcCECAAQQE2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKMAiADIABBDGo2AogCIAMgAykCiAI3AwAgAiADEBgMDwtBPBB/IgBCADcCECAAQQQ2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AigCQCABRQ0AIAEoAgAhASADQQA6AJUCIANBBkEBIAEgAUEBTBsiASABQQZOG0EwcjoAlAIgA0GUAmoQYSIBQfj///8HTw0QAkACQCABQQtPBEAgAUEHciIFQQFqEH8hBCADIAVB/////wdrNgKAAiADIAQ2AvgBIAMgATYC/AEMAQsgAyABOgCDAiADQfgBaiEEIAFFDQELIAFFDQAgBCADQZQCaiAB/AoAAAsgASAEakEAOgAAIANBtNwANgKYAiADQaQBaiAAQRxqQbTcACADQZgCahAZAkAgAygCpAEiBEEUaiIBIANB+AFqRg0AIAMsAIMCIQUgBCwAH0EATgRAIAVBAE4EQCABIAMpAvgBNwIAIAEgAygCgAI2AggMAgsgASADKAL4ASADKAL8ARCcAQwBCyABIAMoAvgBIANB+AFqIAVBAEgiBBsgAygC/AEgBSAEGxCbAQsgAywAgwJBAE4NACADKAKAAhogAygC+AEQcgsgAyAANgL0ASADIABBDGo2AvABIAAgACgCBEEBajYCBCADIAMpAvABNwMIIAIgA0EIahAYIAAgACgCBCICQQFrNgIEIAINDiAAIAAoAgAoAggRAQAgABB8DA4LQTwQfyIAQgA3AhAgAEEMNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIAMgADYC7AEgAyAAQQxqNgLoASADIAMpAugBNwMQIAIgA0EQahAYDA0LQTwQfyIAQgA3AhAgAEEgNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBAJAIAFFDQACQAJAIAEoAgQiBQRAIAEoAgAiBg0BCyADQQA2AoACIANCADcD+AEMAQsgBUH4////B08NDwJAIAVBC08EQCAFQQdyIgdBAWoQfyEBIAMgB0H/////B2s2AoACIAMgATYC+AEgAyAFNgL8AQwBCyADIAU6AIMCIANB+AFqIQELIAUEQCABIAYgBfwKAAALIAEgBWpBADoAACADKAL8ASADLQCDAiIBIAHAQQBIG0UNACAEQaTdACADQfgBahAaCyADLACDAkEATg0AIAMoAoACGiADKAL4ARByCyADIAA2AuQBIAMgBDYC4AEgACAAKAIEQQFqNgIEIAMgAykC4AE3AxggAiADQRhqEBggACAAKAIEIgJBAWs2AgQgAg0MIAAgACgCACgCCBEBACAAEHwMDAtBPBB/IgBCADcCECAAQQ02AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgLcASADIABBDGo2AtgBIAMgAykC2AE3AyAgAiADQSBqEBgMCwtBPBB/IgBCADcCECAAQQ42AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAEEMaiEEAkAgAUUNACABKAIAIgFBAUYNACADQfgBaiABEJ8BIARBjN0AIANB+AFqEBogAywAgwJBAE4NACADKAKAAhogAygC+AEQcgsgAyAANgLUASADIAQ2AtABIAAgACgCBEEBajYCBCADIAMpAtABNwMoIAIgA0EoahAYIAAgACgCBCICQQFrNgIEIAINCiAAIAAoAgAoAggRAQAgABB8DAoLQTwQfyIAQgA3AhAgAEEPNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBAJAIAFFDQAgASgCAEUNACAEQfTcACADQfgBakHfDxAbIgUQGiAFLAALQQBIBEAgBSgCCBogBSgCABByCyAEQYDdACADQfgBakHfD0H/DyABLQAEQd8BcUHYAEYbEBsiARAaIAEsAAtBAE4NACABKAIIGiABKAIAEHILIAMgADYCzAEgAyAENgLIASAAIAAoAgRBAWo2AgQgAyADKQLIATcDMCACIANBMGoQGCAAIAAoAgQiAkEBazYCBCACDQkgACAAKAIAKAIIEQEAIAAQfAwJC0E8EH8iAEIANwIQIABBEDYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCAAQQxqIQQCQCABRQ0AAkAgAS0AICIFRQ0AIANBADoApQEgAyAFOgCkASAEQdjcACADQfgBaiADQaQBahAbIgUQGiAFLAALQQBODQAgBSgCCBogBSgCABByCwJAAkAgASgCFCIFBEAgASgCECIGDQELIANBADYCgAIgA0IANwP4AQwBCyAFQfj///8HTw0LAkAgBUELTwRAIAVBB3IiB0EBahB/IQEgAyAHQf////8HazYCgAIgAyABNgL4ASADIAU2AvwBDAELIAMgBToAgwIgA0H4AWohAQsgBQRAIAEgBiAF/AoAAAsgASAFakEAOgAAIAMoAvwBIAMtAIMCIgEgAcBBAEgbRQ0AIARB6NwAIANB+AFqEBoLIAMsAIMCQQBODQAgAygCgAIaIAMoAvgBEHILIAMgADYCxAEgAyAENgLAASAAIAAoAgRBAWo2AgQgAyADKQLAATcDOCACIANBOGoQGCAAIAAoAgQiAkEBazYCBCACDQggACAAKAIAKAIIEQEAIAAQfAwIC0E8EH8iAEIANwIQIABBETYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2ArwBIAMgAEEMajYCuAEgAyADKQK4ATcDQCACIANBQGsQGAwHC0E8EH8iAEIANwIQIABBHzYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCAAQQxqIQQCQCABRQ0AIANB+AFqIAEoAgAQnwEgA0GY3QA2ApgCIANBpAFqIABBHGpBmN0AIANBmAJqEBkCQCADKAKkASIFQRRqIgEgA0H4AWpGDQAgAywAgwIhBiAFLAAfQQBOBEAgBkEATgRAIAEgAykC+AE3AgAgASADKAKAAjYCCAwCCyABIAMoAvgBIAMoAvwBEJwBDAELIAEgAygC+AEgA0H4AWogBkEASCIFGyADKAL8ASAGIAUbEJsBCyADLACDAkEATg0AIAMoAoACGiADKAL4ARByCyADIAA2ArQBIAMgBDYCsAEgACAAKAIEQQFqNgIEIAMgAykCsAE3A0ggAiADQcgAahAYIAAgACgCBCICQQFrNgIEIAINBiAAIAAoAgAoAggRAQAgABB8DAYLQTwQfyIAQgA3AhAgAEESNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBQJAIAFFDQAgA0EAOgCAAiADQuPesZv0rZ239AA3A/gBIANBCDoAgwIgA0GkAWogASgCABCfASADIANB+AFqNgKUAiADQZgCaiAAQRxqIgYgA0H4AWogA0GUAmoQGQJAIAMoApgCIgdBFGoiBCADQaQBakYNACADLACvASEIIAcsAB9BAE4EQCAIQQBOBEAgBCADKQKkATcCACAEIAMoAqwBNgIIDAILIAQgAygCpAEgAygCqAEQnAEMAQsgBCADKAKkASADQaQBaiAIQQBIIgcbIAMoAqgBIAggBxsQmwELIAMsAK8BQQBIBEAgAygCrAEaIAMoAqQBEHILIAMsAIMCQQBIBEAgAygCgAIaIAMoAvgBEHILIANBEBB/IgQ2AvgBIANCjICAgICCgICAfzcC/AEgBEH9CSgAADYACCAEQfUJKQAANwAAIARBADoADCADQaQBaiABKAIEEJ8BIAMgA0H4AWo2ApQCIANBmAJqIAYgA0H4AWogA0GUAmoQGQJAIAMoApgCIgdBFGoiBCADQaQBakYNACADLACvASEIIAcsAB9BAE4EQCAIQQBOBEAgBCADKQKkATcCACAEIAMoAqwBNgIIDAILIAQgAygCpAEgAygCqAEQnAEMAQsgBCADKAKkASADQaQBaiAIQQBIIgcbIAMoAqgBIAggBxsQmwELIAMsAK8BQQBIBEAgAygCrAEaIAMoAqQBEHILIAMsAIMCQQBIBEAgAygCgAIaIAMoAvgBEHILIANBEBB/IgQ2AvgBIANCjICAgICCgICAfzcC/AEgBEHwCSgAADYACCAEQegJKQAANwAAIARBADoADCADQaQBaiABKAIIEJ8BIAMgA0H4AWo2ApQCIANBmAJqIAYgA0H4AWogA0GUAmoQGQJAIAMoApgCIgRBFGoiASADQaQBakYNACADLACvASEGIAQsAB9BAE4EQCAGQQBOBEAgASADKQKkATcCACABIAMoAqwBNgIIDAILIAEgAygCpAEgAygCqAEQnAEMAQsgASADKAKkASADQaQBaiAGQQBIIgQbIAMoAqgBIAYgBBsQmwELIAMsAK8BQQBIBEAgAygCrAEaIAMoAqQBEHILIAMsAIMCQQBODQAgAygCgAIaIAMoAvgBEHILIAMgADYCoAEgAyAFNgKcASAAIAAoAgRBAWo2AgQgAyADKQKcATcDUCACIANB0ABqEBggACAAKAIEIgJBAWs2AgQgAg0FIAAgACgCACgCCBEBACAAEHwMBQtBPBB/IgBCADcCECAAQRM2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKYASADIABBDGo2ApQBIAMgAykClAE3A1ggAiADQdgAahAYDAQLQTwQfyIAQgA3AhAgAEEUNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIAMgADYCkAEgAyAAQQxqNgKMASADIAMpAowBNwNgIAIgA0HgAGoQGAwDC0E8EH8iAEIANwIQIABBFTYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AogBIAMgAEEMajYChAEgAyADKQKEATcDaCACIANB6ABqEBgMAgtBPBB/IgRCADcCECAEQcwXNgIAIARCADcCBCAEQgA3AjQgBEKAgID8AzcCLCAEQRZBFyAAQQ5GGzYCDCAEQgA3AhggBEIANwIgIARBADYCKAJAIAFFDQBBngohBSABKAIAQQFrIgBBAk0EQCAAQQJ0QbwYaigCACEFCyADQQA6AP0BIANByQ0tAAA6APwBIANBBToAgwIgA0HFDSgAADYC+AEgBRBhIgBB+P///wdPDQMCQAJAIABBC08EQCAAQQdyIgZBAWoQfyEBIAMgBkH/////B2s2AqwBIAMgATYCpAEgAyAANgKoAQwBCyADIAA6AK8BIANBpAFqIQEgAEUNAQsgAEUNACABIAUgAPwKAAALIAAgAWpBADoAACADIANB+AFqNgKUAiADQZgCaiAEQRxqIANB+AFqIANBlAJqEBkCQCADKAKYAiIBQRRqIgAgA0GkAWpGDQAgAywArwEhBSABLAAfQQBOBEAgBUEATgRAIAAgAykCpAE3AgAgACADKAKsATYCCAwCCyAAIAMoAqQBIAMoAqgBEJwBDAELIAAgAygCpAEgA0GkAWogBUEASCIBGyADKAKoASAFIAEbEJsBCyADLACvAUEASARAIAMoAqwBGiADKAKkARByCyADLACDAkEATg0AIAMoAoACGiADKAL4ARByCyADIAQ2AoABIAMgBEEMajYCfCAEIAQoAgRBAWo2AgQgAyADKQJ8NwNwIAIgA0HwAGoQGCAEIAQoAgQiAkEBazYCBCACDQEgBCAEKAIAKAIIEQEAIAQQfAwBCyACQQE6ACQgAiwAM0EASARAIAIoAihBADoAACACQQA2AiwMAQsgAkEAOgAzIAJBADoAKAsgA0GgAmokACAJDwsQBwALoAIBBH8jAEEQayICJAACQCAAKAIYIAAsAB8iASABQQBIG0UNACAAKAIMIgMgACgCCEYNAEE8EH8iAUIANwI0IAFCgICA/AM3AiwgAUECNgIMIAFBzBc2AgAgAUIANwIEIAFCADcCHCABQgA3AiQgASAAQRRqIgQpAgA3AhAgASAEKAIINgIYIABBADoAFCAAQQA6AB8gA0EIaygCACEDIAIgATYCDCACIAFBDGo2AgggA0EkaiACQQhqEB0CQCACKAIMIgFFDQAgASABKAIEIgNBAWs2AgQgAw0AIAEgASgCACgCCBEBACABEHwLIAAsAB9BAEgEQCAAKAIUQQA6AAAgAEEANgIYDAELIABBADoAHyAAQQA6ABQLIAJBEGokAAuqAQIDfwF+QQEhAwJAAkACQCAAKAIAQQJr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AgBBIUYNAQsgAUEIaiIBIARHDQEMAgsLIANBADYCHCADQgA3AhQgA0EANgIQIANCADcCCANAAkACQCAFKAIAIgEoAgBBD0cNACABKAIoIAEoAiQiAWtBCEcNACABKAIAKAIAQSFHDQAgAygCCCICIAMoAgwiBkcEQEE8EH8iAUIANwIQIAEgCTYCDCABQcwXNgIAIAFCADcCBCABQgA3AhggAUIANwIgIAFCgICAgICAgMA/NwIoIAMgATYCBCADIAFBDGo2AgAgASADQRBqIgcoAgA2AjggASAGNgI0IAEgAjYCMCAHQQA2AgAgA0IANwMIIANBFGogAxAdAkAgAygCBCIBRQ0AIAEgASgCBCICQQFrNgIEIAINACABIAEoAgAoAggRAQAgARB8CyAFKAIAKAIkIQELIAMoAhgiAiADKAIcIgdJBEAgAiABKAIANgIAIAIgASgCBCIBNgIEIAEEQCABIAEoAgRBAWo2AgQLIAMgAkEIajYCGAwCCyACIAMoAhQiBmsiAkEDdSIOQQFqIghBgICAgAJPDQdB/////wEgByAGayIHQQJ1IgsgCCAIIAtJGyAHQfj///8HTxsiCEGAgICAAk8NCCAIQQN0IgcQfyILIAJqIgggASgCADYCACAIIAEoAgQiATYCBCAHIAtqIQcgCCABBH8gASABKAIEQQFqNgIEIAMoAhwaIAMoAhggBmsiAkEDdQUgDgtBA3RrIQEgAgRAIAEgBiAC/AoAAAsgAyAHNgIcIAMgCEEIaiICNgIYIAMgATYCFCAGBEAgBhByCyADIAI2AhgMAQsgA0EIaiAFEB0LIAVBCGoiBSAERw0ACwJAIAMoAggiAiADKAIMIgRGDQBBPBB/IgFCADcCECABIAk2AgwgAUHMFzYCACABQgA3AgQgAUIANwIYIAFCADcCICABQoCAgICAgIDAPzcCKCADIAE2AgQgAyABQQxqNgIAIAEgAygCEDYCOCABIAQ2AjQgASACNgIwIANBADYCECADQgA3AwggA0EUaiADEB0gAygCBCIBRQ0AIAEgASgCBCICQQFrNgIEIAINACABIAEoAgAoAggRAQAgARB8CyAAKAIkIA1qIgkhASAJQQhqIgQgACgCKCICRwRAA0AgBCkCACEPIARCADcCACABKAIEIQUgASAPNwIAAkAgBUUNACAFIAUoAgQi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QCACIAEoAgA2AgAgAiABKAIENgIEIAFCADcCACAAIAJBCGo2AgQPCwJAIAIgACgCACIEayIFQQN1IgdBAWoiAkGAgICAAkkEQEH/////ASADIARrIgZBAnUiAyACIAIgA0kbIAZB+P///wdPGyIDBH8gA0GAgICAAk8NAiADQQN0EH8FQQALIgYgBWoiAiABKAIANgIAIAIgASgCBDYCBCABQgA3AgAgAiAHQQN0ayEBIAUEQCABIAQgBfwKAAALIAAgBiADQQN0ajYCCCAAIAJBCGoiAjYCBCAAIAE2AgAgBARAIAQQcgsgACACNgIEDwsQEwALEBQAC84BAQN/IwBBEGsiAiQAIAAQEAJAIAEoAgAiA0UNACAAKAIIIAAoAgwiAEYNACAAQQhrKAIAIQQgASgCBCIABEAgACAAKAIEQQFqNgIECyACIAA2AgwgAiADNgIIIARBJGogAkEIahAdIAIoAgwiAEUNACAAIAAoAgQiA0EBazYCBCADDQAgACAAKAIAKAIIEQEAIAAQfAsCQCABKAIEIgFFDQAgASABKAIEIgBBAWs2AgQgAA0AIAEgASgCACgCCBEBACABEHwLIAJBEGokAAvdBQEHfyMAQTBrIgEkACAAKAIAIgMoAgAiBCADKAIEIgJHBEAgBCEDAkACQANAIAMoAgAQEQRAIAIgA0EIaiIDRw0BDAILC0E8EH8iAkIANwIQIAJBATYCDCACQcwXNgIAIAJCADcCBCACQgA3AjQgAkKAgID8AzcCLCACQgA3AhggAkIANwIgIAJBADYCKCABIAI2AhwgASACQQxqNgIYIAFBqgotAAA6ABAgAUEFOgAXIAFBpgooAAA2AgwgAUEAOgARIAFBADoABCABQfTk1asGNgIAIAFBBDoACyABIAFBDGo2AiQgAUEoaiACQRxqIAFBDGogAUEkahAZAkAgASgCKCIDQRRqIgQgAUYNACADLAAfQQBOBEAgBCABKQIANwIAIAQgASgCCDYCCAwBCyAEIAFBBBCbASABLAALQQBODQAgASgCCBogASgCABByCyABLAAXQQBIBEAgASgCFBogASgCDBByCyAAKAIAIQUgAigCMCIHBEAgByIDIAIoAjQiBEcEQANAAkAgBEEEaygCACIDRQ0AIAMgAygCBCIGQQFrNgIEIAYNACADIAMoAgAoAggRAQAg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IAZBAmoiDiEGIAcgDk0NAAJAA0AgBiAKai0AACIJQQlrIg9BF01BAEEBIA90QY2AgARxGw0BAkAgCUHbAGsOAwIAAgALIAZBAWoiBiAHRw0ACyAHIQYLIAYgDk0NACAGQQFqIgkgB08NACAGIApqLQAAQd0ARw0AIAkgCmotAABBOkYNAgsgAUEBaiIBIAhHDQALIAghAQsCQAJAAkAgAUEBayIJIAEgAUEBSxsgASACIARPGyIIQQN0IgYQcSIHRQRAIAAoAiQiAUUNAUHEFCAAKAIsIAERAAAMAQsCQCABIAhLBEAgBkUNASAHIAxBCGogBvwKAAAMAQsgByAENgIEIAcgAjYCACABQQJJDQAgCUEDdCICRQ0AIAdBCGogDEEIaiAC/AoAAAsgACgCWCECIAAoAmAiBiAAKAJkIgRJDQIgAiAEQQF2IARqQQggBBsiBCAAKAJcbBBzIgINASAAKAIkIgFFDQBB1RQgACgCLCABEQAACyAHEHJBfyERDAgLIAAgBDYCZCAAIAI2AlggACgCYCEGCyADIA1rIQQgCiANaiENIAIgACgCXCIJIAZsaiEDIAkEQCADQQAgCfwLAAsgAyAENgIEIAMgDTYCACADIA0gBBA2NgIIIAAgACgCYEEBajYCYCADIAg2AhggAyAHNgIUIAFBAEgEQEF/IREMBwsgASALaiELDAELIAEgCmotAABB2wBHDQFBACETIBIgC2siFUEBayIDQQAgAyAVTRshFyABQQFqIQhBACEOQQAhCUEAIRBBACEGA0BB5wcgBiAGQecHTBshGCAMIBBBA3RqKAIEIQ8DQAJ/AkACQAJAAkAgCCAPSQRAAkACQAJAIAggCmoiAS0AAEHbAGsOAwwABAELIAhBAWoiAiAAKAIEIgNPDQECQCACIApqLQAAIgJBIWtB/wFxQQ9JDQAgAkH/AXFBCmsOdQACAgACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAAAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAAAAAICAgICAgICAgICAgICAgICAgICAgICAgICAAAAAAILIA4gCCAJGyEOIBMgECAJGyETIAhBAmoiCQwHCyAAKAIEIQMLIAEgAyAIayAFQSxqEDciBEH/AE0NAkEAIQNB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IghrSg0AIABBICACIAggA0ECaiAHIAxBEGprIgYgBkECayADSBsgBiADGyIDaiIHIAQQbCAAIA4gDRBoIABBMCACIAcgBEGAgARzEGwgACAMQRBqIAYQaCAAQTAgAyAGa0EAQQAQbCAAIA8gCRBoIABBICACIAcgBEGAwABzEGwgAiAHIAIgB0obIQoLIAxBsARqJAAgCgutBQIGfgR/IAEgASgCAEEHakF4cSIBQRBqNgIAIAAgASkDACECIAEpAwghBiMAQSBrIgEkACAGQv///////z+DIQMCfiAGQjCIQv//AYMiBKciAEGB+ABrQf0PTQRAIANCBIYgAkI8iIQhAyAAQYD4AGutIQQCQCACQv//////////D4MiAkKBgICAgICAgAhaBEAgA0IBfCEDDAELIAJCgICAgICAgIAIUg0AIANCAYMgA3whAwtCACADIANC/////////wdWIgAbIQIgAK0gBHwMAQsCQCACIAOEUA0AIARC//8BUg0AIANCBIYgAkI8iIRCgICAgICAgASEIQJC/w8MAQsgAEH+hwFLBEBCACECQv8PDAELQYD4AEGB+AAgBFAiCBsiCyAAayIJQfAASgRAQgAhAkIADAELIAFBEGohCiACIQQgAyADQoCAgICAgMAAhCAIGyIDIQUCQEGAASAJayIIQcAAcQRAIAIgCEFAaq2GIQVCACEEDAELIAhFDQAgBSAIrSIHhiAEQcAAIAhrrYiEIQUgBCAHhiEECyAKIAQ3AwAgCiAFNwMIAkAgCUHAAHEEQCADIAlBQGqtiCECQgAhAwwBCyAJRQ0AIANBwAAgCWuthiACIAmtIgSIhCECIAMgBIghAwsgASACNwMAIAEgAzcDCCABKQMIQgSGIAEpAwAiA0I8iIQhAgJAIAAgC0cgASkDECABKQMYhEIAUnGtIANC//////////8Pg4QiA0KBgICAgICAgAhaBEAgAkIBfCECDAELIANCgICAgICAgIAIUg0AIAJCAYMgAnwhAgsgAkKAgICAgICACIUgAiACQv////////8HViIAGyECIACtCyEDIAFBIGokACAGQoCAgICAgICAgH+DIANCNIaEIAKEvzkDAAumAQEFfyAAKAJUIgMoAgAhBSADKAIEIgQgACgCFCAAKAIcIgdrIgYgBCAGSRsiBgRAIAUgByAGEFYgAyAD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AkEMaiECIANBAWoiAwRAIAIgASAD/AoAAAsgBCACNgIAIAALCgAgAC0AC0EHdgsVACAAEIIBBEAgACgCBA8LIAAQhwELGwEBf0EKIQEgABCCAQR/IAAQiAFBAWsFIAELCxIAIAAQggEEQCAAKAIADwsgAAuoAgEDfyMAQSBrIggkAEH3////ByIJIAFBf3NqIAJPBEAgABCFASEKIAFB8////wNJBEAgCCABQQF0NgIcIAggASACajYCECAIQRBqIAhBHGoQjAEoAgAQjQFBAWohCQsgCEEcaiAIQRhqIAAQjgEoAgAQjwEgCEEQaiAJEJABIAgoAhAhCSAIKAIUGiAEBEAgCSAKIAQQkQELIAYEQCAEIAlqIAcgBhCRAQsgAyAEIAVqIgdrIQIgAyAHRwRAIAQgCWogBmogBCAKaiAFaiACEJEBCyABQQpHBEAgChByCyAAIAkQkgEgACAIKAIUEJMBIAAgBCAGaiACaiIEEJQBIAhBADoADyAEIAlqIAhBD2oQiwEgCEEcahCaASAIQSBqJAAPCxCVAQALCwAgAC0AC0H/AHELDgAgACgCCEH/////B3ELGQACQCACRQ0AIAJFDQAgACABIAL8CgAACwsZACAAEIIBBEAgACABEJQBDwsgACABEJcBCwwAIAAgAS0AADoAAAsjAQJ/IwBBEGsiAiQAIAAgARB6IQMgAkEQaiQAIAEgACADGwskACAAQQtPBH8gAEEIakF4cSIAIABBAWsiACAAQQtGGwVBCgsLCwAgACABNgIAIAALCgAgACABEI4BGgsYAQF/IAEQfyECIAAgATYCBCAAIAI2AgALCwAgACABIAIQiQELCQAgACABNgIACxAAIAAgAUGAgICAeHI2AggLCQAgACABNgIECwUAEHkACwcAIABBC0kLDQAgACABQf8AcToACwuuAgEGfyABQff///8HTQRAIAAQhAEgAUkEQCABEI0BIQIjAEEQayIBJAAgAUEMaiABQQhqIAAQjgEoAgAQjwEgABCEASEEIAAQgwEhBgJAAn8gAhCWASIHBEBBASEEIAJBAWohBSAAIQIgACgCAAwBCyACQQFqIQMCQCACIARLBEAgASADEJABIAEoAgQhBSABKAIAIQIMAQsgASADEJABIAEoAgQhAyAAEIQBIQQgASgCBCEFIAEoAgAhAiADQQFrIARN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cmVmZXJlbmNlL3JuZW0vcGFja2FnZXMvY29yZS9jcHAvd2FzbS9BU1RTZXJpYWxpemVyLmNwcAAvZW1zZGsvZW1zY3JpcHRlbi9zeXN0ZW0vbGliL2xpYmN4eGFiaS9zcmMvcHJpdmF0ZV90eXBlaW5mby5jcHAAdGlwAHZpZGVvAFZpZGVvAGNhdXRpb24Ab3B0aW9uAHN0ZDo6ZXhjZXB0aW9uAGZpZ2NhcHRpb24AQWRtb25pdGlvbgBzZWN0aW9uAG1haW4AYWxpZ24AbmFuAFxuAGZvcm0AbWVudWl0ZW0ATGlzdEl0ZW0AcGFyYW0AdWwAdXJsAGNvbABodG1sAFRhYmxlSGVhZGVyQ2VsbABUYWJsZUNlbGwAbGV2ZWwAZGwAaXNUYXNrAGxpbmsATGluawBDb2RlQmxvY2sAdHJhY2sAU29mdEJyZWFrAExpbmVCcmVhawBUaGVtYXRpY0JyZWFrAGxpAGJhZF9hcnJheV9uZXdfbGVuZ3RoAFBhcmFncmFwaABTdHJpa2V0aHJvdWdoAHNlYXJjaABkaWFsb2cAU3Ryb25nAGJhc2ljX3N0cmluZwBub2RlVHlwZVRvU3RyaW5nAHdhcm5pbmcASGVhZGluZwBpbmYAXGYAdHJ1ZQBibG9ja3F1b3RlAEJsb2NrcXVvdGUAbm90ZQBmYWxzZQBiYXNlAGZpZ3VyZQBhZG1vbml0aW9uVHlwZQBVbmRlcmxpbmUATGF0ZXhNYXRoSW5saW5lAEJsYW5rTGluZQBpZnJhbWUAdGl0bGUAYXJ0aWNsZQB0YWJsZQBUYWJsZQBJbWFnZQBDb2RlAGFzaWRlAHRkAGxlZ2VuZAB0YXNrQ2hlY2tlZABkZAB0aGVhZABUYWJsZUhlYWQAc3JjAHN0ZDo6YmFkX2FsbG9jAFxiACInPTw+YAAtXwBcXAAoe1sALCJjaGlsZHJlbiI6WwBOQU4ASU5GAGNhdGNoaW5nIGEgY2xhc3Mgd2l0aG91dCBhbiBvYmplY3Q/AF1dPgAtLT4AOipfficiPABtYWlsdG86ACl9XS4hPyw7OgAsImNvbnRlbnQiOgBoNgBoNQBoNABoMwBoMgBoMQBodHRwOi8vAFRvbyBtYW55IGxpbmsgcmVmZXJlbmNlIGRlZmluaXRpb24gaW5zdGFudGlhdGlvbnMuAFVuc3Vw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AHcBAAD/AAAAegEAAH4BAABzAAAAUwIAAIMBAACFAQAAVAIAAIgBAABWAgAAVwIAAIwBAADdAQAAWQIAAFsCAACSAQAAYAIAAGMCAABpAgAAaAIAAJkBAABvAgAAcgIAAHUCAAChAQAApQEAAIACAACoAQAAgwIAAK0BAACIAgAAsAEAAIoCAACLAgAAtAEAALYBAACSAgAAuQEAAL0BAADGAQAAxgEAAMkBAADJAQAAzAEAAMwBAADcAQAA3wEAAO8BAADzAQAA8wEAAPUBAACVAQAAvwEAAPkBAAAfAgAAngEAACMCAAAzAgAAZSwAADwCAACaAQAAZiwAAEICAACAAQAAiQIAAIwCAABHAgAATwIAALkDAABxAwAAcwMAAHcDAADzAwAArAMAAK0DAACvAwAAzAMAAM0DAADOAwAAsQMAAMEDAADDAwAAywMAAMMDAADXAwAAsgMAALgDAADGAwAAwAMAANkDAADvAwAAugMAAMEDAAC4AwAAtQMAAPgDAADyAwAA+wMAAHsDAAB9AwAAUAQAAF8EAAAwBAAATwQAAGEEAACBBAAAiwQAAL8EAADPBAAAwgQAAM4EAADRBAAALwUAAGEFAACGBQAAAC0AACUtAAAnLQAALS0AAPATAAD1EwAAMgQAADQEAAA+BAAAQQQAAEIEAABCBAAASgQAAGMEAABLpgAAihwAANAQAAD6EAAA/RAAAP8QAAABHgAAlR4AAGEeAAChHgAA/x4AAAAfAAAHHwAAEB8AABUfAAAgHwAAJx8AADAfAAA3HwAAQB8AAEUfAABRHwAAUx8AAFUfAABXHwAAYB8AAGcfAACwHwAAsR8AAHAfAABxHwAAuQMAAHIfAAB1HwAA0B8AANEfAAB2HwAAdx8AAOAfAADhHwAAeh8AAHsfAADlHwAAeB8AAHkfAAB8HwAAfR8AAMkDAABrAAAA5QAAAE4hAABwIQAAfyEAAIQhAADQJAAA6SQAADAsAABfLAAAYSwAAGsCAAB9HQAAfQIAAGgsAABsLAAAUQIAAHECAABQAgAAUgIAAHMsAAB2LAAAPwIAAEACAACBLAAA4ywAAOwsAADuLAAA8ywAAEGmAABtpgAAgaYAAJumAAAjpwAAL6cAADOnAABvpwAAeqcAAHynAAB5HQAAf6cAAIenAACMpwAAZQIAAJGnAACTpwAAl6cA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\")}function getBinarySync(file){if(ArrayBuffer.isView(file)){return file}if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw\"both async and sync fetching of the wasm failed\"}async function getWasmBinary(binaryFile){return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){return{env:wasmImports,wasi_snapshot_preview1:wasmImports}}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports[\"memory\"];updateMemoryViews();assignWasmExports(wasmExports);removeRunDependency(\"wasm-instantiate\");return wasmExports}addRunDependency(\"wasm-instantiate\");function receiveInstantiationResult(result){return receiveInstance(result[\"instance\"])}var info=getWasmImports();if(Module[\"instantiateWasm\"]){return new Promise((resolve,reject)=>{Module[\"instantiateWasm\"](info,(mod,inst)=>{resolve(receiveInstance(mod,inst))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name=\"ExitStatus\";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var base64Decode=b64=>{var b1,b2,i=0,j=0,bLength=b64.length;var output=new Uint8Array((bLength*3>>2)-(b64[bLength-2]==\"=\")-(b64[bLength-1]==\"=\"));for(;i>4;output[j+1]=b1<<4|b2>>2;output[j+2]=b2<<6|base64ReverseLookup[b64.charCodeAt(i+3)]}return output};var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var UTF8Decoder=typeof TextDecoder!=\"undefined\"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str=\"\";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):\"\";var ___assert_fail=(condition,filename,line,func)=>abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):\"unknown filename\",line,func?UTF8ToString(func):\"unknown function\"]);class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var exceptionLast=0;var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>abort(\"\");var getHeapMax=()=>536870912;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module[\"_\"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType===\"string\"){return UTF8ToString(ret)}if(returnType===\"boolean\")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type===\"number\"||type===\"boolean\");var numericRet=returnType!==\"string\";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};for(var base64ReverseLookup=new Uint8Array(123),i=25;i>=0;--i){base64ReverseLookup[48+i]=52+i;base64ReverseLookup[65+i]=i;base64ReverseLookup[97+i]=26+i}base64ReverseLookup[43]=62;base64ReverseLookup[47]=63;{if(Module[\"noExitRuntime\"])noExitRuntime=Module[\"noExitRuntime\"];if(Module[\"print\"])out=Module[\"print\"];if(Module[\"printErr\"])err=Module[\"printErr\"];if(Module[\"wasmBinary\"])wasmBinary=Module[\"wasmBinary\"];if(Module[\"arguments\"])arguments_=Module[\"arguments\"];if(Module[\"thisProgram\"])thisProgram=Module[\"thisProgram\"]}Module[\"ccall\"]=ccall;Module[\"cwrap\"]=cwrap;Module[\"UTF8ToString\"]=UTF8ToString;var _parseMarkdown,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current;function assignWasmExports(wasmExports){Module[\"_parseMarkdown\"]=_parseMarkdown=wasmExports[\"parseMarkdown\"];__emscripten_stack_restore=wasmExports[\"_emscripten_stack_restore\"];__emscripten_stack_alloc=wasmExports[\"_emscripten_stack_alloc\"];_emscripten_stack_get_current=wasmExports[\"emscripten_stack_get_current\"]}var wasmImports={__assert_fail:___assert_fail,__cxa_throw:___cxa_throw,_abort_js:__abort_js,emscripten_resize_heap:_emscripten_resize_heap};var wasmExports=await createWasm();function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module[\"calledRun\"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module[\"onRuntimeInitialized\"]?.();postRun()}if(Module[\"setStatus\"]){Module[\"setStatus\"](\"Running...\");setTimeout(()=>{setTimeout(()=>Module[\"setStatus\"](\"\"),1);doRun()},1)}else{doRun()}}function preInit(){if(Module[\"preInit\"]){if(typeof Module[\"preInit\"]==\"function\")Module[\"preInit\"]=[Module[\"preInit\"]];while(Module[\"preInit\"].length>0){Module[\"preInit\"].shift()()}}}preInit();run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})}\n\n\n return moduleRtn;\n}\n);\n})();\nif (typeof exports === 'object' && typeof module === 'object') {\n module.exports = createMd4cModule;\n // This default export looks redundant, but it allows TS to import this\n // commonjs style module.\n module.exports.default = createMd4cModule;\n} else if (typeof define === 'function' && define['amd'])\n define([], () => createMd4cModule);\n"],"mappings":"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","ignoreList":[]} \ No newline at end of file diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts -index 1ee4eda..b415d18 100644 --- a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts -@@ -235,6 +235,7 @@ export interface MarkdownStyleInternal { +@@ -114,6 +114,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -235,6 +236,7 @@ } export interface LinkPressEvent { url: string; @@ -1939,10 +1922,17 @@ index 8e380e9..5f342bd 100644 +{"version":3,"file":"EnrichedMarkdownNativeComponent.d.ts","sourceRoot":"","sources":["../../../src/EnrichedMarkdownNativeComponent.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,cAAc,CAAC;AAGtB,UAAU,sBAAsB;IAC9B,QAAQ,EAAE,YAAY,CAAC,KAAK,CAAC;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,EAAE,YAAY,CAAC,KAAK,CAAC;IAC9B,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;IACjC,UAAU,EAAE,YAAY,CAAC,KAAK,CAAC;CAChC;AAED,UAAU,sBAAuB,SAAQ,sBAAsB;IAC7D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,oBAAqB,SAAQ,sBAAsB;IAC3D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,wBAAwB;IAChC,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,UAAU,CAAC;CAC7B;AAED,UAAU,wBAAwB;IAChC,IAAI,EAAE,wBAAwB,CAAC;IAC/B,GAAG,EAAE,wBAAwB,CAAC;IAC9B,SAAS,EAAE,wBAAwB,CAAC;IACpC,OAAO,EAAE,wBAAwB,CAAC;IAClC,OAAO,EAAE,wBAAwB,CAAC;CACnC;AAED,UAAU,uBAAwB,SAAQ,sBAAsB;IAC9D,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,YAAY,CAAC,KAAK,CAAC;IAChC,QAAQ,EAAE,YAAY,CAAC,KAAK,CAAC;IAC7B,eAAe,EAAE,UAAU,CAAC;IAC5B,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;IACjC,OAAO,EAAE,YAAY,CAAC,KAAK,CAAC;IAC5B,WAAW,EAAE,wBAAwB,CAAC;CACvC;AAED,UAAU,iBAAkB,SAAQ,sBAAsB;IACxD,WAAW,EAAE,UAAU,CAAC;IACxB,UAAU,EAAE,YAAY,CAAC,KAAK,CAAC;IAC/B,cAAc,EAAE,YAAY,CAAC,KAAK,CAAC;IACnC,WAAW,EAAE,UAAU,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;IACzB,QAAQ,EAAE,YAAY,CAAC,KAAK,CAAC;IAC7B,UAAU,EAAE,YAAY,CAAC,KAAK,CAAC;IAC/B,WAAW,EAAE,YAAY,CAAC,KAAK,CAAC;CACjC;AAED,UAAU,6BAA6B;IACrC,OAAO,EAAE,UAAU,CAAC;IACpB,aAAa,EAAE,UAAU,CAAC;IAC1B,WAAW,EAAE,UAAU,CAAC;IACxB,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,UAAU,CAAC;IACrB,OAAO,EAAE,UAAU,CAAC;IACpB,QAAQ,EAAE,UAAU,CAAC;IACrB,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,UAAU,CAAC;IACrB,QAAQ,EAAE,UAAU,CAAC;IACrB,GAAG,EAAE,UAAU,CAAC;IAChB,SAAS,EAAE,UAAU,CAAC;IACtB,QAAQ,EAAE,UAAU,CAAC;CACtB;AAED,UAAU,sBAAuB,SAAQ,sBAAsB;IAC7D,eAAe,EAAE,UAAU,CAAC;IAC5B,WAAW,EAAE,UAAU,CAAC;IACxB,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;IACjC,WAAW,EAAE,YAAY,CAAC,KAAK,CAAC;IAChC,OAAO,EAAE,YAAY,CAAC,KAAK,CAAC;IAC5B,YAAY,EAAE,6BAA6B,CAAC;CAC7C;AAED,UAAU,iBAAiB;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;IACnB,eAAe,EAAE,UAAU,CAAC;CAC7B;AAED,UAAU,gBAAgB;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,UAAU,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;IACjC,iBAAiB,EAAE,YAAY,CAAC,KAAK,CAAC;IACtC,eAAe,EAAE,YAAY,CAAC,KAAK,CAAC;IACpC,UAAU,EAAE,YAAY,CAAC,KAAK,CAAC;IAC/B,WAAW,EAAE,YAAY,CAAC,KAAK,CAAC;IAChC,WAAW,EAAE,UAAU,CAAC;IACxB,QAAQ,EAAE,YAAY,CAAC,KAAK,CAAC;CAC9B;AAGD,UAAU,wBAAwB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;IACnB,eAAe,EAAE,UAAU,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAED,UAAU,mBAAmB;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAED,UAAU,qBAAqB;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAED,UAAU,0BAA0B;IAClC,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,UAAU,sBAAsB;IAC9B,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,UAAU,iBAAiB;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC,KAAK,CAAC;IAC7B,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,UAAU,CAAC;IAC5B,WAAW,EAAE,UAAU,CAAC;CACzB;AAED,UAAU,kBAAkB;IAC1B,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC;IAC3B,SAAS,EAAE,YAAY,CAAC,KAAK,CAAC;IAC9B,WAAW,EAAE,YAAY,CAAC,KAAK,CAAC;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;IACjC,SAAS,EAAE,YAAY,CAAC,KAAK,CAAC;IAC9B,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;CAClC;AAED,UAAU,kBAAkB;IAC1B,SAAS,EAAE,YAAY,CAAC,KAAK,CAAC;IAC9B,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;IACjC,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;IACjC,WAAW,EAAE,YAAY,CAAC,KAAK,CAAC;IAChC,eAAe,EAAE,UAAU,CAAC;CAC7B;AAED,UAAU,wBAAwB;IAChC,IAAI,EAAE,YAAY,CAAC,KAAK,CAAC;CAC1B;AAED,UAAU,0BAA0B;IAClC,KAAK,EAAE,UAAU,CAAC;IAClB,MAAM,EAAE,YAAY,CAAC,KAAK,CAAC;IAC3B,SAAS,EAAE,YAAY,CAAC,KAAK,CAAC;IAC9B,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;CAClC;AAED,UAAU,kBAAmB,SAAQ,sBAAsB;IACzD,gBAAgB,EAAE,MAAM,CAAC;IACzB,qBAAqB,EAAE,UAAU,CAAC;IAClC,eAAe,EAAE,UAAU,CAAC;IAC5B,sBAAsB,EAAE,UAAU,CAAC;IACnC,qBAAqB,EAAE,UAAU,CAAC;IAClC,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,YAAY,CAAC,KAAK,CAAC;IAChC,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;IACjC,qBAAqB,EAAE,YAAY,CAAC,KAAK,CAAC;IAC1C,mBAAmB,EAAE,YAAY,CAAC,KAAK,CAAC;IACxC,kBAAkB,EAAE,YAAY,CAAC,KAAK,CAAC;IACvC,KAAK,EAAE,MAAM,CAAC;CACf;AAED,UAAU,qBAAqB;IAC7B,YAAY,EAAE,UAAU,CAAC;IACzB,WAAW,EAAE,UAAU,CAAC;IACxB,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;IACjC,oBAAoB,EAAE,YAAY,CAAC,KAAK,CAAC;IACzC,cAAc,EAAE,UAAU,CAAC;IAC3B,gBAAgB,EAAE,UAAU,CAAC;IAC7B,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAED,UAAU,iBAAiB;IACzB,QAAQ,EAAE,YAAY,CAAC,KAAK,CAAC;IAC7B,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,UAAU,CAAC;IAC5B,OAAO,EAAE,YAAY,CAAC,KAAK,CAAC;IAC5B,SAAS,EAAE,YAAY,CAAC,KAAK,CAAC;IAC9B,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,UAAU,uBAAuB;IAC/B,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,UAAU,6BAA6B;IACrC,OAAO,EAAE,YAAY,CAAC,KAAK,CAAC;IAC5B,KAAK,EAAE,YAAY,CAAC,KAAK,CAAC;CAC3B;AAED,UAAU,yBAAyB;IACjC,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;CAClC;AAED,UAAU,oBAAoB;IAC5B,KAAK,EAAE,UAAU,CAAC;IAClB,SAAS,EAAE,6BAA6B,CAAC;IACzC,KAAK,EAAE,yBAAyB,CAAC;CAClC;AAED,UAAU,wBAAwB;IAChC,SAAS,EAAE,YAAY,CAAC,KAAK,CAAC;IAC9B,mBAAmB,EAAE,YAAY,CAAC,KAAK,CAAC;CACzC;AAED,UAAU,sBAAsB;IAC9B,SAAS,EAAE,YAAY,CAAC,KAAK,CAAC;IAC9B,mBAAmB,EAAE,YAAY,CAAC,KAAK,CAAC;CACzC;AAED,UAAU,sBAAsB;IAC9B,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,UAAU,CAAC;CAC7B;AAED,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,sBAAsB,CAAC;IAClC,EAAE,EAAE,oBAAoB,CAAC;IACzB,EAAE,EAAE,oBAAoB,CAAC;IACzB,EAAE,EAAE,oBAAoB,CAAC;IACzB,EAAE,EAAE,oBAAoB,CAAC;IACzB,EAAE,EAAE,oBAAoB,CAAC;IACzB,EAAE,EAAE,oBAAoB,CAAC;IACzB,UAAU,EAAE,uBAAuB,CAAC;IACpC,IAAI,EAAE,iBAAiB,CAAC;IACxB,SAAS,EAAE,sBAAsB,CAAC;IAClC,IAAI,EAAE,iBAAiB,CAAC;IACxB,YAAY,EAAE,aAAa,CAAC,QAAQ,CAAC,wBAAwB,CAAC,CAAC,CAAC;IAChE,MAAM,EAAE,mBAAmB,CAAC;IAC5B,EAAE,EAAE,qBAAqB,CAAC;IAC1B,aAAa,EAAE,0BAA0B,CAAC;IAC1C,SAAS,EAAE,sBAAsB,CAAC;IAClC,IAAI,EAAE,iBAAiB,CAAC;IACxB,KAAK,EAAE,kBAAkB,CAAC;IAC1B,KAAK,EAAE,kBAAkB,CAAC;IAC1B,WAAW,EAAE,wBAAwB,CAAC;IACtC,aAAa,EAAE,0BAA0B,CAAC;IAC1C,KAAK,EAAE,kBAAkB,CAAC;IAC1B,QAAQ,EAAE,qBAAqB,CAAC;IAChC,IAAI,EAAE,iBAAiB,CAAC;IACxB,UAAU,EAAE,uBAAuB,CAAC;IACpC,OAAO,EAAE,oBAAoB,CAAC;IAC9B,WAAW,EAAE,wBAAwB,CAAC;IACtC,SAAS,EAAE,sBAAsB,CAAC;IAClC,SAAS,EAAE,sBAAsB,CAAC;CACnC;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,YAAY,CAAC,KAAK,CAAC;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,uBAAuB;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,UAAU,CAAC;CAC5B;AAED,MAAM,WAAW,mBAAmB;IAClC,cAAc,EAAE,OAAO,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,kBAAkB,EAAE,MAAM,CAAC;IAG3B,2BAA2B,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CACpD;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,YAAY,CAAC,KAAK,CAAC;IACnC,YAAY,EAAE,YAAY,CAAC,KAAK,CAAC;CAClC;AAED,MAAM,WAAW,4BAA4B;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AACD,MAAM,WAAW,kCAAkC;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AACD,MAAM,WAAW,6BAA6B;IAC5C,GAAG,EAAE,MAAM,CAAC;CACb;AACD,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,EAAE,MAAM,CAAC;CAClB;AACD,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,QAAQ,CAAC,4BAA4B,CAAC,CAAC;IAC7C,UAAU,EAAE,QAAQ,CAAC,kCAAkC,CAAC,CAAC;IACzD,KAAK,EAAE,QAAQ,CAAC,6BAA6B,CAAC,CAAC;IAC/C,IAAI,EAAE,QAAQ,CAAC,4BAA4B,CAAC,CAAC;IAC7C,KAAK,EAAE,QAAQ,CAAC,6BAA6B,CAAC,CAAC;CAChD;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,cAAc,EAAE,OAAO,CAAC;IACxB;;;OAGG;IACH,kBAAkB,EAAE,OAAO,CAAC;IAC5B;;;;OAIG;IACH,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,UAAU,uBAAuB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,UAAU,yBAAyB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,UAAU,0BAA0B;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,aAAa,CAAC,QAAQ,CAAC,yBAAyB,CAAC,CAAC,CAAC;CAC3D;AAED,UAAU,+BAA+B;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,mBAAmB;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,OAAO,CAAC;IACzB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,WAAY,SAAQ,SAAS;IAC5C;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,EAAE,qBAAqB,CAAC;IACrC;;;OAGG;IACH,WAAW,CAAC,EAAE,YAAY,CAAC,oBAAoB,CAAC,cAAc,CAAC,CAAC;IAChE;;;;;OAKG;IACH,eAAe,CAAC,EAAE,YAAY,CAAC,oBAAoB,CAAC,kBAAkB,CAAC,CAAC;IACxE;;;;;OAKG;IACH,YAAY,CAAC,EAAE,YAAY,CAAC,oBAAoB,CAAC,eAAe,CAAC,CAAC;IAClE;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IAC5D;;;OAGG;IACH,mBAAmB,CAAC,EAAE,YAAY,CAAC,oBAAoB,CAAC,sBAAsB,CAAC,CAAC;IAChF;;;;;;;OAOG;IACH,wBAAwB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IACnE;;;;OAIG;IACH,WAAW,CAAC,EAAE,YAAY,CAAC,oBAAoB,CAAC,cAAc,CAAC,CAAC;IAChE,gFAAgF;IAChF,gBAAgB,CAAC,EAAE,YAAY,CAAC,oBAAoB,CAAC,mBAAmB,CAAC,CAAC;IAC1E;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IAChE;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,YAAY,CAAC,oBAAoB,CAAC,eAAe,CAAC,CAAC;IAClE;;;OAGG;IACH,sBAAsB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IACjE;;;;;;;;;;;OAWG;IACH,iBAAiB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IAC5D;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,UAAU,CAAC;IAC5B;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,UAAU,CAAC;IAClC;;;OAGG;IACH,SAAS,EAAE,iBAAiB,CAAC;IAC7B,sEAAsE;IACtE,KAAK,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IAChD;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IAC3D;;;;;;;OAOG;IACH,qBAAqB,CAAC,EAAE,YAAY,CAAC,KAAK,CAAC;IAC3C;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IAC/D;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IAC9D;;OAEG;IACH,eAAe,CAAC,EAAE,uBAAuB,CAAC;IAC1C;;;;;OAKG;IACH,cAAc,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAE/D;;OAEG;IACH,gBAAgB,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,qBAAqB,CAAC,CAAC,CAAC;IAClE;;OAEG;IACH,mBAAmB,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,0BAA0B,CAAC,CAAC,CAAC;IAC1E,yEAAyE;IACzE,eAAe,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,uBAAuB,CAAC,CAAC,CAAC;IACnE,qFAAqF;IACrF,eAAe,CAAC,EAAE,QAAQ,CAAC;QACzB,IAAI,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAAC;QACpC,UAAU,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAAC;KAC3C,CAAC,CAAC;IACH,qFAAqF;IACrF,oBAAoB,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,0BAA0B,CAAC,CAAC,CAAC;IAC3E,0BAA0B,CAAC,EAAE,YAAY,CAAC,oBAAoB,CAAC,+BAA+B,CAAC,CAAC;IAChG;;OAEG;IACH,mBAAmB,EAAE,QAAQ,CAAC,mBAAmB,CAAC,CAAC;IACnD,mBAAmB,EAAE,QAAQ,CAAC,wBAAwB,CAAC,CAAC;IACxD;;;OAGG;IACH,sBAAsB,CAAC,EAAE,YAAY,CAAC,oBAAoB,CAAC,2BAA2B,CAAC,CAAC;IACxF;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IACpE;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChE;;;;;;;;;OASG;IACH,gBAAgB,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IACpE;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAChE;;;OAGG;IACH,aAAa,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1D;;AAED,wBAEG"} \ No newline at end of file diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts -index bcde9a7..247d757 100644 --- a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts -@@ -235,6 +235,7 @@ export interface MarkdownStyleInternal { +@@ -114,6 +114,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -235,6 +236,7 @@ } export interface LinkPressEvent { url: string; @@ -2177,10 +2167,17 @@ index a32ca4e..279b944 100644 +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/web/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts -index 32ff768..8e89249 100644 --- a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts -@@ -275,6 +275,7 @@ export interface MarkdownStyleInternal { +@@ -138,6 +138,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -275,6 +276,7 @@ export interface LinkPressEvent { url: string; @@ -2189,10 +2186,17 @@ index 32ff768..8e89249 100644 export interface LinkLongPressEvent { diff --git a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts -index 6f31ccc..7b08b62 100644 --- a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts +++ b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts -@@ -276,6 +276,7 @@ export interface MarkdownStyleInternal { +@@ -138,6 +138,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -276,6 +277,7 @@ export interface LinkPressEvent { url: string; @@ -3253,3 +3257,630 @@ new file mode 100644 + } + } +} +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +@@ -23,8 +23,7 @@ + } + + private fun applyStrongStyle(tp: TextPaint) { +- tp.textSize = blockStyle.fontSize +- ++ // Weight composes with the current run; its size belongs to the block or code span. + val currentTypeface = tp.typeface ?: Typeface.DEFAULT + val isItalic = (currentTypeface.style) and Typeface.ITALIC != 0 + val style = if (isItalic) Typeface.BOLD_ITALIC else Typeface.BOLD +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +@@ -12,14 +12,14 @@ + + class LinkSpan( + val url: String, +- private val onLinkPress: ((String) -> Unit)?, ++ private val onLinkPress: ((String, String) -> Unit)?, + private val onLinkLongPress: ((String) -> Unit)?, + private val styleCache: SpanStyleCache, + private val blockStyle: BlockStyle, + private val context: Context, + val recognizedLink: Boolean = false, +- // A recognized code span keeps its code font unless a link or variant font is set. +- private val keepsCodeFont: Boolean = false, ++ // Syntax context for recognized inline-code links is independent of typography. ++ private val recognizedInlineCodeLink: Boolean = false, + ) : ClickableSpan() { + @Volatile + private var longPressTriggered = false +@@ -30,7 +30,8 @@ + return + } + +- onLinkPress?.invoke(url) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url) ++ val sourceType = if (recognizedInlineCodeLink) "inline-code" else if (recognizedLink) "text" else "markdown" ++ onLinkPress?.invoke(url, sourceType) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url, sourceType) + } + + fun onLongClick(widget: View): Boolean { +@@ -50,11 +51,7 @@ + + val fontFamily = variant?.fontFamily?.takeIf { it.isNotEmpty() } ?: styleCache.linkFontFamily + if (fontFamily.isNotEmpty()) { +- textPaint.textSize = blockStyle.fontSize + textPaint.applyBlockStyleFont(blockStyle.copy(fontFamily = fontFamily), context) +- } else if (!keepsCodeFont) { +- textPaint.textSize = blockStyle.fontSize +- textPaint.applyBlockStyleFont(blockStyle, context) + } + + textPaint.color = variant?.color ?: styleCache.linkColor +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +@@ -19,7 +19,7 @@ + override fun render( + node: MarkdownASTNode, + builder: SpannableStringBuilder, +- onLinkPress: ((String) -> Unit)?, ++ onLinkPress: ((String, String) -> Unit)?, + onLinkLongPress: ((String) -> Unit)?, + factory: RendererFactory, + ) { +@@ -56,7 +56,7 @@ + blockStyle, + factory.context, + recognizedLink = recognizedLink, +- keepsCodeFont = recognizedLink && node.children.any { it.type == MarkdownASTNode.NodeType.Code }, ++ recognizedInlineCodeLink = recognizedLink && node.children.any { it.type == MarkdownASTNode.NodeType.Code }, + ), + start, + end, +diff --git a/node_modules/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/InlineCodeFormattingTest.kt b/node_modules/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/InlineCodeFormattingTest.kt +new file mode 100644 +--- /dev/null ++++ b/node_modules/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/InlineCodeFormattingTest.kt +@@ -0,0 +1,89 @@ ++package com.swmansion.enriched.markdown ++ ++import android.text.TextPaint ++import com.facebook.react.bridge.JavaOnlyMap ++import com.swmansion.enriched.markdown.renderer.BlockStyle ++import com.swmansion.enriched.markdown.renderer.SpanStyleCache ++import com.swmansion.enriched.markdown.spans.CodeSpan ++import com.swmansion.enriched.markdown.spans.StrongSpan ++import com.swmansion.enriched.markdown.spans.EmphasisSpan ++import com.swmansion.enriched.markdown.styles.StyleConfig ++import org.junit.Assert.* ++import org.junit.Test ++import org.junit.runner.RunWith ++import org.robolectric.RobolectricTestRunner ++import org.robolectric.RuntimeEnvironment ++import org.robolectric.annotation.Config ++ ++@RunWith(RobolectricTestRunner::class) ++@org.robolectric.annotation.GraphicsMode(org.robolectric.annotation.GraphicsMode.Mode.NATIVE) ++@Config(manifest = Config.NONE, sdk = [34]) ++class InlineCodeFormattingTest { ++ private fun cache(): SpanStyleCache { ++ com.facebook.react.uimanager.DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) ++ val common = JavaOnlyMap.of( ++ "fontFamily", "", "fontWeight", "bold", "fontStyle", "italic", ++ "fontSize", 12.0, "color", -16777216.0, "backgroundColor", 0.0, ++ "borderColor", 0.0, "underline", false, ++ "marginTop", 0.0, "marginBottom", 0.0, "lineHeight", 20.0, "textAlign", "left", ++ "checkedColor", 0.0, "checkmarkColor", 0.0, "checkedTextColor", 0.0, ++ "checkboxSize", 18.0, "checkboxBorderRadius", 2.0, "checkedStrikethrough", false, ++ "particles", JavaOnlyMap.of("density", 1.0, "speed", 1.0), ++ "solid", JavaOnlyMap.of("borderRadius", 0.0), ++ "fontScale", 0.7, "baselineOffsetScale", 0.3, ++ ) ++ val styles = JavaOnlyMap() ++ listOf("paragraph", "strong", "em", "strikethrough", "link", "code", "taskList", ++ "spoiler", "superscript", "subscript", "highlight").forEach { styles.putMap(it, common) } ++ styles.putMap("code", JavaOnlyMap.of("fontFamily", "", "fontSize", 0.0, "fontScale", 0.75, "color", -16777216.0, "backgroundColor", 0.0, "borderColor", 0.0)) ++ return SpanStyleCache(StyleConfig(styles, RuntimeEnvironment.getApplication(), false, 1f)) ++ } ++ ++ @Test fun boldPreservesInlineCodeSizeInMeasurementAndDrawing() { ++ val cache = cache() ++ for (blockSize in listOf(16f, 28f)) { ++ val block = BlockStyle(blockSize, "", "normal", -16777216) ++ for (draw in listOf(false, true)) { ++ for (boldFirst in listOf(false, true)) { ++ val paint = TextPaint().apply { textSize = blockSize } ++ val code = CodeSpan(cache, block) ++ val bold = StrongSpan(cache, block) ++ val spans = if (boldFirst) listOf(bold, code) else listOf(code, bold) ++ spans.forEach { if (draw) it.updateDrawState(paint) else it.updateMeasureState(paint) } ++ assertEquals("Inline code size must survive bold in either span order", blockSize * 0.75f, paint.textSize, 0f) ++ assertTrue(paint.typeface.isBold) ++ } ++ } ++ } ++ } ++ ++ @Test fun boldPreservesExistingSizeAndItalic() { ++ val cache = cache() ++ val block = BlockStyle(28f, "", "normal", -16777216) ++ val paint = TextPaint().apply { textSize = 16f } ++ EmphasisSpan(cache, block).updateMeasureState(paint) ++ StrongSpan(cache, block).updateMeasureState(paint) ++ assertEquals(16f, paint.textSize, 0f) ++ assertTrue(paint.typeface.isBold) ++ assertTrue(paint.typeface.isItalic) ++ } ++ ++ @Test fun linksPreserveCodeAndEmphasizedProseFonts() { ++ val cache = cache() ++ val block = BlockStyle(28f, "", "normal", -16777216) ++ for (code in listOf(false, true)) { ++ val paint = TextPaint().apply { textSize = 16f } ++ if (code) CodeSpan(cache, block).updateDrawState(paint) ++ EmphasisSpan(cache, block).updateDrawState(paint) ++ StrongSpan(cache, block).updateDrawState(paint) ++ val size = paint.textSize ++ val font = paint.typeface ++ com.swmansion.enriched.markdown.spans.LinkSpan( ++ "https://example.com", null, null, cache, block, RuntimeEnvironment.getApplication(), ++ ).updateDrawState(paint) ++ assertEquals(size, paint.textSize, 0f) ++ assertEquals(font, paint.typeface) ++ } ++ } ++ ++} +diff --git a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts +--- a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts ++++ b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts +@@ -197,6 +197,8 @@ + interface CodeStyle { + fontFamily?: string; + fontSize?: number; ++ /** Scales the inherited block size when fontSize is unset. */ ++ fontScale?: number; + color?: string; + backgroundColor?: string; + borderColor?: string; +diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts +--- a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts ++++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts +@@ -179,6 +179,8 @@ + interface CodeStyle { + fontFamily?: string; + fontSize?: number; ++ /** Scales the inherited block size when fontSize is unset. */ ++ fontScale?: number; + color?: string; + backgroundColor?: string; + borderColor?: string; +diff --git a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +--- a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts ++++ b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +@@ -150,6 +150,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: number; ++ fontScale: number; + color: string; + backgroundColor: string; + borderColor: string; +diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts +--- a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts ++++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts +@@ -117,6 +117,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: number; ++ fontScale: number; + color: string; + backgroundColor: string; + borderColor: string; +diff --git a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +--- a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts ++++ b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +@@ -203,6 +203,7 @@ + // code doesn't fall back to the browser's default proportional font. + fontFamily: Platform.select({ web: getMonospaceFont(), default: '' })!, + fontSize: 0, ++ fontScale: 1, + color: normalizeColor('#E01E5A')!, + backgroundColor: normalizeColor('#FDF2F4')!, + borderColor: normalizeColor('#F8D7DA')!, +diff --git a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts +--- a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts ++++ b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts +@@ -165,6 +165,7 @@ + code: { + fontFamily: MONOSPACE_FONT, + fontSize: 0, ++ fontScale: 1, + color: '#E01E5A', + backgroundColor: '#FDF2F4', + borderColor: '#F8D7DA', +diff --git a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js +--- a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js ++++ b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js +@@ -226,6 +226,7 @@ + default: '' + }), + fontSize: 0, ++ fontScale: 1, + color: normalizeColor('#E01E5A'), + backgroundColor: normalizeColor('#FDF2F4'), + borderColor: normalizeColor('#F8D7DA') +diff --git a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js +--- a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js ++++ b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js +@@ -150,6 +150,7 @@ + code: { + fontFamily: MONOSPACE_FONT, + fontSize: 0, ++ fontScale: 1, + color: '#E01E5A', + backgroundColor: '#FDF2F4', + borderColor: '#F8D7DA' +diff --git a/node_modules/react-native-enriched-markdown/src/web/styles.ts b/node_modules/react-native-enriched-markdown/src/web/styles.ts +--- a/node_modules/react-native-enriched-markdown/src/web/styles.ts ++++ b/node_modules/react-native-enriched-markdown/src/web/styles.ts +@@ -290,7 +290,7 @@ + const code = style.code; + return { + fontFamily: normalizeFontFamily(code.fontFamily), +- fontSize: code.fontSize || undefined, ++ fontSize: code.fontSize || `${code.fontScale}em`, + color: code.color, + backgroundColor: code.backgroundColor, + border: `1px solid ${code.borderColor}`, +diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/styles.js b/node_modules/react-native-enriched-markdown/lib/module/web/styles.js +--- a/node_modules/react-native-enriched-markdown/lib/module/web/styles.js ++++ b/node_modules/react-native-enriched-markdown/lib/module/web/styles.js +@@ -271,7 +271,7 @@ + const code = style.code; + return { + fontFamily: normalizeFontFamily(code.fontFamily), +- fontSize: code.fontSize || undefined, ++ fontSize: code.fontSize || `${code.fontScale}em`, + color: code.color, + backgroundColor: code.backgroundColor, + border: `1px solid ${code.borderColor}`, +diff --git a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h +--- a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h ++++ b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h +@@ -226,6 +226,8 @@ + // Code properties + - (NSString *)codeFontFamily; + - (void)setCodeFontFamily:(NSString *)newValue; ++- (CGFloat)codeFontScale; ++- (void)setCodeFontScale:(CGFloat)newValue; + - (CGFloat)codeFontSize; + - (void)setCodeFontSize:(CGFloat)newValue; + - (RCTUIColor *)codeColor; +diff --git a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm +--- a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm ++++ b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm +@@ -139,6 +139,7 @@ + // Code properties + NSString *_codeFontFamily; + CGFloat _codeFontSize; ++ CGFloat _codeFontScale; + RCTUIColor *_codeColor; + RCTUIColor *_codeBackgroundColor; + RCTUIColor *_codeBorderColor; +@@ -273,6 +274,7 @@ + { + self = [super init]; + _allowFontScaling = YES; ++ _codeFontScale = 1; + _maxFontSizeMultiplier = 0; + _paragraphTextAlign = NSTextAlignmentNatural; + _h1TextAlign = NSTextAlignmentNatural; +@@ -442,6 +444,7 @@ + copy->_underlineColor = [_underlineColor copy]; + copy->_codeFontFamily = [_codeFontFamily copy]; + copy->_codeFontSize = _codeFontSize; ++ copy->_codeFontScale = _codeFontScale; + copy->_codeColor = [_codeColor copy]; + copy->_codeBackgroundColor = [_codeBackgroundColor copy]; + copy->_codeBorderColor = [_codeBorderColor copy]; +@@ -1513,6 +1516,16 @@ + _codeFontFamily = newValue; + } + ++- (CGFloat)codeFontScale ++{ ++ return _codeFontScale; ++} ++ ++- (void)setCodeFontScale:(CGFloat)newValue ++{ ++ _codeFontScale = newValue; ++} ++ + - (CGFloat)codeFontSize + { + return _codeFontSize; +diff --git a/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h b/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h +--- a/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h ++++ b/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h +@@ -713,6 +713,11 @@ + changed = YES; + } + ++ if (newStyle.code.fontScale != oldStyle.code.fontScale) { ++ [config setCodeFontScale:newStyle.code.fontScale]; ++ changed = YES; ++ } ++ + if (newStyle.code.fontSize != oldStyle.code.fontSize) { + [config setCodeFontSize:newStyle.code.fontSize]; + changed = YES; +diff --git a/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h b/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h +--- a/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h ++++ b/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h +@@ -143,7 +143,7 @@ + hashFields(s.codeBlock.fontFamily, s.codeBlock.fontSize, s.codeBlock.fontWeight, s.codeBlock.marginTop, + s.codeBlock.marginBottom, s.codeBlock.lineHeight, s.codeBlock.padding, s.codeBlock.borderRadius, + s.codeBlock.borderWidth); +- hashFields(s.code.fontFamily, s.code.fontSize); ++ hashFields(s.code.fontFamily, s.code.fontSize, s.code.fontScale); + hashFields(s.link.fontFamily, s.strong.fontFamily, s.strong.fontWeight, s.em.fontFamily, s.em.fontStyle); + // Variants change link metrics: a font family, or a pill box around the label. + for (const auto &variant : s.linkVariants) { +diff --git a/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m b/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m +--- a/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m ++++ b/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m +@@ -24,7 +24,7 @@ + UIFontDescriptorSymbolicTraits traits = blockFont.fontDescriptor.symbolicTraits; + UIFontWeight weight = (traits & UIFontDescriptorTraitBold) ? UIFontWeightBold : UIFontWeightRegular; + +- CGFloat codeFontSize = _config.codeFontSize > 0 ? _config.codeFontSize : blockStyle.fontSize; ++ CGFloat codeFontSize = _config.codeFontSize > 0 ? _config.codeFontSize : blockStyle.fontSize * _config.codeFontScale; + + // Both sizes above are unscaled; apply the same multiplier as the surrounding block font. + CGFloat fontScale = context.allowFontScaling ? RCTFontSizeMultiplierWithMax(context.maxFontSizeMultiplier) : 1.0; +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt +@@ -5,6 +5,7 @@ + data class CodeStyle( + val fontFamily: String, + val fontSize: Float, ++ val fontScale: Float, + val color: Int, + val backgroundColor: Int, + val borderColor: Int, +@@ -17,10 +18,11 @@ + val fontFamily = parser.parseString(map, "fontFamily") + val fontSizeRaw = parser.parseOptionalDouble(map, "fontSize").toFloat() + val fontSize = if (fontSizeRaw > 0) parser.toPixelFromSP(fontSizeRaw) else 0f ++ val fontScale = parser.parseOptionalDouble(map, "fontScale", 1.0).toFloat() + val color = parser.parseColor(map, "color") + val backgroundColor = parser.parseColor(map, "backgroundColor") + val borderColor = parser.parseColor(map, "borderColor") +- return CodeStyle(fontFamily, fontSize, color, backgroundColor, borderColor) ++ return CodeStyle(fontFamily, fontSize, fontScale, color, backgroundColor, borderColor) + } + } + } +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +@@ -40,6 +40,7 @@ + + val codeFontFamily: String = style.codeStyle.fontFamily + val codeFontSize: Float = style.codeStyle.fontSize ++ val codeFontScale: Float = style.codeStyle.fontScale + val codeColor: Int = style.codeStyle.color + val spoilerColor: Int = style.spoilerStyle.color + val spoilerParticleDensity: Float = style.spoilerStyle.particleDensity +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +@@ -20,7 +20,7 @@ + } + + private fun applyMonospacedFont(paint: TextPaint) { +- paint.textSize = if (styleCache.codeFontSize > 0) styleCache.codeFontSize else blockStyle.fontSize ++ paint.textSize = if (styleCache.codeFontSize > 0) styleCache.codeFontSize else blockStyle.fontSize * styleCache.codeFontScale + val preservedStyle = (paint.typeface?.style ?: 0) and (Typeface.BOLD or Typeface.ITALIC) + paint.typeface = + if (styleCache.codeFontFamily.isNotEmpty()) { +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp +index a430d1a..b30a017 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp +@@ -22,6 +22,14 @@ void MarkdownContainerShadowNode::dirtyLayoutIfNeeded() { + } + } + ++// Only new props change the rendered markdown. When a parent is cloned, React Native's adoptYogaChild ++// re-clones its children without new props, and dirtying those clones re-measures every settled message ++// whenever the list above grows. ++bool MarkdownContainerShadowNode::shouldNewRevisionDirtyMeasurement(const ShadowNode & /*sourceShadowNode*/, ++ const ShadowNodeFragment &fragment) const { ++ return fragment.props != nullptr; ++} ++ + Size MarkdownContainerShadowNode::measureContent(const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const { + return measurementsManager_->measure(getSurfaceId(), getTag(), getConcreteProps(), layoutConstraints); +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h +index dea8bb2..6c4bd20 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h +@@ -19,6 +19,8 @@ public: + + MarkdownContainerShadowNode(ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment) { ++ forceHeightRecalculationCounter_ = ++ static_cast(sourceShadowNode).forceHeightRecalculationCounter_; + dirtyLayoutIfNeeded(); + } + +@@ -35,6 +37,10 @@ public: + + Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override; + ++protected: ++ bool shouldNewRevisionDirtyMeasurement(const ShadowNode &sourceShadowNode, ++ const ShadowNodeFragment &fragment) const override; ++ + private: + int forceHeightRecalculationCounter_{0}; + std::shared_ptr measurementsManager_; +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +index 3ca259b..ab51055 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +@@ -22,6 +22,14 @@ void MarkdownTextShadowNode::dirtyLayoutIfNeeded() { + } + } + ++// Only new props change the rendered markdown. When a parent is cloned, React Native's adoptYogaChild ++// re-clones its children without new props, and dirtying those clones re-measures every settled message ++// whenever the list above grows. ++bool MarkdownTextShadowNode::shouldNewRevisionDirtyMeasurement(const ShadowNode & /*sourceShadowNode*/, ++ const ShadowNodeFragment &fragment) const { ++ return fragment.props != nullptr; ++} ++ + Size MarkdownTextShadowNode::measureContent(const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const { + return measurementsManager_->measure(getSurfaceId(), getTag(), getConcreteProps(), layoutConstraints); +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +index eaa3279..05a02eb 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +@@ -22,6 +22,8 @@ public: + + MarkdownTextShadowNode(ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment) { ++ forceHeightRecalculationCounter_ = ++ static_cast(sourceShadowNode).forceHeightRecalculationCounter_; + dirtyLayoutIfNeeded(); + } + +@@ -39,6 +41,10 @@ public: + + Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override; + ++protected: ++ bool shouldNewRevisionDirtyMeasurement(const ShadowNode &sourceShadowNode, ++ const ShadowNodeFragment &fragment) const override; ++ + private: + int forceHeightRecalculationCounter_{0}; + std::shared_ptr measurementsManager_; +diff --git a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +--- a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h ++++ b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +@@ -1639,6 +1639,7 @@ + struct EnrichedMarkdownMarkdownStyleCodeStruct { + std::string fontFamily{}; + Float fontSize{0.0}; ++ Float fontScale{0.0}; + SharedColor color{}; + SharedColor backgroundColor{}; + SharedColor borderColor{}; +@@ -1651,6 +1652,7 @@ + folly::dynamic result = folly::dynamic::object(); + result["fontFamily"] = fontFamily; + result["fontSize"] = fontSize; ++ result["fontScale"] = fontScale; + result["color"] = ::facebook::react::toDynamic(color); + result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor); + result["borderColor"] = ::facebook::react::toDynamic(borderColor); +@@ -1670,6 +1672,10 @@ + if (tmp_fontSize != map.end()) { + fromRawValue(context, tmp_fontSize->second, result.fontSize); + } ++ auto tmp_fontScale = map.find("fontScale"); ++ if (tmp_fontScale != map.end()) { ++ fromRawValue(context, tmp_fontScale->second, result.fontScale); ++ } + auto tmp_color = map.find("color"); + if (tmp_color != map.end()) { + fromRawValue(context, tmp_color->second, result.color); +@@ -6185,6 +6191,7 @@ + struct EnrichedMarkdownTextMarkdownStyleCodeStruct { + std::string fontFamily{}; + Float fontSize{0.0}; ++ Float fontScale{0.0}; + SharedColor color{}; + SharedColor backgroundColor{}; + SharedColor borderColor{}; +@@ -6197,6 +6204,7 @@ + folly::dynamic result = folly::dynamic::object(); + result["fontFamily"] = fontFamily; + result["fontSize"] = fontSize; ++ result["fontScale"] = fontScale; + result["color"] = ::facebook::react::toDynamic(color); + result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor); + result["borderColor"] = ::facebook::react::toDynamic(borderColor); +@@ -6216,6 +6224,10 @@ + if (tmp_fontSize != map.end()) { + fromRawValue(context, tmp_fontSize->second, result.fontSize); + } ++ auto tmp_fontScale = map.find("fontScale"); ++ if (tmp_fontScale != map.end()) { ++ fromRawValue(context, tmp_fontScale->second, result.fontScale); ++ } + auto tmp_color = map.find("color"); + if (tmp_color != map.end()) { + fromRawValue(context, tmp_color->second, result.color); +diff --git a/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h b/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +--- a/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h ++++ b/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +@@ -1639,6 +1639,7 @@ + struct EnrichedMarkdownMarkdownStyleCodeStruct { + std::string fontFamily{}; + Float fontSize{0.0}; ++ Float fontScale{0.0}; + SharedColor color{}; + SharedColor backgroundColor{}; + SharedColor borderColor{}; +@@ -1651,6 +1652,7 @@ + folly::dynamic result = folly::dynamic::object(); + result["fontFamily"] = fontFamily; + result["fontSize"] = fontSize; ++ result["fontScale"] = fontScale; + result["color"] = ::facebook::react::toDynamic(color); + result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor); + result["borderColor"] = ::facebook::react::toDynamic(borderColor); +@@ -1669,6 +1671,10 @@ + auto tmp_fontSize = map.find("fontSize"); + if (tmp_fontSize != map.end()) { + fromRawValue(context, tmp_fontSize->second, result.fontSize); ++ } ++ auto tmp_fontScale = map.find("fontScale"); ++ if (tmp_fontScale != map.end()) { ++ fromRawValue(context, tmp_fontScale->second, result.fontScale); + } + auto tmp_color = map.find("color"); + if (tmp_color != map.end()) { +@@ -6185,6 +6191,7 @@ + struct EnrichedMarkdownTextMarkdownStyleCodeStruct { + std::string fontFamily{}; + Float fontSize{0.0}; ++ Float fontScale{0.0}; + SharedColor color{}; + SharedColor backgroundColor{}; + SharedColor borderColor{}; +@@ -6197,6 +6204,7 @@ + folly::dynamic result = folly::dynamic::object(); + result["fontFamily"] = fontFamily; + result["fontSize"] = fontSize; ++ result["fontScale"] = fontScale; + result["color"] = ::facebook::react::toDynamic(color); + result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor); + result["borderColor"] = ::facebook::react::toDynamic(borderColor); +@@ -6215,6 +6223,10 @@ + auto tmp_fontSize = map.find("fontSize"); + if (tmp_fontSize != map.end()) { + fromRawValue(context, tmp_fontSize->second, result.fontSize); ++ } ++ auto tmp_fontScale = map.find("fontScale"); ++ if (tmp_fontScale != map.end()) { ++ fromRawValue(context, tmp_fontScale->second, result.fontScale); + } + auto tmp_color = map.find("color"); + if (tmp_color != map.end()) { diff --git a/patches/react-native-reanimated+4.5.5.patch b/patches/react-native-reanimated+4.5.5.patch new file mode 100644 index 0000000000..b1d3218add --- /dev/null +++ b/patches/react-native-reanimated+4.5.5.patch @@ -0,0 +1,35 @@ +diff --git a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt +index 48e3771..0fba1af 100644 +--- a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt ++++ b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt +@@ -245,6 +245,15 @@ open class NativeProxy { + }.apply { isAccessible = true } + } + ++ // Backport of https://github.com/software-mansion/react-native-reanimated/pull/10435 ++ // (shipped in Reanimated 4.7.1). The reflective updatePropsSynchronously path skips RN's ++ // view-existence guard, so a tag that unmounted with buffered updates throws ++ // RetryableMountingLayerException on every event dispatch and Log.w printed the full stack ++ // on the UI thread inside the draw pass. Remove once the installed Reanimated includes the fix. ++ private val getViewExistsMethod by lazy { ++ runCatching { mountingManager.javaClass.getMethod("getViewExists", Int::class.javaPrimitiveType) }.getOrNull() ++ } ++ + @DoNotStrip + fun synchronouslyUpdateUIProps( + intBuffer: IntArray, +@@ -253,9 +262,13 @@ open class NativeProxy { + SynchronousPropsBufferParser.parse(intBuffer, doubleBuffer) { viewTag, props -> + if (BuildConfig.IS_REACT_NATIVE_86_OR_NEWER) { + try { ++ // The props stay in the registry, and the commit hook applies them to the shadow tree. ++ if (getViewExistsMethod?.invoke(mountingManager, viewTag) == false) { ++ return@parse ++ } + updatePropsSynchronouslyMethod.invoke(mountingManager, viewTag, props) + } catch (e: Exception) { +- Log.w("Reanimated", "synchronouslyUpdateUIProps failed for tag $viewTag", e) ++ Log.w("Reanimated", "synchronouslyUpdateUIProps failed for tag $viewTag: ${e.cause ?: e}") + } + } else { + mFabricUIManager.synchronouslyUpdateViewOnUIThread(viewTag, props) diff --git a/patches/react-native-svg+15.15.4.patch b/patches/react-native-svg+15.15.4.patch index b6b4a85a90..90a30090d7 100644 --- a/patches/react-native-svg+15.15.4.patch +++ b/patches/react-native-svg+15.15.4.patch @@ -59,3 +59,130 @@ index 0c854d6..7dc2828 100644 } return props.transform as TransformsStyle['transform']; } +diff --git a/node_modules/react-native-svg/android/build.gradle b/node_modules/react-native-svg/android/build.gradle +--- a/node_modules/react-native-svg/android/build.gradle ++++ b/node_modules/react-native-svg/android/build.gradle +@@ -103,6 +103,7 @@ + } + + defaultConfig { ++ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + minSdkVersion safeExtGet('minSdkVersion', 16) + //noinspection OldTargetApi + targetSdkVersion safeExtGet('targetSdkVersion', 28) +@@ -136,6 +137,8 @@ + } + + dependencies { ++ androidTestImplementation 'androidx.test:runner:1.6.2' ++ androidTestImplementation 'androidx.test.ext:junit:1.2.1' + implementation 'com.facebook.react:react-native:+' + implementation("com.facebook.fresco:fresco:${FRESCO_VERSION}") { + exclude group: 'com.facebook.soloader' +diff --git a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java +--- a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java ++++ b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java +@@ -159,6 +159,8 @@ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); ++ // Percentage lengths and paths depend on the canvas size, not just SVG props. ++ clearChildCache(); + this.invalidate(); + } + +diff --git a/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java +new file mode 100644 +--- /dev/null ++++ b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java +@@ -0,0 +1,90 @@ ++package com.horcrux.svg; ++ ++import static org.junit.Assert.assertEquals; ++import static org.junit.Assert.assertTrue; ++ ++import android.graphics.Bitmap; ++import android.graphics.Canvas; ++import android.graphics.Color; ++import android.view.View; ++import androidx.test.ext.junit.runners.AndroidJUnit4; ++import androidx.test.platform.app.InstrumentationRegistry; ++import com.facebook.react.bridge.BridgeReactContext; ++import com.facebook.react.bridge.DynamicFromObject; ++import com.facebook.react.bridge.JavaOnlyArray; ++import com.facebook.react.bridge.JavaOnlyMap; ++import com.facebook.react.soloader.OpenSourceMergedSoMapping; ++import com.facebook.react.uimanager.DisplayMetricsHolder; ++import com.facebook.soloader.SoLoader; ++import org.junit.Test; ++import org.junit.runner.RunWith; ++ ++@RunWith(AndroidJUnit4.class) ++public class SvgResizeTest { ++ @Test ++ public void percentageFadeFollowsCanvasGrowthAndShrinkage() { ++ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { ++ android.content.Context app = InstrumentationRegistry.getInstrumentation().getTargetContext(); ++ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(app); ++ try { ++ SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE); ++ } catch (java.io.IOException exception) { ++ throw new RuntimeException(exception); ++ } ++ BridgeReactContext context = new DrawingContext(app); ++ SvgView svg = new SvgView(context); ++ LinearGradientView gradient = new LinearGradientView(context); ++ gradient.setName("fade"); ++ gradient.setX1(new DynamicFromObject("0%")); ++ gradient.setY1(new DynamicFromObject("0%")); ++ gradient.setX2(new DynamicFromObject("0%")); ++ gradient.setY2(new DynamicFromObject("100%")); ++ gradient.setGradientUnits(0); ++ gradient.setGradient(JavaOnlyArray.of(0.0, Color.RED, 1.0, Color.TRANSPARENT)); ++ svg.addView(gradient); ++ // Exercise recursive invalidation as well as the direct child case. ++ GroupView group = new GroupView(context); ++ RectView rectangle = new RectView(context); ++ rectangle.setX(new DynamicFromObject(0.0)); ++ rectangle.setY(new DynamicFromObject(0.0)); ++ rectangle.setWidth(new DynamicFromObject("100%")); ++ rectangle.setHeight(new DynamicFromObject("100%")); ++ rectangle.setFill(JavaOnlyMap.of("type", 1, "brushRef", "fade")); ++ group.addView(rectangle); ++ svg.addView(group); ++ ++ assertFade(svg, 80, 60); ++ assertFade(svg, 160, 120); ++ assertFade(svg, 60, 40); ++ assertFade(svg, 160, 120); ++ }); ++ } ++ ++ // Pixel rendering needs a React context but does not send layout events to JavaScript. ++ private static final class DrawingContext extends BridgeReactContext ++ implements com.facebook.react.uimanager.events.EventDispatcherProvider { ++ DrawingContext(android.content.Context context) { super(context); } ++ ++ @Override ++ public com.facebook.react.uimanager.events.EventDispatcher getEventDispatcher() { return null; } ++ } ++ ++ private static void assertFade(SvgView svg, int width, int height) { ++ svg.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), ++ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)); ++ svg.layout(0, 0, width, height); ++ Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); ++ svg.draw(new Canvas(image)); ++ try { ++ int middle = Color.alpha(image.getPixel(width / 2, height / 2)); ++ int right = Color.alpha(image.getPixel(width - 2, height / 2)); ++ assertTrue("Gradient midpoint must follow canvas height at " + width + "x" + height ++ + "; alpha=" + middle, middle >= 120 && middle <= 135); ++ assertEquals("Fade must reach the right edge at " + width + "x" + height, middle, right); ++ assertTrue("Fade must end at the new bottom edge", ++ Color.alpha(image.getPixel(width / 2, height - 2)) < 20); ++ } finally { ++ image.recycle(); ++ } ++ } ++} diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index 0195868340..c3da63a8ac 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -329,3 +329,435 @@ index 195954b..c4ac28c 100644 namespace "com.unistyles" defaultConfig { +diff --git a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx +--- a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx ++++ b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx +@@ -28,13 +28,14 @@ + + return forwardRef, PropsWithUnistyles>((props, ref) => { + const narrowedProps = props as PropsWithUnistyles & UnistyleStyles ++ const { uniProps, ...componentProps } = narrowedProps + const styleClassNames = getClassName(narrowedProps.style, true) + const contentContainerStyleClassNames = getClassName(narrowedProps.contentContainerStyle) + const { proxifiedRuntime, proxifiedTheme } = useProxifiedUnistyles() + + const { key: mappingsKey, ...mappingsProps } = mappings ? mappings(proxifiedTheme, proxifiedRuntime) : {} +- const { key: uniPropsKey, ...unistyleProps } = narrowedProps.uniProps +- ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) ++ const { key: uniPropsKey, ...unistyleProps } = uniProps ++ ? uniProps(proxifiedTheme, proxifiedRuntime) + : {} + + const emptyStyles = narrowedProps.style +@@ -46,7 +47,7 @@ + : undefined + + const combinedProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style + ? { + // Override default component styles with undefined values to reset them +diff --git a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx +--- a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx ++++ b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx +@@ -63,6 +63,7 @@ + + return forwardRef, PropsWithUnistyles>((props, ref) => { + const narrowedProps = props as PropsWithUnistyles & UnistyleStyles ++ const { uniProps, ...componentProps } = narrowedProps + const NativeComponent = Component as ComponentType + + // @ts-ignore we don't know the type of the component +@@ -89,15 +90,15 @@ + }, [narrowedProps.style, narrowedProps.contentContainerStyle]) + + const { key: mappingsKey, ...mappingsProps } = mappings ? mappings(proxifiedTheme, proxifiedRuntime) : {} +- const { key: uniPropsKey, ...unistyleProps } = narrowedProps.uniProps +- ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) ++ const { key: uniPropsKey, ...unistyleProps } = uniProps ++ ? uniProps(proxifiedTheme, proxifiedRuntime) + : {} + + const styleSecrets = getSecrets(narrowedProps.style) + const contentContainerStyleSecrets = getSecrets(narrowedProps.contentContainerStyle) + + const finalProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style + ? { + style: styleSecrets.styles, +diff --git a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js +--- a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js ++++ b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js +@@ -13,6 +13,7 @@ + export const withUnistyles = (Component, mappings) => { + return /*#__PURE__*/forwardRef((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const styleClassNames = getClassName(narrowedProps.style, true); + const contentContainerStyleClassNames = getClassName(narrowedProps.contentContainerStyle); + const { +@@ -26,10 +27,10 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const emptyStyles = narrowedProps.style ? Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(narrowedProps.style)).filter(([key]) => !key.startsWith('unistyles') && !key.startsWith('_')).map(([key]) => [key, undefined])) : undefined; + const combinedProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + // Override default component styles with undefined values to reset them + style: emptyStyles +diff --git a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js +--- a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js ++++ b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js +@@ -27,6 +27,7 @@ + }; + return /*#__PURE__*/forwardRef((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const NativeComponent = Component; + + // @ts-ignore we don't know the type of the component +@@ -57,11 +58,11 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const styleSecrets = getSecrets(narrowedProps.style); + const contentContainerStyleSecrets = getSecrets(narrowedProps.contentContainerStyle); + const finalProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + style: styleSecrets.styles + } : {}), +diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js +--- a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js ++++ b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js +@@ -18,6 +18,7 @@ + const withUnistyles = (Component, mappings) => { + return /*#__PURE__*/(0, _react.forwardRef)((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const styleClassNames = (0, _getClassname.getClassName)(narrowedProps.style, true); + const contentContainerStyleClassNames = (0, _getClassname.getClassName)(narrowedProps.contentContainerStyle); + const { +@@ -31,10 +32,10 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const emptyStyles = narrowedProps.style ? Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(narrowedProps.style)).filter(([key]) => !key.startsWith('unistyles') && !key.startsWith('_')).map(([key]) => [key, undefined])) : undefined; + const combinedProps = { +- ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, props), ++ ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + // Override default component styles with undefined values to reset them + style: emptyStyles +diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js +--- a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js ++++ b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js +@@ -32,6 +32,7 @@ + }; + return /*#__PURE__*/(0, _react.forwardRef)((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const NativeComponent = Component; + + // @ts-ignore we don't know the type of the component +@@ -62,11 +63,11 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const styleSecrets = getSecrets(narrowedProps.style); + const contentContainerStyleSecrets = getSecrets(narrowedProps.contentContainerStyle); + const finalProps = { +- ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, props), ++ ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + style: styleSecrets.styles + } : {}), +diff --git a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js +index 305fa49..5957aa5 100644 +--- a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js ++++ b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js +@@ -35,10 +35,17 @@ class UnistylesRuntime { + return this.darkMedia; + } + get colorScheme() { ++ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers ++ // MediaQueryList change events while the document is hidden and drops the pending event ++ // of any list whose `matches` was read before the document became visible again, which ++ // would silence the adaptive-theme listeners registered on those two lists. ++ if ((0, _utils.isServer)()) { ++ return _types.ColorScheme.Unspecified; ++ } + switch (true) { +- case this.getLightMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: light)').matches: + return _types.ColorScheme.Light; +- case this.getDarkMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: dark)').matches: + return _types.ColorScheme.Dark; + default: + return _types.ColorScheme.Unspecified; +diff --git a/node_modules/react-native-unistyles/lib/module/web/runtime.js b/node_modules/react-native-unistyles/lib/module/web/runtime.js +index 7f13a06..c487bee 100644 +--- a/node_modules/react-native-unistyles/lib/module/web/runtime.js ++++ b/node_modules/react-native-unistyles/lib/module/web/runtime.js +@@ -31,10 +31,17 @@ export class UnistylesRuntime { + return this.darkMedia; + } + get colorScheme() { ++ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers ++ // MediaQueryList change events while the document is hidden and drops the pending event ++ // of any list whose `matches` was read before the document became visible again, which ++ // would silence the adaptive-theme listeners registered on those two lists. ++ if (isServer()) { ++ return ColorScheme.Unspecified; ++ } + switch (true) { +- case this.getLightMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: light)').matches: + return ColorScheme.Light; +- case this.getDarkMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: dark)').matches: + return ColorScheme.Dark; + default: + return ColorScheme.Unspecified; +diff --git a/node_modules/react-native-unistyles/src/web/runtime.ts b/node_modules/react-native-unistyles/src/web/runtime.ts +index a16dac0..6519858 100644 +--- a/node_modules/react-native-unistyles/src/web/runtime.ts ++++ b/node_modules/react-native-unistyles/src/web/runtime.ts +@@ -41,8 +41,16 @@ export class UnistylesRuntime { + get colorScheme() { ++ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers ++ // MediaQueryList change events while the document is hidden and drops the pending event ++ // of any list whose `matches` was read before the document became visible again, which ++ // would silence the adaptive-theme listeners registered on those two lists. ++ if (isServer()) { ++ return ColorScheme.Unspecified ++ } ++ + switch (true) { +- case this.getLightMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: light)').matches: + return ColorScheme.Light +- case this.getDarkMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: dark)').matches: + return ColorScheme.Dark + default: + return ColorScheme.Unspecified +diff --git a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt +index 0f8d1f0..154a12a 100644 +--- a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt ++++ b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+android.kt +@@ -3,11 +3,16 @@ package com.unistyles + import android.os.Build + import android.content.Context + import android.content.res.Configuration ++import android.content.res.Resources ++import android.os.Handler ++import android.os.Looper + import android.util.DisplayMetrics + import android.view.View + import android.view.WindowManager + import androidx.annotation.Keep ++import androidx.core.content.OnConfigurationChangedProvider + import androidx.core.text.TextUtilsCompat ++import androidx.core.util.Consumer + import androidx.core.view.ViewCompat + import androidx.core.view.WindowCompat + import androidx.core.view.WindowInsetsCompat +@@ -22,6 +27,7 @@ import com.margelo.nitro.unistyles.Insets + import com.margelo.nitro.unistyles.Orientation + import com.margelo.nitro.unistyles.UnistyleDependency + import com.margelo.nitro.unistyles.UnistylesNativeMiniRuntime ++import java.lang.ref.WeakReference + import java.util.Locale + + @Keep +@@ -30,6 +36,14 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + private val _insets = NativePlatformInsets(reactContext, this::getMiniRuntime) { this.onConfigChange() } + private var _miniRuntime: UnistylesNativeMiniRuntime = buildMiniRuntime() + private val _listener = NativePlatformListener(reactContext, this::getMiniRuntime) { this.diffMiniRuntime() } ++ private var _configurationProvider: WeakReference? = null ++ private val _activityConfigurationListener = Consumer { ++ Handler(Looper.getMainLooper()).post { this.onConfigChange() } ++ } ++ ++ // Application resources can lag behind configuration changes ++ private val configurationResources: Resources ++ get() = reactContext.currentActivity?.resources ?: reactContext.resources + + init { + checkEdgeToEdge() +@@ -37,12 +51,16 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + } + + fun onDestroy() { ++ stopObservingActivityConfiguration() + _listener.onDestroy() + _insets.onDestroy() + reactContext.removeLifecycleEventListener(this) + } + + override fun onHostResume() { ++ observeActivityConfiguration() ++ // catch configuration changes missed while no Activity was attached ++ onConfigChange() + _insets.getInitialInsets(false) + _insets.startInsetsListener() + } +@@ -51,7 +69,26 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + _insets.stopInsetsListener() + } + +- override fun onHostDestroy() {} ++ override fun onHostDestroy() { ++ stopObservingActivityConfiguration() ++ } ++ ++ private fun observeActivityConfiguration() { ++ val provider = reactContext.currentActivity as? OnConfigurationChangedProvider ++ ++ if (_configurationProvider?.get() === provider) { ++ return ++ } ++ ++ stopObservingActivityConfiguration() ++ provider?.addOnConfigurationChangedListener(_activityConfigurationListener) ++ _configurationProvider = provider?.let { WeakReference(it) } ++ } ++ ++ private fun stopObservingActivityConfiguration() { ++ _configurationProvider?.get()?.removeOnConfigurationChangedListener(_activityConfigurationListener) ++ _configurationProvider = null ++ } + + override val memorySize: Long + get() = 0 +@@ -61,7 +98,7 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + } + + override fun getColorScheme(): ColorScheme { +- val uiMode = reactContext.resources.configuration.uiMode ++ val uiMode = configurationResources.configuration.uiMode + + val colorScheme = when (uiMode.and(Configuration.UI_MODE_NIGHT_MASK)) { + Configuration.UI_MODE_NIGHT_YES -> ColorScheme.DARK +@@ -73,15 +110,15 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + } + + override fun getFontScale(): Double { +- return reactContext.resources.configuration.fontScale.toDouble() ++ return configurationResources.configuration.fontScale.toDouble() + } + + override fun getPixelRatio(): Double { +- return reactContext.resources.displayMetrics.density.toDouble() ++ return configurationResources.displayMetrics.density.toDouble() + } + + override fun getOrientation(): Orientation { +- val orientation = when (reactContext.resources.configuration.orientation) { ++ val orientation = when (configurationResources.configuration.orientation) { + Configuration.ORIENTATION_PORTRAIT -> Orientation.PORTRAIT + Configuration.ORIENTATION_LANDSCAPE -> Orientation.LANDSCAPE + else -> Orientation.PORTRAIT +@@ -122,7 +159,7 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + return Dimensions(screenWidth, screenHeight) + } + else -> { +- val displayMetrics = reactContext.resources.displayMetrics ++ val displayMetrics = configurationResources.displayMetrics + + reactContext.currentActivity?.windowManager?.currentWindowMetrics?.bounds?.let { + val boundsWidth = (it.width() / displayMetrics.density).toDouble() +@@ -157,7 +194,7 @@ class NativePlatformAndroid(private val reactContext: ReactApplicationContext): + "com.facebook.react.modules.i18nmanager.I18nUtil", + Context.MODE_PRIVATE + ) +- val resourcesLocale = reactContext.resources.configuration.locales[0] ++ val resourcesLocale = configurationResources.configuration.locales[0] + val hasForcedRtl = sharedPrefs.getBoolean("RCTI18nUtil_forceRTL", false) + // user preferences + val isRtl = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL +diff --git a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt +index a9c1804..f5a3c20 100644 +--- a/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt ++++ b/node_modules/react-native-unistyles/android/src/main/java/com/unistyles/NativePlatform+insets.kt +@@ -38,7 +38,7 @@ class NativePlatformInsets( + } + + fun getInsets(): Insets { +- val density = reactContext.resources.displayMetrics.density ++ val density = (reactContext.currentActivity?.resources ?: reactContext.resources).displayMetrics.density + + return Insets( + this._insets.top / density, +diff --git a/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp b/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp +index 01026dc..3786ec4 100644 +--- a/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp ++++ b/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp +@@ -17,13 +17,16 @@ void shadow::ShadowTreeManager::updateShadowTree(jsi::Runtime& rt) { + } + + #if REACT_NATIVE_VERSION_MINOR >= 81 +- std::unordered_map tagToProps; ++ // Paseo backport of https://github.com/jpudysz/react-native-unistyles/pull/1357. ++ // UIManager::updateShadowTree commits a root cloned before the commit, so when a ++ // window-size commit (rotation, keyboard) lands in between, the retried Unistyles ++ // commit puts the previous layout constraints back and the app stays laid out for ++ // the old size. Commit here instead and clone from the root the commit hands us. ++ std::unordered_set affectedSurfaces; + + for (const auto& [family, props] : updates) { + auto safeProps = props.isObject() ? props : folly::dynamic::object(); + +- tagToProps.insert({family->getTag(), safeProps}); +- + auto* mutableFamily = const_cast(family); + + if (mutableFamily->nativeProps_DEPRECATED && mutableFamily->nativeProps_DEPRECATED->isObject()) { +@@ -31,9 +34,30 @@ void shadow::ShadowTreeManager::updateShadowTree(jsi::Runtime& rt) { + } else { + mutableFamily->nativeProps_DEPRECATED = std::make_unique(safeProps); + } ++ ++ affectedSurfaces.insert(family->getSurfaceId()); + } + +- UIManagerBinding::getBinding(rt)->getUIManager().updateShadowTree(std::move(tagToProps)); ++ const auto& shadowTreeRegistry = UIManagerBinding::getBinding(rt)->getUIManager().getShadowTreeRegistry(); ++ ++ shadowTreeRegistry.enumerate([&updates, &affectedSurfaces](const ShadowTree& shadowTree, bool&){ ++ if (!affectedSurfaces.contains(shadowTree.getSurfaceId())) { ++ return; ++ } ++ ++ auto transaction = [&updates](const RootShadowNode& oldRootShadowNode) -> RootShadowNode::Unshared { ++ auto affectedNodes = shadow::ShadowTreeManager::findAffectedNodes(oldRootShadowNode, updates); ++ ++ return std::static_pointer_cast(shadow::ShadowTreeManager::cloneShadowTree( ++ oldRootShadowNode, ++ updates, ++ affectedNodes ++ )); ++ }; ++ ++ // mountSynchronously: this is an update from C++, not React ++ shadowTree.commit(transaction, {false, true}); ++ }); + #else + const auto& shadowTreeRegistry = UIManagerBinding::getBinding(rt)->getUIManager().getShadowTreeRegistry(); + diff --git a/plugin-examples/agent-configuration/README.md b/plugin-examples/agent-configuration/README.md index 189afe6cfd..cb191bd3e1 100644 --- a/plugin-examples/agent-configuration/README.md +++ b/plugin-examples/agent-configuration/README.md @@ -1,14 +1,19 @@ # Agent configuration -Changes new Codex agents through `server.before("agent.create", ...)`: +Sets saved Codex options through `server.addHook("agent.create.before", async ...)`: - Sets `config.providerOptions.sandbox_mode` to `workspace-write`. - Sets `config.providerOptions.approval_policy` to `on-request`. -- Adds the `company` server to `config.mcpServers`. -- Preserves other configuration, provider options, MCP servers, and environment overrides. +- Preserves other configuration and provider options. + +Adds the `company` MCP server to Codex launches through +`server.addHook("agent.open.before", async ...)`. The server is never saved in the agent's configuration; +existing saved MCP servers are preserved. Existing agents receive the server through the +[session reopen behavior](../../public-docs/plugins/hooks.md#attach-tools-to-agents). Replace `https://tools.example.com/mcp` in `index.server.ts` with your MCP server URL before using -this example. The URL is a placeholder. The `company` entry replaces an existing server with that name. +this example. A `company` name collision with a saved server or another +plugin fails the session open and names both sources. Install on a daemon with plugins enabled: @@ -16,11 +21,13 @@ Install on a daemon with plugins enabled: paseo plugin install /absolute/path/to/plugin-examples/agent-configuration ``` -The callback receives the request after earlier plugins have transformed it. The nested spreads -preserve their settings; the explicit values replace only the named fields. Paseo validates and saves -the resulting configuration before starting the agent. Existing agents are unchanged. +Each session-open callback receives only this plugin's MCP servers. The nested spreads preserve +entries added by this plugin's earlier callbacks. The create callback receives configuration after +earlier plugins have transformed it; its explicit Codex options replace the named fields in the +saved configuration of new agents and remain after this plugin is disabled. -See the [lifecycle reference](../../public-docs/plugins/reference.md#lifecycle-hooks) for ordering -and callback shapes. [Lifecycle actions](../lifecycle-actions) demonstrates provider switching and -environment injection. Its provider-switch callback clears provider options when changing Codex to -Claude, so use these examples separately when trying the Codex-specific settings. +See [Inject MCP servers](../../public-docs/plugins/hooks.md#inject-mcp-servers) for choosing +between saved configuration and plugin-owned servers. [Lifecycle actions](../lifecycle-actions) +demonstrates provider switching and environment injection. Its provider-switch callback clears +provider options when changing Codex to Claude, so use these examples separately when trying the +Codex-specific settings. diff --git a/plugin-examples/agent-configuration/index.server.ts b/plugin-examples/agent-configuration/index.server.ts index 1f86e63a67..cfb236caa1 100644 --- a/plugin-examples/agent-configuration/index.server.ts +++ b/plugin-examples/agent-configuration/index.server.ts @@ -1,7 +1,7 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; export default function contribute(server: PluginServerContext) { - server.before("agent.create", ({ request }) => { + server.addHook("agent.create.before", async ({ request }) => { if (request.config.provider !== "codex") { return request; } @@ -15,12 +15,22 @@ export default function contribute(server: PluginServerContext) { sandbox_mode: "workspace-write", approval_policy: "on-request", }, - mcpServers: { - ...request.config.mcpServers, - company: { - type: "http", - url: "https://tools.example.com/mcp", - }, + }, + }; + }); + + server.addHook("agent.open.before", async ({ request }) => { + if (request.provider !== "codex") { + return; + } + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { + type: "http", + url: "https://tools.example.com/mcp", }, }, }; diff --git a/plugin-examples/agent-configuration/paseo-plugin.json b/plugin-examples/agent-configuration/paseo-plugin.json index 1a049eda62..98e1460c12 100644 --- a/plugin-examples/agent-configuration/paseo-plugin.json +++ b/plugin-examples/agent-configuration/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "agent-configuration", "requirements": { - "paseo": ">=0.8.0" + "paseo": ">=0.14.0-beta.1" } } diff --git a/plugin-examples/agent-tools/README.md b/plugin-examples/agent-tools/README.md new file mode 100644 index 0000000000..e8674a9d66 --- /dev/null +++ b/plugin-examples/agent-tools/README.md @@ -0,0 +1,33 @@ +# Agent tools + +Attaches `append_workspace_note` to agents whose `notes/enabled` label is `"true"`. +Calling the tool with `{ "note": "Run the targeted tests before committing." }` appends that +line to `AGENT_NOTES.md` in the calling agent's working directory and returns the file path. +It creates the file if it does not exist. + +Install on a daemon with plugins enabled: + +```bash +paseo plugin install /absolute/path/to/plugin-examples/agent-tools +``` + +Create a labeled agent through the SDK: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +The session-open callback also runs when an agent resumes or reloads. After changing an existing +agent's labels, call `paseo.agents.ref(id).reload()` to apply the selection immediately. This +interrupts a running turn. `refresh()` reads the agent snapshot without reopening its session. + +From the repository root, `npm run typecheck` includes this example. Outside this repository, +use `paseo plugin init` to create the package and TypeScript setup, then copy `index.server.ts` +and the manifest into that project. + +See [Attach tools to agents](../../public-docs/plugins/hooks.md#attach-tools-to-agents) +for targeting, execution, provider gates, collisions, and plugin reload behavior. diff --git a/plugin-examples/agent-tools/index.server.ts b/plugin-examples/agent-tools/index.server.ts new file mode 100644 index 0000000000..1bede92569 --- /dev/null +++ b/plugin-examples/agent-tools/index.server.ts @@ -0,0 +1,26 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.open.before", async ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} diff --git a/plugin-examples/agent-tools/paseo-plugin.json b/plugin-examples/agent-tools/paseo-plugin.json new file mode 100644 index 0000000000..e67c53f072 --- /dev/null +++ b/plugin-examples/agent-tools/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "agent-tools", + "requirements": { "paseo": ">=0.14.0-beta.1" } +} diff --git a/plugin-examples/lifecycle-actions/index.server.ts b/plugin-examples/lifecycle-actions/index.server.ts index c97f2a7254..7b1fb1e761 100644 --- a/plugin-examples/lifecycle-actions/index.server.ts +++ b/plugin-examples/lifecycle-actions/index.server.ts @@ -2,7 +2,7 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { latestOutputText, shellCommand } from "./server/inspect"; export default function contribute(server: PluginServerContext) { - server.before("workspace.create", ({ request }) => { + server.addHook("workspace.create.before", async ({ request }) => { if (request.title === "Isolated lifecycle example" && request.source.kind === "directory") { return { ...request, @@ -17,7 +17,7 @@ export default function contribute(server: PluginServerContext) { return request; }); - server.on("agent.turn_ended", async (event, context) => { + server.addHook("agent.turn.after", async (event, context) => { if (event.outcome.kind === "canceled") { return; } @@ -30,7 +30,7 @@ export default function contribute(server: PluginServerContext) { } }); - server.on("agent.permission_requested", async (event, context) => { + server.addHook("agent.permission.before", async (event, context) => { const command = shellCommand(event.request); if (command === null) { return; @@ -56,7 +56,7 @@ export default function contribute(server: PluginServerContext) { } }); - server.before("agent.create", ({ request }) => { + server.addHook("agent.create.before", async ({ request }) => { return { ...request, env: { @@ -66,7 +66,7 @@ export default function contribute(server: PluginServerContext) { }; }); - server.before("agent.create", ({ request }) => { + server.addHook("agent.create.before", async ({ request }) => { if (request.config.provider === "codex") { return { ...request, @@ -82,7 +82,7 @@ export default function contribute(server: PluginServerContext) { return request; }); - server.before("agent.session_open", ({ request }) => { + server.addHook("agent.open.before", async ({ request }) => { return { ...request, env: { diff --git a/plugin-examples/lifecycle-actions/paseo-plugin.json b/plugin-examples/lifecycle-actions/paseo-plugin.json index fe578fae6d..df289d0aa2 100644 --- a/plugin-examples/lifecycle-actions/paseo-plugin.json +++ b/plugin-examples/lifecycle-actions/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "lifecycle-actions", "requirements": { - "paseo": ">=0.8.0" + "paseo": ">=0.14.0-beta.1" } } diff --git a/plugin-examples/lifecycle-logger/README.md b/plugin-examples/lifecycle-logger/README.md index d6c00b8032..60259d34c7 100644 --- a/plugin-examples/lifecycle-logger/README.md +++ b/plugin-examples/lifecycle-logger/README.md @@ -1,6 +1,6 @@ # Lifecycle logger -Registers all eleven server lifecycle hooks and logs their data as JSON. It observes agent creation, +Registers agent, workspace, and worktree hooks and logs their data as JSON. It observes agent creation, session opening, turns, permissions, archive, and workspace creation/archive. Before hooks return the request unchanged. Environment values are redacted; prompts and timeline content are logged. diff --git a/plugin-examples/lifecycle-logger/index.server.ts b/plugin-examples/lifecycle-logger/index.server.ts index 0f5fd4ed95..e87be2c2f6 100644 --- a/plugin-examples/lifecycle-logger/index.server.ts +++ b/plugin-examples/lifecycle-logger/index.server.ts @@ -2,55 +2,59 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { logHook } from "./server/log"; export default function contribute(server: PluginServerContext) { - server.before("workspace.create", ({ request }) => { - logHook("before workspace.create", { request }); + server.addHook("workspace.create.before", async ({ request }) => { + logHook("workspace.create.before", { request }); return request; }); - server.on("workspace.created", (event) => { - logHook("workspace.created", event); + server.addHook("workspace.create.after", async (event) => { + logHook("workspace.create.after", event); }); - server.on("workspace.archived", (event) => { - logHook("workspace.archived", event); + server.addHook("workspace.archive.after", async (event) => { + logHook("workspace.archive.after", event); }); - server.before("agent.create", ({ request }) => { - logHook("before agent.create", { request }); + server.addHook("worktree.create.after", async (event) => { + logHook("worktree.create.after", event); + }); + + server.addHook("agent.create.before", async ({ request }) => { + logHook("agent.create.before", { request }); return request; }); - server.before("agent.session_open", ({ request }) => { - logHook("before agent.session_open", { request }); + server.addHook("agent.open.before", async ({ request }) => { + logHook("agent.open.before", { request }); return request; }); - server.on("agent.created", (event) => { - logHook("agent.created", event); + server.addHook("agent.create.after", async (event) => { + logHook("agent.create.after", event); }); - server.on("agent.closed", (event) => { - logHook("agent.closed", event); + server.addHook("agent.close.after", async (event) => { + logHook("agent.close.after", event); }); - server.on("agent.turn_started", (event) => { - logHook("agent.turn_started", event); + server.addHook("agent.turn.before", async (event) => { + logHook("agent.turn.before", event); }); - server.on("agent.turn_ended", (event) => { - logHook("agent.turn_ended", event); + server.addHook("agent.turn.after", async (event) => { + logHook("agent.turn.after", event); }); - server.on("agent.permission_requested", (event) => { - logHook("agent.permission_requested", event); + server.addHook("agent.permission.before", async (event) => { + logHook("agent.permission.before", event); }); - server.on("agent.permission_resolved", (event) => { - logHook("agent.permission_resolved", event); + server.addHook("agent.permission.after", async (event) => { + logHook("agent.permission.after", event); }); - server.on("agent.archived", (event) => { - logHook("agent.archived", event); + server.addHook("agent.archive.after", async (event) => { + logHook("agent.archive.after", event); }); return () => {}; diff --git a/plugin-examples/lifecycle-logger/paseo-plugin.json b/plugin-examples/lifecycle-logger/paseo-plugin.json index 8385fbf14c..2c964d4622 100644 --- a/plugin-examples/lifecycle-logger/paseo-plugin.json +++ b/plugin-examples/lifecycle-logger/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "lifecycle-logger", "requirements": { - "paseo": ">=0.8.0" + "paseo": ">=0.14.0-beta.1" } } diff --git a/plugin-examples/worktree-placement/README.md b/plugin-examples/worktree-placement/README.md new file mode 100644 index 0000000000..8d88d8c627 --- /dev/null +++ b/plugin-examples/worktree-placement/README.md @@ -0,0 +1,36 @@ +# Worktree placement + +Place worktrees next to their source checkout, using the repository directory name and branch. +For `/home/me/src/paseo` on branch `feature/x`, the new worktree is +`/home/me/src/paseo.feature-x`. Slashes in branch names become hyphens. + +Install on Paseo 0.14.0-beta.1 or newer with plugins enabled: + +```bash +paseo plugin install /absolute/path/to/plugin-examples/worktree-placement +``` + +The plugin is one file, `index.server.ts`: + +```ts +import { basename, dirname, join } from "node:path"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +export default function contribute(server: PluginServerContext) { + return server.addHook("worktree.create.before", async ({ request }) => ({ + ...request, + path: join( + dirname(request.sourceCheckout), + `${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`, + ), + })); +} +``` + +Paseo validates the returned path and refuses an existing one. It creates, lists, restores, +and removes the worktree through the normal workspace workflow. Restoring uses the recorded +path without running the hook. Git can choose a unique branch name after this hook runs; +the path retains the branch name received by the hook. + +See [worktree.create.before](../../public-docs/plugins/hooks.md#worktreecreatebefore) +for the request fields and validation rules. diff --git a/plugin-examples/worktree-placement/index.server.ts b/plugin-examples/worktree-placement/index.server.ts new file mode 100644 index 0000000000..67509a000d --- /dev/null +++ b/plugin-examples/worktree-placement/index.server.ts @@ -0,0 +1,12 @@ +import { basename, dirname, join } from "node:path"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +export default function contribute(server: PluginServerContext) { + return server.addHook("worktree.create.before", async ({ request }) => ({ + ...request, + path: join( + dirname(request.sourceCheckout), + `${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`, + ), + })); +} diff --git a/plugin-examples/worktree-placement/paseo-plugin.json b/plugin-examples/worktree-placement/paseo-plugin.json new file mode 100644 index 0000000000..ab7f7a20e6 --- /dev/null +++ b/plugin-examples/worktree-placement/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "worktree-placement", + "requirements": { "paseo": ">=0.14.0-beta.1" } +} diff --git a/plugins/package.json b/plugins/package.json index de80b5b54c..e7d0a6019f 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0", + "version": "0.15.0-beta.1", "private": true, "type": "module", "scripts": { diff --git a/plugins/worktree-include/LICENSE b/plugins/worktree-include/LICENSE new file mode 100644 index 0000000000..60cf520f1d --- /dev/null +++ b/plugins/worktree-include/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Carlos Precioso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/worktree-include/index.server.ts b/plugins/worktree-include/index.server.ts new file mode 100644 index 0000000000..9055c1677d --- /dev/null +++ b/plugins/worktree-include/index.server.ts @@ -0,0 +1,8 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { copyWorktreeIncludes } from "./server/copy.js"; + +export default function contribute(server: PluginServerContext) { + return server.addHook("worktree.create.after", async ({ worktree }) => { + await copyWorktreeIncludes(worktree.path, worktree.sourceCheckout); + }); +} diff --git a/plugins/worktree-include/paseo-plugin.json b/plugins/worktree-include/paseo-plugin.json new file mode 100644 index 0000000000..711476b70f --- /dev/null +++ b/plugins/worktree-include/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "worktree-include", "requirements": { "paseo": ">=0.14.0-beta.1" } } diff --git a/plugins/worktree-include/server/copy.ts b/plugins/worktree-include/server/copy.ts new file mode 100644 index 0000000000..73a3ba63f3 --- /dev/null +++ b/plugins/worktree-include/server/copy.ts @@ -0,0 +1,71 @@ +// Derived from cprecioso/paseo-worktreeinclude (MIT); see ./LICENSE in the plugin root. +// Copyright (c) 2026 Carlos Precioso. +import { execFile } from "node:child_process"; +import { constants, existsSync } from "node:fs"; +import { copyFile, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const INCLUDE_FILE = ".worktreeinclude"; + +export type CopyResult = + | { kind: "no-include-file" } + | { kind: "copied"; copied: string[]; skipped: string[] }; + +async function git(cwd: string, args: string[], input?: string): Promise { + const child = exec("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 }); + if (input !== undefined) { + child.child.stdin?.end(input); + } + const { stdout } = await child; + return stdout; +} + +async function gitAllowExit1(cwd: string, args: string[], input: string): Promise { + try { + return await git(cwd, args, input); + } catch (error) { + // `git check-ignore` exits 1 when nothing matched. + if ((error as { code?: number }).code === 1) return ""; + throw error; + } +} + +const splitNul = (s: string) => s.split("\0").filter(Boolean); + +// Copy files that match .worktreeinclude (gitignore syntax) +// AND are gitignored, so tracked files are never touched. +export async function copyWorktreeIncludes(worktree: string, source: string): Promise { + if (!existsSync(join(source, INCLUDE_FILE))) return { kind: "no-include-file" }; + + const matches = splitNul( + await git(source, [ + "ls-files", + "--others", + "--ignored", + `--exclude-from=${INCLUDE_FILE}`, + "-z", + ]), + ); + if (matches.length === 0) return { kind: "copied", copied: [], skipped: [] }; + + const ignored = splitNul( + await gitAllowExit1(source, ["check-ignore", "--stdin", "-z"], matches.join("\0") + "\0"), + ); + + const copied: string[] = []; + const skipped: string[] = []; + for (const file of ignored) { + const dest = join(worktree, file); + if (existsSync(dest)) { + skipped.push(file); + continue; + } + await mkdir(dirname(dest), { recursive: true }); + // Clone (APFS/btrfs copy-on-write) when supported, else fall back to a regular copy. + await copyFile(join(source, file), dest, constants.COPYFILE_FICLONE); + copied.push(file); + } + return { kind: "copied", copied, skipped }; +} diff --git a/public-docs/configuration.md b/public-docs/configuration.md index a11c4c69ba..f89a12dcf7 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -74,7 +74,7 @@ Runtime-safe settings include relay enablement, MCP settings, browser tools, hos New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon whose config already omitted this field when it started keeps the legacy relay-enabled behavior for compatibility. Set `daemon.relay.enabled` explicitly when editing an older config. -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. +Idle runtime cleanup, 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. 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. @@ -84,6 +84,22 @@ Agent providers, both the first-class ones Paseo ships with and custom entries y See [Providers](/docs/providers) for the mental model and [Supported providers](/docs/supported-providers) for the full list of agents Paseo can launch. For pointing Claude at Anthropic-compatible endpoints (Z.AI, Alibaba/Qwen), multiple profiles, custom binaries, ACP agents, and the `additionalModels` merge behavior, see [Custom providers](/docs/custom-providers). The full field reference lives on GitHub at [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md). +## Idle runtimes + +Paseo releases resumable idle agent processes after one hour when its daemon baseline allows +cleanup and any implemented provider check agrees. Your next prompt resumes the same conversation. Disable this +for the entire host by turning off **Release idle agents** in **Settings → your host → Agents**, or with: + +```json +{ "agents": { "idleRuntimeCleanup": false } } +``` + +This setting defaults to `true`. Changes in Settings apply immediately; config-file edits apply +on config reload. Re-enabling keeps existing idle timestamps. There is no per-agent setting. Providers without a runtime safety check use the same daemon baseline and one-hour idle +period. Active work, descendants and pending finish notifications prevent cleanup. ACP adapters +also retain runtimes with running ACP terminals, including silent ones; exited terminals do not +prevent cleanup. This check does not inspect processes launched internally by the provider. + ## Worktrees New worktrees are created under `$PASEO_HOME/worktrees` by default. To place new worktrees somewhere else, set `worktrees.root`: @@ -100,7 +116,7 @@ Relative paths are resolved against `PASEO_HOME`. Existing worktrees remain wher ## Voice -Voice is configured through `features.dictation` and `features.voiceMode`, with provider credentials under `providers`. +Voice is configured through `features.dictation` and `features.voiceMode`. Provider credentials and options live inline at `features.dictation.stt`, `features.voiceMode.stt`, and `features.voiceMode.tts`. For voice philosophy, architecture, and complete local/OpenAI setup examples, see [Voice docs](/docs/voice). @@ -253,9 +269,9 @@ Set the persisted value in `config.json`: - `PASEO_LOG_FILE_ROTATE_SIZE`, override `log.file.rotate.maxSize` - `PASEO_LOG_FILE_ROTATE_COUNT`, override `log.file.rotate.maxFiles` - `PASEO_LOG`, `PASEO_LOG_FORMAT`, legacy log overrides (still supported) -- `OPENAI_API_KEY`, override OpenAI provider key -- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT) -- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS) +- `OPENAI_API_KEY`, `OPENAI_BASE_URL`, fallback speech credentials and base URL when neither a site value nor an endpoint env value is set +- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, speech-to-text key and base URL fallbacks when no value is saved at the dictation or voice-mode STT site +- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, text-to-speech key and base URL fallbacks when no value is saved at the voice-mode TTS site - `PASEO_VOICE_LLM_PROVIDER`, override voice LLM provider (`claude`, `codex`, `opencode`) - `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, override voice provider selection (`local` or `openai`) - `PASEO_LOCAL_MODELS_DIR`, control local model directory diff --git a/public-docs/configuration.zh-CN.md b/public-docs/configuration.zh-CN.md index 4f904db8d8..532b763907 100644 --- a/public-docs/configuration.zh-CN.md +++ b/public-docs/configuration.zh-CN.md @@ -72,7 +72,7 @@ paseo daemon restart 当你删除 `daemon.relay.enabled` 时,新的主目录会保持中继关闭。如果 daemon 启动时其配置就已经省略了这个字段,它会为了兼容而保持旧版的中继开启行为。编辑较旧的配置时,请显式设置 `daemon.relay.enabled`。 -监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。 +空闲运行时清理、监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。 部署环境变量和旧版进程管理器参数仍然具有最终决定权。当某个启动覆盖项阻止了文件中已更改的设置生效时,重新加载会在 `overrideControlledPaths` 下报告它。这包括启动设置,例如监听地址、密码、中继端点和 TLS、服务代理和 Web UI 设置、日志、语音合成与识别以及语音配置。主机名和 CORS 来源等列表设置仍然在各个来源之间追加,因此 `config.json` 中的值继续生效。要让文件中的值具有决定权,请停止该部署并在不带覆盖项的情况下重新启动。 @@ -82,6 +82,16 @@ Agent Provider,包括 Paseo 自带的一等 Provider 和你在 `agents.provide 心智模型见 [Provider](/docs/providers),Paseo 可以启动的全部 Agent 见[支持的 Provider](/docs/supported-providers)。要让 Claude 指向兼容 Anthropic 的端点(Z.AI、Alibaba/Qwen)、使用多个配置、自定义二进制文件、ACP Agent,以及了解 `additionalModels` 的合并行为,见[自定义 Provider](/docs/custom-providers)。完整的字段参考在 GitHub 上的 [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md)。 +## 空闲运行时 {#idle-runtimes} + +当 daemon 的基础条件允许清理,且 Provider 已实现的安全检查也允许时,Paseo 会在可恢复的 Agent 空闲一小时后释放其进程。你的下一条提示词会恢复同一个会话。要为整个主机禁用此功能,请在 **设置 → 你的主机 → Agents** 中关闭 **释放空闲 Agent**,或设置: + +```json +{ "agents": { "idleRuntimeCleanup": false } } +``` + +此设置默认为 `true`。在设置中更改后立即生效;配置文件中的更改在重新加载配置后生效。重新启用会保留现有的空闲计时。没有单个 Agent 的设置。不提供运行时安全检查的 Provider 使用相同的 daemon 基础条件和一小时空闲期。活动中的工作、后代 Agent 和待发送的完成通知都会阻止清理。ACP 适配器还会在 ACP 终端仍运行时保留运行时,即使终端没有输出;已退出的终端不会阻止清理。此检查不检查 Provider 内部启动的进程。 + ## worktree {#worktrees} 新的 worktree 默认创建在 `$PASEO_HOME/worktrees` 下。要把新的 worktree 放到其他位置,请设置 `worktrees.root`: diff --git a/public-docs/hub/configuration/hub-yml.md b/public-docs/hub/configuration/hub-yml.md index 91b71782d9..26578854a8 100644 --- a/public-docs/hub/configuration/hub-yml.md +++ b/public-docs/hub/configuration/hub-yml.md @@ -141,14 +141,15 @@ steps: Use one of these semantic event names for new GitHub workflows: -| `on` | Matches | -| ------------------------------------- | ------------------------------------------------------------------------- | -| `github.issue_created` | An `issues` delivery whose action is `opened`. | -| `github.pull_request_created` | A `pull_request` delivery whose action is `opened`. | -| `github.issue_comment_created` | An `issue_comment` delivery whose action is `created`, on an issue. | -| `github.pull_request_comment_created` | An `issue_comment` delivery whose action is `created`, on a pull request. | -| `github.issue_label_added` | An `issues` delivery whose action is `labeled`. | -| `github.pull_request_label_added` | A `pull_request` delivery whose action is `labeled`. | +| `on` | Matches | +| -------------------------------------- | ------------------------------------------------------------------------- | +| `github.issue_created` | An `issues` delivery whose action is `opened`. | +| `github.pull_request_created` | A `pull_request` delivery whose action is `opened`. | +| `github.pull_request_ready_for_review` | A draft pull request is marked ready for review. | +| `github.issue_comment_created` | An `issue_comment` delivery whose action is `created`, on an issue. | +| `github.pull_request_comment_created` | An `issue_comment` delivery whose action is `created`, on a pull request. | +| `github.issue_label_added` | An `issues` delivery whose action is `labeled`. | +| `github.pull_request_label_added` | A `pull_request` delivery whose action is `labeled`. | Existing configurations may continue to use `github.issues`, `github.issue_comment`, `github.pull_request_review`, `github.pull_request_review_comment`, and `github.push`. These legacy events retain their existing behavior. diff --git a/public-docs/hub/configuration/hub-yml.zh-CN.md b/public-docs/hub/configuration/hub-yml.zh-CN.md index 13f1ea5478..db8ddd38af 100644 --- a/public-docs/hub/configuration/hub-yml.zh-CN.md +++ b/public-docs/hub/configuration/hub-yml.zh-CN.md @@ -139,14 +139,15 @@ steps: 新的 GitHub workflow 请使用以下语义化事件名之一: -| `on` | 匹配 | -| ------------------------------------- | ----------------------------------------------------------------- | -| `github.issue_created` | action 为 `opened` 的 `issues` 投递。 | -| `github.pull_request_created` | action 为 `opened` 的 `pull_request` 投递。 | -| `github.issue_comment_created` | 在 issue 上、action 为 `created` 的 `issue_comment` 投递。 | -| `github.pull_request_comment_created` | 在 Pull Request 上、action 为 `created` 的 `issue_comment` 投递。 | -| `github.issue_label_added` | action 为 `labeled` 的 `issues` 投递。 | -| `github.pull_request_label_added` | action 为 `labeled` 的 `pull_request` 投递。 | +| `on` | 匹配 | +| -------------------------------------- | ----------------------------------------------------------------- | +| `github.issue_created` | action 为 `opened` 的 `issues` 投递。 | +| `github.pull_request_created` | action 为 `opened` 的 `pull_request` 投递。 | +| `github.pull_request_ready_for_review` | 草稿 PR 被标记为可审查。 | +| `github.issue_comment_created` | 在 issue 上、action 为 `created` 的 `issue_comment` 投递。 | +| `github.pull_request_comment_created` | 在 Pull Request 上、action 为 `created` 的 `issue_comment` 投递。 | +| `github.issue_label_added` | action 为 `labeled` 的 `issues` 投递。 | +| `github.pull_request_label_added` | action 为 `labeled` 的 `pull_request` 投递。 | 已有的配置可以继续使用 `github.issues`、`github.issue_comment`、`github.pull_request_review`、`github.pull_request_review_comment` 和 `github.push`。这些旧版事件保持原有的行为。 diff --git a/public-docs/hub/self-hosting/github-app.md b/public-docs/hub/self-hosting/github-app.md index 740ce9da5c..ad47a03f42 100644 --- a/public-docs/hub/self-hosting/github-app.md +++ b/public-docs/hub/self-hosting/github-app.md @@ -31,7 +31,7 @@ Keep GitHub's SSL verification enabled. ## Subscribe to GitHub events -Under **Subscribe to events**, select **Issue comment**, **Issues**, **Pull requests**, **Pull request review**, **Pull request review comment**, and **Push**. **Pull requests** is required for `github.pull_request_created` and `github.pull_request_label_added`. +Under **Subscribe to events**, select **Issue comment**, **Issues**, **Pull requests**, **Pull request review**, **Pull request review comment**, and **Push**. **Pull requests** is required for `github.pull_request_created`, `github.pull_request_ready_for_review`, and `github.pull_request_label_added`. ## Connect repositories diff --git a/public-docs/hub/self-hosting/github-app.zh-CN.md b/public-docs/hub/self-hosting/github-app.zh-CN.md index 75c0ac67a4..ef892fe815 100644 --- a/public-docs/hub/self-hosting/github-app.zh-CN.md +++ b/public-docs/hub/self-hosting/github-app.zh-CN.md @@ -29,7 +29,7 @@ GitHub 会使用以下 Hub URL: ## 订阅 GitHub 事件 {#subscribe-to-github-events} -在 **Subscribe to events** 下,选择 **Issue comment**、**Issues**、**Pull requests**、**Pull request review**、**Pull request review comment** 和 **Push**。`github.pull_request_created` 和 `github.pull_request_label_added` 需要 **Pull requests**。 +在 **Subscribe to events** 下,选择 **Issue comment**、**Issues**、**Pull requests**、**Pull request review**、**Pull request review comment** 和 **Push**。`github.pull_request_created`、`github.pull_request_ready_for_review` 和 `github.pull_request_label_added` 需要 **Pull requests**。 ## 连接仓库 {#connect-repositories} diff --git a/public-docs/hub/triggers/github.md b/public-docs/hub/triggers/github.md index 92be54bfe1..3585a2be3a 100644 --- a/public-docs/hub/triggers/github.md +++ b/public-docs/hub/triggers/github.md @@ -74,6 +74,10 @@ steps: `github.pull_request_created` fires only when someone opens a pull request. +To wait until a draft is finished, change `on` in the example above to `github.pull_request_ready_for_review`. It fires only when a draft is marked ready, with the user making that transition checked against `from_users`. Opening a pull request, later pushes, and converting it back to draft do not fire this event. + +Pull request webhook context includes `github.pull_request.draft` and `github.pull_request.head_sha` as delivered by GitHub. `github.pull_request` is `null` for other events, including conversation comments on a pull request. Fetch the live pull request with `gh pr view` before reviewing and again before posting. Finish without posting if it is now draft or its head differs from the reviewed SHA. Converting back to draft does not cancel a Hub run already in progress; the workflow owns these live checks. + ## Respond to new comments `.paseo/workflows/respond-to-issue-comment.yml`: @@ -177,14 +181,15 @@ steps: ## Choose the event -| `on` | Fires when | -| ------------------------------------- | ---------------------------------------------------- | -| `github.issue_created` | An issue is opened. | -| `github.pull_request_created` | A pull request is opened. | -| `github.issue_comment_created` | A comment is created on an issue. | -| `github.pull_request_comment_created` | A conversation comment is created on a pull request. | -| `github.issue_label_added` | A label is added to an issue. | -| `github.pull_request_label_added` | A label is added to a pull request. | +| `on` | Fires when | +| -------------------------------------- | ---------------------------------------------------- | +| `github.issue_created` | An issue is opened. | +| `github.pull_request_created` | A pull request is opened. | +| `github.pull_request_ready_for_review` | A draft pull request is marked ready for review. | +| `github.issue_comment_created` | A comment is created on an issue. | +| `github.pull_request_comment_created` | A conversation comment is created on a pull request. | +| `github.issue_label_added` | A label is added to an issue. | +| `github.pull_request_label_added` | A label is added to a pull request. | Choose `github.issue_comment_created` for a comment on an issue and `github.pull_request_comment_created` for a conversation comment on a pull request. GitHub delivers both as issue comments; Hub separates them for you. A comment on a changed line is a diff comment, covered by the legacy `github.pull_request_review_comment` event. diff --git a/public-docs/hub/triggers/github.zh-CN.md b/public-docs/hub/triggers/github.zh-CN.md index 3f599873f6..71c033129f 100644 --- a/public-docs/hub/triggers/github.zh-CN.md +++ b/public-docs/hub/triggers/github.zh-CN.md @@ -72,6 +72,10 @@ steps: `github.pull_request_created` 只在有人创建 PR 时触发。 +若要等草稿完成后再开始审查,将上例的 `on` 改为 `github.pull_request_ready_for_review`。只有将草稿 PR 标记为可审查时才会触发;创建 PR、后续 push 和转回草稿都不会触发。`from_users` 匹配执行此操作的用户。 + +PR webhook 的上下文通过 `github.pull_request.draft` 和 `github.pull_request.head_sha` 提供投递时的草稿状态和 head commit。其他事件(包括 PR 会话评论)的此字段为 `null`。审查前及发布结果前,通过 `gh pr view` 重新获取 PR;如果它已成为草稿或 head 与已审查的 SHA 不同,就结束运行而不发布结果。转回草稿不会取消已启动的 Hub 运行,workflow 应自行检查状态。 + ## 回复新评论 {#respond-to-new-comments} `.paseo/workflows/respond-to-issue-comment.yml`: @@ -175,14 +179,15 @@ steps: ## 选择事件 {#choose-the-event} -| `on` | 触发时机 | -| ------------------------------------- | ---------------------- | -| `github.issue_created` | 创建 issue。 | -| `github.pull_request_created` | 创建 PR。 | -| `github.issue_comment_created` | 在 issue 上创建评论。 | -| `github.pull_request_comment_created` | 在 PR 上创建对话评论。 | -| `github.issue_label_added` | 为 issue 添加标签。 | -| `github.pull_request_label_added` | 为 PR 添加标签。 | +| `on` | 触发时机 | +| -------------------------------------- | ------------------------ | +| `github.issue_created` | 创建 issue。 | +| `github.pull_request_created` | 创建 PR。 | +| `github.pull_request_ready_for_review` | 草稿 PR 被标记为可审查。 | +| `github.issue_comment_created` | 在 issue 上创建评论。 | +| `github.pull_request_comment_created` | 在 PR 上创建对话评论。 | +| `github.issue_label_added` | 为 issue 添加标签。 | +| `github.pull_request_label_added` | 为 PR 添加标签。 | issue 上的评论选择 `github.issue_comment_created`,PR 上的对话评论选择 `github.pull_request_comment_created`。GitHub 把两者都作为 issue 评论投递;Hub 会替你区分它们。针对某一行改动的评论属于 diff 评论,由旧的 `github.pull_request_review_comment` 事件处理。 diff --git a/public-docs/hub/triggers/index.md b/public-docs/hub/triggers/index.md index 164d1739eb..002ad5c20e 100644 --- a/public-docs/hub/triggers/index.md +++ b/public-docs/hub/triggers/index.md @@ -87,17 +87,18 @@ Hub includes an `executionId` in each prompt. When using a continuing agent, pas ## Events -| `on` | Fires when | -| ------------------------------------- | ---------------------------------------------------- | -| `github.issue_created` | An issue is opened. | -| `github.pull_request_created` | A pull request is opened. | -| `github.issue_comment_created` | A comment is created on an issue. | -| `github.pull_request_comment_created` | A conversation comment is created on a pull request. | -| `github.issue_label_added` | A label is added to an issue. | -| `github.pull_request_label_added` | A label is added to a pull request. | -| `slack.mention` | The bot is mentioned in a channel. | -| `discord.mention` | The bot is mentioned in a guild. | -| `manual.run` | A run started from the API. | +| `on` | Fires when | +| -------------------------------------- | ---------------------------------------------------- | +| `github.issue_created` | An issue is opened. | +| `github.pull_request_created` | A pull request is opened. | +| `github.pull_request_ready_for_review` | A draft pull request is marked ready for review. | +| `github.issue_comment_created` | A comment is created on an issue. | +| `github.pull_request_comment_created` | A conversation comment is created on a pull request. | +| `github.issue_label_added` | A label is added to an issue. | +| `github.pull_request_label_added` | A label is added to a pull request. | +| `slack.mention` | The bot is mentioned in a channel. | +| `discord.mention` | The bot is mentioned in a guild. | +| `manual.run` | A run started from the API. | New GitHub workflows should use a semantic event. The five legacy events remain compatible: `github.issues`, `github.issue_comment`, `github.pull_request_review`, `github.pull_request_review_comment`, and `github.push`. See [GitHub triggers](/docs/hub/triggers/github) for complete workflows and when to use each event. diff --git a/public-docs/hub/triggers/index.zh-CN.md b/public-docs/hub/triggers/index.zh-CN.md index 9835633efd..549614ca1a 100644 --- a/public-docs/hub/triggers/index.zh-CN.md +++ b/public-docs/hub/triggers/index.zh-CN.md @@ -85,17 +85,18 @@ Hub 在每段提示词中都包含一个 `executionId`。使用持续的 Agent ## 事件 {#events} -| `on` | 触发时机 | -| ------------------------------------- | ------------------------------- | -| `github.issue_created` | 有 issue 被创建。 | -| `github.pull_request_created` | 有 Pull Request 被创建。 | -| `github.issue_comment_created` | issue 上新增了评论。 | -| `github.pull_request_comment_created` | Pull Request 上新增了对话评论。 | -| `github.issue_label_added` | issue 被添加了标签。 | -| `github.pull_request_label_added` | Pull Request 被添加了标签。 | -| `slack.mention` | 机器人在频道中被提及。 | -| `discord.mention` | 机器人在服务器中被提及。 | -| `manual.run` | 通过 API 发起了一次运行。 | +| `on` | 触发时机 | +| -------------------------------------- | ------------------------------- | +| `github.issue_created` | 有 issue 被创建。 | +| `github.pull_request_created` | 有 Pull Request 被创建。 | +| `github.pull_request_ready_for_review` | 草稿 PR 被标记为可审查。 | +| `github.issue_comment_created` | issue 上新增了评论。 | +| `github.pull_request_comment_created` | Pull Request 上新增了对话评论。 | +| `github.issue_label_added` | issue 被添加了标签。 | +| `github.pull_request_label_added` | Pull Request 被添加了标签。 | +| `slack.mention` | 机器人在频道中被提及。 | +| `discord.mention` | 机器人在服务器中被提及。 | +| `manual.run` | 通过 API 发起了一次运行。 | 新的 GitHub workflow 应使用语义化事件。五个旧版事件仍然兼容:`github.issues`、`github.issue_comment`、`github.pull_request_review`、`github.pull_request_review_comment` 和 `github.push`。完整的 workflow 以及何时使用哪个事件,见 [GitHub 触发器](/docs/hub/triggers/github)。 diff --git a/public-docs/plugins/hooks.md b/public-docs/plugins/hooks.md new file mode 100644 index 0000000000..b535821e62 --- /dev/null +++ b/public-docs/plugins/hooks.md @@ -0,0 +1,659 @@ +--- +title: Plugin hooks +description: Register hooks to change requests or observe agent and workspace operations. +nav: Hooks +order: 48 +category: Plugins +--- + +# Plugin hooks + +Use `server.addHook(name, handler)` in `index.server.ts`. Hooks run while the plugin is enabled, even with no app connected. `addHook` is available from Paseo 0.14; set `requirements.paseo` to the release you build against. + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +export default function contribute(server: PluginServerContext) { + const remove = server.addHook("agent.open.before", async ({ request }) => ({ + ...request, + env: { ...request.env, REGION: "eu" }, + })); + return remove; +} +``` + +## Naming and timing + +Names follow `subject.operation.position`: the subject, the operation, then `before` or `after`. The name identifies a lifecycle position. Whether Paseo waits is a per-hook property shown in the table below. `workspace` covers every workspace; `worktree` covers only git worktrees. + +- Waiting hooks finish before Paseo continues. Throwing, returning invalid data, or exceeding 30 seconds fails the operation with `Plugin failed: …`. Request-changing hooks receive `{ request }` and may return a complete replacement request or `undefined`. +- Notifications receive the event object and return `Promise`. Paseo does not wait; errors are logged against the plugin and the operation continues. `agent.turn.before` and `agent.permission.before` are notifications fired when a turn starts or a permission becomes pending. + +All handlers must be async. Turn completion includes success, failure, and cancellation; inspect `outcome`. Permission completion includes every resolution; inspect `resolution`. + +Creating an agent happens once, and `agent.create.before` changes its saved configuration. Opening starts the provider process: the first run, resume after a daemon restart, reading history, importing an outside session, or reload. `agent.open.before` contributes launch-only environment, tools, and MCP servers each time. Creating opens the agent; opening an existing agent does not create it; closing ends the running instance. Opening a tab in the app is a separate action. + +`worktree.create.after` runs before the workspace is registered. It precedes `workspace.create.after`, and both precede the first agent's `agent.create.before` / `agent.open.before`. + +### Return early from a waiting hook + +Start detached work and return when the operation should continue immediately: + +```ts +server.addHook("worktree.create.after", async ({ worktree }) => { + void warmCaches(worktree.path); +}); +``` + +Paseo only sees the returned promise, so detached work gets no error reporting and no cancellation `signal`. + +## Hooks + +| Hook | Waits | Payload | Handler may change | +| ------------------------- | ----- | -------------------------------------- | ---------------------------------------------- | +| `workspace.create.before` | yes | `{ request }` | Entire create request | +| `workspace.create.after` | no | `{ workspace }` | | +| `workspace.archive.after` | no | `{ workspace }` | | +| `worktree.create.before` | yes | `{ request }` | Creation options and destination | +| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation | +| `agent.create.before` | yes | `{ request: { config, env? } }` | Saved config except `cwd`; `env` | +| `agent.create.after` | no | `{ agent }` | | +| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` | +| `agent.close.after` | no | `{ agent }` | | +| `agent.archive.after` | no | `{ agent, archivedAt }` | | +| `agent.turn.before` | no | `{ agent, turnId }` | | +| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | | +| `agent.permission.before` | no | `{ agent, request }` | | +| `agent.permission.after` | no | `{ agent, requestId, resolution }` | | + +## Shared payloads + +```ts +interface PluginHookAgent { + id: string; + workspaceId: string | null; + parentAgentId: string | null; + provider: string; + cwd: string; + title: string | null; +} + +interface PluginHookWorkspace { + id: string; + projectId: string; + cwd: string; + name: string | null; + archivedAt: string | null; +} + +type PluginTurnOutcome = + | { kind: "completed" } + | { kind: "failed"; error: { message: string; code?: string } } + | { kind: "canceled"; reason: string }; +``` + +| Field | Shape / meaning | +| ------------ | -------------------------------------------------------------------------------------- | +| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens | +| `timeline` | Deprecated, always `[]`; fetch history with the SDK. | +| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` | +| `resolution` | SDK `AgentPermissionResponse` | +| `archivedAt` | Timestamp string | + +## workspace.create.before + +Receives `{ request }` with `source`, optional `title`, and `firstAgentContext`. You may replace the entire explicit create request; a throw, invalid result, or timeout fails creation. Existing workspaces and directory lookup/import are unaffected. + +### Choose workspace isolation + +```ts +server.addHook("workspace.create.before", async ({ request }) => { + if (request.source.kind !== "directory") { + return request; + } + + return { + ...request, + source: { + kind: "worktree", + cwd: request.source.path, + action: "branch-off", + }, + }; +}); +``` + +**Result:** an explicit directory creation request becomes a worktree request. Existing workspaces +and directory lookup/import operations are unaffected. + +## workspace.create.after + +Fires after the workspace record is created and its directory is available. Receives `{ workspace }`. + +```ts +server.addHook("workspace.create.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## workspace.archive.after + +Fires after archive state is saved. Receives `{ workspace }`; worktree cleanup can still be pending. + +```ts +server.addHook("workspace.archive.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## worktree.create.before {#worktreecreatebefore} + +Choose the location and creation options for a new worktree. Paseo awaits this hook before `git worktree add`, on creation from the app, CLI, MCP, and agent creation. Receives `{ request }`; return a complete replacement request or `undefined` to keep it. Throwing, invalid returned data, or a 30-second timeout aborts creation with the plugin and hook named in the error. + +```ts +import { basename, dirname, join } from "node:path"; + +server.addHook("worktree.create.before", async ({ request }) => ({ + ...request, + path: join( + dirname(request.sourceCheckout), + `${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`, + ), +})); +``` + +For source `/home/me/src/paseo` and branch `feature/x`, this creates `/home/me/src/paseo.feature-x`. See the complete [worktree-placement example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement). + +| Request field | Meaning | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `kind` | `branch-off`, `checkout-branch`, `checkout-change-request`, or `checkout-github-pr` | +| `branch` | Requested local branch name | +| `baseRef` | Starting ref for branch-off; comparison base for change requests; absent for `checkout-branch`. To branch off a ref, return `kind: "branch-off"`. | +| `slug` | Normalized worktree name | +| `path` | Absolute destination; initially the default location with any existing-path suffix resolved | +| `repoRoot` | Read-only main repository root | +| `sourceCheckout` | Read-only source checkout root, including when creation starts from another worktree | +| `forge`, `changeRequestNumber`, `headRef` | Change-request checkout identity; GitHub legacy checkouts use `githubPrNumber` instead of `forge` and `changeRequestNumber` | +| `headRepositoryOwner`, `headRepository` | Optional source repository facts; `headRepository` is present only on change-request checkouts | +| `checkoutRefs` | Optional fetch sources: `{ remoteName?: string, remoteRef: string }[]` | +| `pushRemoteUrl`, `trackOriginHead` | Optional push destination and tracking policy | + +Once `path` is returned, `slug` only feeds branch-off naming; the CLI looks up worktrees by the basename of their path. + +Every field except `repoRoot` and `sourceCheckout` is transformable. Return all required facts for the chosen `kind`. Paseo resolves the Git source plan after this hook, so handlers can change the branch and base before fetching refs, while Git can still choose a unique final branch name afterward. + +Paseo resolves symlinked parents and validates the returned path. It must be absolute, must not exist, and must not be the filesystem root, your home directory, the repository root, inside a `.git` directory, or an ancestor of the repository. An existing destination fails creation; Paseo never adds a suffix to a plugin-chosen path. The plugin must leave directory creation to Paseo. + +Ordering: `worktree.create.before` → `git worktree add` → `worktree.create.after` → workspace registration. Restoring a missing worktree recreates it at its recorded path without running either worktree creation hook. + +## worktree.create.after + +Paseo waits for this hook after a new git worktree exists and before workspace registration, setup, terminals, or agents. Receives `{ worktree: { path, branch, sourceCheckout } }`: the absolute worktree path, branch name, and source checkout directory. There is no workspace id yet. Return nothing; write files directly into the worktree. Throwing or exceeding 30 seconds aborts creation and removes the new worktree. + +This covers branch-off, branch checkout, and PR checkout creation. Restoring a missing archived worktree uses a separate recovery path and does not fire this hook. + +```ts +import { copyFile } from "node:fs/promises"; + +server.addHook("worktree.create.after", async ({ worktree }) => { + await copyFile(`${worktree.sourceCheckout}/.env`, `${worktree.path}/.env`); +}); +``` + +## agent.create.before + +Receives `{ request: { config, env? } }`. Config changes are saved and remain after the plugin is disabled or removed; `env` applies to the initial launch and is not saved. You cannot change `config.cwd`. A throw, invalid result, or timeout fails creation. + +**`agent.create.before.config`** uses `AgentSessionConfig`: + +| Fields | Constraint | +| --------------------------------------------- | -------------------------------------------------------------------------- | +| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options | +| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections | +| `title`, `systemPrompt` | Agent configuration | +| `providerOptions` | Opaque provider-specific options | +| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | +| `cwd` | Cannot change | + +### Change saved configuration + +Add this callback inside `contribute(server)` to set the saved options for new Codex agents: + +```ts +server.addHook("agent.create.before", async ({ request }) => { + if (request.config.provider !== "codex") { + return request; + } + + return { + ...request, + config: { + ...request.config, + providerOptions: { + ...request.config.providerOptions, + sandbox_mode: "workspace-write", + approval_policy: "on-request", + }, + }, + }; +}); +``` + +| Input | Result | +| ------------------------------------------- | ----------------------- | +| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | +| `providerOptions.web_search: "disabled"` | Preserved by the spread | + +The selected provider validates `providerOptions`. Explicit Codex sandbox and approval options +override its mode presets. + +## agent.create.after + +Ordinary creation finishes. Receives `{ agent }`; import and resume do not fire it. + +```ts +server.addHook("agent.create.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.open.before + +Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for this launch. All other fields are read-only. A throw, invalid result, or timeout fails the open. + +**`agent.open.before` request example:** + +```json +{ + "agentId": "agent-123", + "workspaceId": "workspace-456", + "provider": "codex", + "cwd": "/projects/shop", + "title": "Workspace notes", + "labels": { "notes/enabled": "true" }, + "tools": [], + "mcpServers": {}, + "reason": "resume", + "purpose": "interactive", + "env": { "COMPANY_ENV": "development" } +} +``` + +| Field | Values | +| ------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `workspaceId` | String or `null` | +| `title` | Read-only string or `null`; creation title on create, stored title on resume. | +| `labels` | Read-only string map; creation labels on create, stored labels on resume. | +| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). | +| `mcpServers` | Editable launch-only server map, local to this plugin. See [Inject MCP servers](#inject-mcp-servers). | +| `reason` | `create`, `resume`, `refresh`, `import` | +| `purpose` | `interactive`, `history` | +| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | + +### Inject environment variables on every session opening + +```ts +server.addHook("agent.open.before", async ({ request }) => { + return { + ...request, + env: { + ...request.env, + COMPANY_ENV: "development", + }, + }; +}); +``` + +Session-open overrides are re-derived on every open. `agent.create.before.env` applies only to the +initial launch and is not saved in the agent's configuration. + +### Inject MCP servers + +Use `agent.create.before.config.mcpServers` when the server should become part of the agent's saved +configuration. For a plugin-owned server, add this callback inside `contribute(server)`, replacing +the placeholder URL with your MCP endpoint: + +```ts +server.addHook("agent.open.before", async ({ request }) => { + if (request.provider !== "codex") return; + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`request.mcpServers` uses the same server configurations as `AgentSessionConfig.mcpServers`. +The map starts empty for each plugin and accumulates across that plugin's own callbacks. It never +contains the agent's saved servers or another plugin's servers. Replace the map to add, replace, +or remove this plugin's entries; returning `undefined` preserves it. Paseo merges the plugins' +final maps in plugin-ID order. + +A name colliding with a saved server, Paseo's internal `paseo` server, or another plugin's server +fails the session open and names the server and both sources. A provider without MCP support fails +with `does not support MCP servers`, naming the plugin. Opens with `purpose: "history"` ignore +these servers. Install, disable, and reload changes reach existing agents through the +[session reopen behavior](#attach-tools-to-agents). + +### Attach tools to agents + +Define a tool in `index.server.ts` and attach it in `server.addHook("agent.open.before", async handler)`. +This example gives `append_workspace_note` only to agents with the label `notes/enabled: "true"`: + +```ts +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.open.before", async ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} +``` + +Set the label when creating an agent with the SDK: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +Select agents using any of the session-open request fields: + +| Select by | Condition in the callback | +| ---------------- | -------------------------------------------- | +| Label | `request.labels["notes/enabled"] === "true"` | +| Provider | `request.provider === "codex"` | +| Workspace | `request.workspaceId === "workspace-456"` | +| Individual agent | `request.agentId === chosenAgentId` | + +`request.tools` starts empty for each plugin. Each callback sees the tools returned by earlier +callbacks in that plugin; replace the array to add or remove tools. Returning `undefined` preserves +the request. Tools are ignored for `purpose: "history"`. + +Import `defineAgentTool`, `AgentToolDefinition`, `AgentToolContext`, and `AgentToolResult` from +`@getpaseo/plugin/server`. The helper infers the validated input from your Zod schema: + +```ts +declare function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition; + +interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} +``` + +| Execution context | Value | +| ----------------- | --------------------------------------------------------------------------------------------------- | +| `agent` | Calling agent as a `PluginHookAgent`, including `id`, `workspaceId`, `provider`, `cwd`, and `title` | +| `paseo` | SDK connected to this daemon | +| `signal` | Abort signal for this tool call; pass it to file operations or external requests | + +`execute` runs in the plugin process. Closures and the original Zod schema stay there; only tool +metadata and call inputs/results cross the process boundary. Paseo validates input before calling +`execute`. Invalid input returns schema issues to the agent. A thrown error becomes +`{ isError: true, text: message }` and the plugin keeps running. Canceling the call or closing its +transport aborts `signal` and discards the result. Tool calls have a 10 minute ceiling, separate +from the 30 second hook timeout. Closing or reopening an agent session drops its old definitions. + +Return model-visible text in `text`. `structuredContent` must be JSON-serializable; when `text` is +omitted, Paseo formats that value as text for the model. Set `isError: true` to report a tool error. + +Paseo delivers attached tools through its agent tool transport, using MCP or native registration +as the provider supports. The same definition and callback work with either. Attached tools obey +the [same gates as Paseo tools](/docs/mcp#limit-paseo-tools-by-provider). Only that agent sees them. + +Names must match `/^[a-z][a-z0-9_]*$/` and be at most 64 characters. A name colliding with a Paseo +tool or another plugin's tool on the same agent fails the session open and names both owners. +A duplicate name within one plugin also fails the session open. Agents the callback does not +target are unaffected. + +| Change | When the agent receives it | +| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Start, stop, or reload a plugin declaring `agent.open.before` | Open sessions become stale. Each reopens before its next idle prompt; a running turn continues without interruption. The next prompt after that reopen reuses the session. | +| Change which agents the callback targets | Run `paseo plugin reload agent-tools` after editing this example, then send the agent a prompt when it is idle. | +| Apply immediately | Call `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`. `requestId` is optional. This reopens the provider session, reruns hooks with `reason: "refresh"`, and interrupts a running turn. | +| Read the current snapshot | Call `await paseo.agents.ref(id).refresh()`. This reads state without reopening the session. | + +If an automatic reopen fails, the triggering prompt fails with that error. See the +[complete agent-tools example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools). + +## agent.close.after + +A live agent runtime closes. Receives `{ agent }`. + +```ts +server.addHook("agent.close.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.archive.after + +Archive state is saved. Receives `{ agent, archivedAt }`; runtime cleanup can still be pending. + +```ts +server.addHook("agent.archive.after", async ({ agent, archivedAt }) => { + console.log(agent, archivedAt); +}); +``` + +## agent.turn.before + +A live turn starts. Receives `{ agent, turnId }`. History replay does not fire it. + +```ts +server.addHook("agent.turn.before", async ({ agent, turnId }) => { + console.log(agent, turnId); +}); +``` + +## agent.turn.after + +A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome, timeline }`. `outcome.kind` is `completed`, `failed`, or `canceled`. `timeline` is deprecated and always empty; fetch history through the SDK as below. + +### Send a follow-up when a turn ends + +Copy [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) +into your plugin. The helper imports types from `@getpaseo/protocol/agent-types`; add +`@getpaseo/protocol` at the version matching your daemon to your development dependencies +and install them before loading the plugin. `latestOutputText` joins text chunks after the latest user message. + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { latestOutputText } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.turn.after", async (event, context) => { + if (event.outcome.kind === "canceled") { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); + const text = latestOutputText(page.entries.map(({ item }) => item)); + if (/out of credits/i.test(text)) { + await agent.send("Try again."); + } + }); + + return () => {}; +} +``` + +```text +Turn ends: "out of credits" + → plugin sends "Try again." + → a new turn starts +``` + +The handler fetches only the last 20 timeline items. Output before that page is not inspected. +Use `cursor` and `direction` to page further when your plugin needs more history. + +This sends a new message with the existing SDK. Persistent matches keep sending follow-ups; +add limits or delays in your plugin when needed. Attachments and tool effects are not replayed. + +## agent.permission.before + +A permission or question becomes pending. Receives `{ agent, request }`; `request` is an `AgentPermissionRequest`. Send the answer through the SDK. + +### Answer a permission request + +Using `shellCommand` from the same [helper file](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts): + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { shellCommand } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.permission.before", async (event, context) => { + const command = shellCommand(event.request); + if (command === null) { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + if (/\brm\s+-rf\b/.test(command)) { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "deny", message: "Recursive deletion is blocked." }, + }); + return; + } + + if (command.trim() === "git status") { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "allow" }, + }); + } + }); + + return () => {}; +} +``` + +| Request | Result | +| ------------------------ | ------------------------- | +| `rm -rf build` | Declined | +| `git status` | Approved | +| Other command or request | Left pending for the user | +| Already-resolved request | SDK response fails | + +The regex is an example policy, not a shell parser. Permission requests can also be questions, +plans, and mode changes; requesting permission does not end the turn. + +## agent.permission.after + +A pending request is answered or cleared. Receives `{ agent, requestId, resolution }`; `resolution` is an `AgentPermissionResponse`. + +```ts +server.addHook("agent.permission.after", async ({ agent, requestId, resolution }) => { + console.log(agent, requestId, resolution); +}); +``` + +Agent hooks exclude the daemon's private disposable helpers. Public background workspaces and their ordinary agents use the same hooks. + +## Ordering and returned values + +```text +Creation request + → agent.create.before hooks (plugin-ID order; registration order within each plugin) + → resolve defaults and validate provider configuration + → derive launch configuration with Paseo runtime tools and daemon prompt + → agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers) + → set PASEO_AGENT_ID and PASEO_AGENT_CWD + → open provider session and save agent configuration +``` + +| Callback returns | Next callback receives | +| ------------------------------------------------------- | --------------------------------------------------- | +| `{ ...request, env: { ...request.env, REGION: "eu" } }` | Prior request with `REGION` added/replaced | +| `{ ...request, env: { REGION: "eu" } }` | Prior request with the entire override map replaced | +| `undefined` | Unchanged request | +| Throws or returns invalid data | Operation fails; later callbacks do not run | + +There is no automatic deep merge. `env` accumulates across plugins; `tools` and `mcpServers` +accumulate only across callbacks within the same plugin. Returning a replacement map or array +replaces that scope's prior value. + +## Context and cleanup + +| Contract | Behavior | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `context.paseo` | Existing SDK connected to this daemon | +| `context.signal` | Aborted on invocation timeout or plugin stop; pass to external requests | +| Input data | Detached snapshot; change state through returned requests or SDK commands | +| Registration result | Idempotent remover, e.g. `const remove = server.addHook(...); remove();` | +| Reload, disable, removal, shutdown | Remaining registrations removed | +| Unknown hook name | Registration fails | +| Hook timeout | 30 seconds; aborts the signal. A waiting hook fails the pending operation; a notification handler logs an error. | +| Event-handler error | Logged against plugin; original operation continues | +| Event delivery | Live, best effort; no replay, persistence, or automatic retry | +| Event concurrency | Different events may overlap; callback completion order is not guaranteed | + +## Complete examples + +| Plugin | Includes | +| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | Agent, workspace, and worktree hooks; JSON logs with environment values redacted | +| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection | +| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options | +| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | Label-targeted tools and file operations in the plugin process | + +Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. + +## Renamed hooks + +The old registration methods are deprecated for removal after 2027-04-30. Existing plugins keep their behavior. Mixed old and new registrations within a plugin run in registration order. `addHook` accepts only the new names. + +| Old registration | New registration | +| -------------------------------------------------- | --------------------------------------------------------- | +| `server.before("workspace.create", handler)` | `server.addHook("workspace.create.before", asyncHandler)` | +| `server.on("workspace.created", handler)` | `server.addHook("workspace.create.after", asyncHandler)` | +| `server.on("workspace.archived", handler)` | `server.addHook("workspace.archive.after", asyncHandler)` | +| `server.before("agent.create", handler)` | `server.addHook("agent.create.before", asyncHandler)` | +| `server.on("agent.created", handler)` | `server.addHook("agent.create.after", asyncHandler)` | +| `server.before("agent.session_open", handler)` | `server.addHook("agent.open.before", asyncHandler)` | +| `server.on("agent.closed", handler)` | `server.addHook("agent.close.after", asyncHandler)` | +| `server.on("agent.archived", handler)` | `server.addHook("agent.archive.after", asyncHandler)` | +| `server.on("agent.turn_started", handler)` | `server.addHook("agent.turn.before", asyncHandler)` | +| `server.on("agent.turn_ended", handler)` | `server.addHook("agent.turn.after", asyncHandler)` | +| `server.on("agent.permission_requested", handler)` | `server.addHook("agent.permission.before", asyncHandler)` | +| `server.on("agent.permission_resolved", handler)` | `server.addHook("agent.permission.after", asyncHandler)` | diff --git a/public-docs/plugins/hooks.zh-CN.md b/public-docs/plugins/hooks.zh-CN.md new file mode 100644 index 0000000000..eecf370a29 --- /dev/null +++ b/public-docs/plugins/hooks.zh-CN.md @@ -0,0 +1,641 @@ +--- +title: 插件钩子 +description: 为 Agent 和工作区操作注册钩子,修改请求或接收通知。 +nav: 钩子 +--- + +# 插件钩子 {#plugin-hooks} + +在 `index.server.ts` 中使用 `server.addHook(name, handler)`。钩子在插件启用时运行,即使没有连接的应用。`addHook` 从 Paseo 0.14 起可用;将 `requirements.paseo` 设置为你开发插件时使用的版本。 + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; + +export default function contribute(server: PluginServerContext) { + const remove = server.addHook("agent.open.before", async ({ request }) => ({ + ...request, + env: { ...request.env, REGION: "eu" }, + })); + return remove; +} +``` + +## 命名与等待 {#naming-and-timing} + +名称遵循 `subject.operation.position`:主体、操作、`before` 或 `after`。名称标识生命周期中的位置;Paseo 是否等待由每个钩子决定,见下表。`workspace` 包括所有工作区,`worktree` 仅包括 git worktree。 + +- 等待型钩子结束后 Paseo 才继续。抛出错误、返回无效数据或超过 30 秒会让操作失败,错误包含 `Plugin failed: …`。修改请求的钩子收到 `{ request }`,可以返回完整的替换请求或 `undefined`。 +- 通知收到事件对象,返回 `Promise`。Paseo 不等待;错误会记录在插件名下,原操作继续。`agent.turn.before` 和 `agent.permission.before` 分别在轮次开始或权限进入待处理状态时发出通知。 + +所有处理函数都必须为异步函数。轮次完成包括成功、失败和取消,请查看 `outcome`。权限完成包括所有处理结果,请查看 `resolution`。 + +创建 Agent 会发生一次,`agent.create.before` 修改已保存的配置。打开 Agent 指启动 Provider 进程,包括首次运行、daemon 重启后恢复、读取历史、导入外部会话和重新加载。`agent.open.before` 为每次启动提供临时的环境变量、工具和 MCP 服务器。创建会打开 Agent;打开已有 Agent 不会创建它;关闭会结束运行中的实例。在应用中打开标签页不属于此操作。 + +`worktree.create.after` 在工作区注册前运行,随后是 `workspace.create.after`,两者都早于第一个 Agent 的 `agent.create.before` / `agent.open.before`。 + +### 从等待型钩子提前返回 {#return-early-from-a-waiting-hook} + +如果操作应立即继续,可以启动独立任务并返回: + +```ts +server.addHook("worktree.create.after", async ({ worktree }) => { + void warmCaches(worktree.path); +}); +``` + +Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没有取消 `signal`。 + +## 钩子一览 {#hooks} + +| 钩子 | 等待 | 载荷 | 可修改 | +| ------------------------- | ---- | -------------------------------------- | ----------------------------------------- | +| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 | +| `workspace.create.after` | 否 | `{ workspace }` | | +| `workspace.archive.after` | 否 | `{ workspace }` | | +| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 | +| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 | +| `agent.create.before` | 是 | `{ request: { config, env? } }` | 除 `cwd` 外的已保存配置;`env` | +| `agent.create.after` | 否 | `{ agent }` | | +| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` | +| `agent.close.after` | 否 | `{ agent }` | | +| `agent.archive.after` | 否 | `{ agent, archivedAt }` | | +| `agent.turn.before` | 否 | `{ agent, turnId }` | | +| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | | +| `agent.permission.before` | 否 | `{ agent, request }` | | +| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | | + +## 共享载荷 {#shared-payloads} + +```ts +interface PluginHookAgent { + id: string; + workspaceId: string | null; + parentAgentId: string | null; + provider: string; + cwd: string; + title: string | null; +} + +interface PluginHookWorkspace { + id: string; + projectId: string; + cwd: string; + name: string | null; + archivedAt: string | null; +} + +type PluginTurnOutcome = + | { kind: "completed" } + | { kind: "failed"; error: { message: string; code?: string } } + | { kind: "canceled"; reason: string }; +``` + +| 字段 | 结构 / 含义 | +| ------------ | ---------------------------------------------------------------------------------------- | +| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 | +| `timeline` | 已弃用,始终为 `[]`;通过 SDK 获取历史。 | +| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` | +| `resolution` | SDK 的 `AgentPermissionResponse` | +| `archivedAt` | 时间戳字符串 | + +## workspace.create.before {#workspacecreatebefore} + +收到 `{ request }`,其中包含 `source`、可选的 `title` 和 `firstAgentContext`。可替换整个显式创建请求;抛出错误、无效返回或超时会让创建失败。已有工作区和目录查找、导入不受影响。 + +### 选择工作区隔离方式 {#choose-workspace-isolation} + +```ts +server.addHook("workspace.create.before", async ({ request }) => { + if (request.source.kind !== "directory") { + return request; + } + + return { + ...request, + source: { + kind: "worktree", + cwd: request.source.path, + action: "branch-off", + }, + }; +}); +``` + +**结果**:显式的目录创建请求会变成 worktree 请求。已有的工作区以及目录查找/导入操作不受影响。 + +## workspace.create.after {#workspacecreateafter} + +工作区记录创建且目录可用后触发,收到 `{ workspace }`。 + +```ts +server.addHook("workspace.create.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## workspace.archive.after {#workspacearchiveafter} + +归档状态保存后触发,收到 `{ workspace }`;worktree 清理可能尚未完成。 + +```ts +server.addHook("workspace.archive.after", async ({ workspace }) => { + console.log(workspace); +}); +``` + +## worktree.create.before {#worktreecreatebefore} + +为新 worktree 选择路径和创建选项。通过应用、CLI、MCP 或创建 Agent 新建 worktree 时,Paseo 在 `git worktree add` 之前等待此钩子。钩子收到 `{ request }`,返回完整的替换请求,或返回 `undefined` 保持原值。抛出错误、返回无效数据或超过 30 秒会终止创建,错误包含插件和钩子名称。 + +```ts +import { basename, dirname, join } from "node:path"; + +server.addHook("worktree.create.before", async ({ request }) => ({ + ...request, + path: join( + dirname(request.sourceCheckout), + `${basename(request.sourceCheckout)}.${request.branch.replaceAll("/", "-")}`, + ), +})); +``` + +源检出目录为 `/home/me/src/paseo`、分支为 `feature/x` 时,目标为 `/home/me/src/paseo.feature-x`。完整插件见 [worktree-placement 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement)。 + +| 请求字段 | 含义 | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `kind` | `branch-off`、`checkout-branch`、`checkout-change-request` 或 `checkout-github-pr` | +| `branch` | 请求的本地分支名称 | +| `baseRef` | branch-off 的起始引用;变更请求的比较基准;`checkout-branch` 不含此字段。要从引用创建分支,返回 `kind: "branch-off"`。 | +| `slug` | 规范化的 worktree 名称 | +| `path` | 绝对目标路径,初始值为已处理现有路径后缀的默认位置 | +| `repoRoot` | 只读,主仓库根目录 | +| `sourceCheckout` | 只读,源检出根目录,也支持从另一个 worktree 发起创建 | +| `forge`、`changeRequestNumber`、`headRef` | 变更请求检出标识;旧版 GitHub 检出用 `githubPrNumber` 代替 `forge` 和 `changeRequestNumber` | +| `headRepositoryOwner`、`headRepository` | 可选的源仓库信息;`headRepository` 仅用于变更请求检出 | +| `checkoutRefs` | 可选的获取源:`{ remoteName?: string, remoteRef: string }[]` | +| `pushRemoteUrl`、`trackOriginHead` | 可选的推送目标和跟踪策略 | + +返回 `path` 后,`slug` 仅用于 branch-off 分支命名;CLI 使用路径的最后一级目录名查找 worktree。 + +除 `repoRoot` 和 `sourceCheckout` 外,所有字段都可以修改。返回所选 `kind` 的全部必填字段。Paseo 在钩子之后解析 Git 源计划,因此钩子可以在获取引用之前修改分支和基准,而 Git 之后仍可能选择唯一的最终分支名称。 + +Paseo 解析父目录中的符号链接并验证返回的路径。路径必须为绝对路径且不存在,不能是文件系统根目录、用户主目录、仓库根目录、`.git` 目录内的路径或仓库的祖先目录。目标已存在时创建失败,Paseo 不会为插件选择的路径添加后缀。插件应让 Paseo 创建目录。 + +顺序:`worktree.create.before` → `git worktree add` → `worktree.create.after` → 注册工作区。恢复缺失的 worktree 时使用记录的原路径,不运行这两个创建钩子。 + +## worktree.create.after {#worktreecreateafter} + +新的 git worktree 在磁盘上创建后,Paseo 会等待此钩子,然后才注册工作区、运行 setup、打开终端或启动 Agent。收到 `{ worktree: { path, branch, sourceCheckout } }`,分别是 worktree 绝对路径、分支名、来源 checkout 目录。此时还没有工作区 id。无需返回数据,直接写入 worktree 即可。抛出错误或超过 30 秒会中止创建并删除新 worktree。 + +此钩子覆盖新建分支、检出分支和检出 PR。恢复缺失的已归档 worktree 使用独立路径,不触发此钩子。 + +```ts +import { copyFile } from "node:fs/promises"; + +server.addHook("worktree.create.after", async ({ worktree }) => { + await copyFile(`${worktree.sourceCheckout}/.env`, `${worktree.path}/.env`); +}); +``` + +## agent.create.before {#agentcreatebefore} + +收到 `{ request: { config, env? } }`。配置修改会保存,即使插件以后被停用或移除;`env` 只用于首次启动,不会保存。不能修改 `config.cwd`。抛出错误、无效返回或超时会让创建失败。 + +**`agent.create.before.config`** 使用 `AgentSessionConfig`: + +| 字段 | 约束 | +| --------------------------------------------- | -------------------------------------------------------- | +| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 | +| `modeId`, `thinkingOptionId`, `featureValues` | 特定于 Provider 的选择 | +| `title`, `systemPrompt` | Agent 配置 | +| `providerOptions` | 不透明的、特定于 Provider 的选项 | +| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 | +| `cwd` | 不能修改 | + +### 修改已保存的配置 {#change-saved-configuration} + +在 `contribute(server)` 中添加这个回调,为新的 Codex Agent 设置已保存的选项: + +```ts +server.addHook("agent.create.before", async ({ request }) => { + if (request.config.provider !== "codex") { + return request; + } + + return { + ...request, + config: { + ...request.config, + providerOptions: { + ...request.config.providerOptions, + sandbox_mode: "workspace-write", + approval_policy: "on-request", + }, + }, + }; +}); +``` + +| 输入 | 结果 | +| ------------------------------------------- | ------------------- | +| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | +| `providerOptions.web_search: "disabled"` | 由展开运算保留 | + +所选的 Provider 会校验 `providerOptions`。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 + +## agent.create.after {#agentcreateafter} + +常规创建完成后触发,收到 `{ agent }`;导入和恢复不会触发。 + +```ts +server.addHook("agent.create.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.open.before {#agentopenbefore} + +收到 `{ request }`,只能修改 `env`、`tools` 和 `mcpServers`,修改只用于此次启动。其他字段只读。抛出错误、无效返回或超时会让打开操作失败。 + +**`agent.open.before` 请求示例:** + +```json +{ + "agentId": "agent-123", + "workspaceId": "workspace-456", + "provider": "codex", + "cwd": "/projects/shop", + "title": "Workspace notes", + "labels": { "notes/enabled": "true" }, + "tools": [], + "mcpServers": {}, + "reason": "resume", + "purpose": "interactive", + "env": { "COMPANY_ENV": "development" } +} +``` + +| 字段 | 取值 | +| ------------- | -------------------------------------------------------------------------------------------------------- | +| `workspaceId` | 字符串或 `null` | +| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 | +| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 | +| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 | +| `mcpServers` | 可编辑的服务器映射,仅用于启动且仅包含本插件的条目。参阅[注入 MCP 服务器](#inject-mcp-servers)。 | +| `reason` | `create`、`resume`、`refresh`、`import` | +| `purpose` | `interactive`、`history` | +| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | + +### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening} + +```ts +server.addHook("agent.open.before", async ({ request }) => { + return { + ...request, + env: { + ...request.env, + COMPANY_ENV: "development", + }, + }; +}); +``` + +会话打开时的环境变量覆盖项会在每次打开时重新计算。`agent.create.before.env` 只作用于首次启动,不会写入 Agent 的已保存配置。 + +### 注入 MCP 服务器 {#inject-mcp-servers} + +需要将服务器保存为 Agent 配置的一部分时,使用 `agent.create.before.config.mcpServers`。 +插件拥有的服务器使用下面的回调,在 `contribute(server)` 中添加,并把占位 URL 替换为你的 MCP 端点: + +```ts +server.addHook("agent.open.before", async ({ request }) => { + if (request.provider !== "codex") return; + + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`request.mcpServers` 使用与 `AgentSessionConfig.mcpServers` 相同的服务器配置。 +每个插件的映射从空开始,只在该插件自己的回调之间累积,不包含 Agent 已保存的服务器或其他插件的服务器。 +替换映射即可添加、替换或移除本插件的条目;返回 `undefined` 会保持不变。 +Paseo 按插件 ID 顺序合并各插件的最终映射。 + +名称与已保存的服务器、Paseo 内部的 `paseo` 服务器或其他插件的服务器冲突时,会话打开失败,错误会指出服务器名称及双方来源。 +不支持 MCP 的 Provider 会报 `does not support MCP servers` 错误,并指出插件名称。 +`purpose: "history"` 的会话打开会忽略这些服务器。安装、停用和重新加载插件后,现有 Agent +通过[会话重新打开行为](#attach-tools-to-agents)获取变化。 + +### 为 Agent 附加工具 {#attach-tools-to-agents} + +在 `index.server.ts` 中定义工具,并在 `server.addHook("agent.open.before", async handler)` 中附加它。 +此示例只向带有 `notes/enabled: "true"` 标签的 Agent 提供 `append_workspace_note`: + +```ts +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { defineAgentTool, type PluginServerContext } from "@getpaseo/plugin/server"; + +const appendWorkspaceNote = defineAgentTool({ + name: "append_workspace_note", + description: "Append a note to AGENT_NOTES.md in this agent's working directory.", + input: z.object({ note: z.string().min(1).describe("Note to append") }), + async execute({ note }, { agent, signal }) { + const notePath = path.join(agent.cwd, "AGENT_NOTES.md"); + await writeFile(notePath, `${note}\n`, { encoding: "utf8", flag: "a", signal }); + return { + text: `Saved note to ${notePath}`, + structuredContent: { path: notePath }, + }; + }, +}); + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.open.before", async ({ request }) => { + if (request.labels["notes/enabled"] !== "true") return; + return { ...request, tools: [...request.tools, appendWorkspaceNote] }; + }); + return () => {}; +} +``` + +通过 SDK 创建 Agent 时设置标签: + +```ts +const agent = await paseo.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/projects/shop", + labels: { "notes/enabled": "true" }, +}); +``` + +使用会话打开请求中的字段选择 Agent: + +| 选择依据 | 回调中的条件 | +| ---------- | -------------------------------------------- | +| 标签 | `request.labels["notes/enabled"] === "true"` | +| Provider | `request.provider === "codex"` | +| 工作区 | `request.workspaceId === "workspace-456"` | +| 单个 Agent | `request.agentId === chosenAgentId` | + +每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具; +替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。 + +从 `@getpaseo/plugin/server` 导入 `defineAgentTool`、`AgentToolDefinition`、`AgentToolContext` +和 `AgentToolResult`。辅助函数会根据 Zod schema 推断验证后的输入类型: + +```ts +declare function defineAgentTool(tool: { + name: string; + title?: string; + description: string; + input: Input; + execute( + input: z.output, + context: AgentToolContext, + ): AgentToolResult | Promise; +}): AgentToolDefinition; + +interface AgentToolResult { + text?: string; + structuredContent?: unknown; + isError?: boolean; +} +``` + +| 执行 context | 值 | +| ------------ | --------------------------------------------------------------------------------------------------- | +| `agent` | 发起调用的 Agent,以 `PluginHookAgent` 表示,包括 `id`、`workspaceId`、`provider`、`cwd` 和 `title` | +| `paseo` | 已连接到此 daemon 的 SDK | +| `signal` | 此次工具调用的中止信号;请把它传给文件操作或外部请求 | + +`execute` 在插件进程中运行。闭包和原始 Zod schema 保留在该进程中;只有工具元数据和调用的 +输入、结果会跨进程传递。Paseo 在调用 `execute` 前验证输入。无效输入会将 schema 问题返回给 +Agent。抛出的错误会转换为 `{ isError: true, text: message }`,插件继续运行。取消调用或关闭 +其传输连接会中止 `signal` 并丢弃结果。工具调用最长运行 10 分钟,与钩子的 30 秒超时分开。 +关闭或重新打开 Agent 会话会移除旧定义。 + +在 `text` 中返回模型可见的文本。`structuredContent` 必须可序列化为 JSON;省略 `text` 时, +Paseo 会将该值格式化为供模型读取的文本。设置 `isError: true` 可报告工具错误。 + +Paseo 通过 Agent 工具传输提供附加的工具,按 Provider 的支持情况使用 MCP 或原生注册。 +同一份定义和回调适用于这两种方式。附加的工具遵循[与 Paseo 工具相同的限制](/docs/mcp#limit-paseo-tools-by-provider)。 +只有该 Agent 能看到这些工具。 + +名称必须匹配 `/^[a-z][a-z0-9_]*$/`,且不超过 64 个字符。如果名称与同一 Agent 的 Paseo +工具或另一插件的工具冲突,会话打开会失败,并指出双方。一个插件内的重复名称也会导致 +会话打开失败。回调未选中的 Agent 不受影响。 + +| 变更 | Agent 何时收到变更 | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 启动、停止或重新加载声明了 `agent.open.before` 的插件 | 已打开的会话会标记为过期。每个会话在下一次空闲提示词前重新打开;正在运行的轮次会继续,不会被中断。重新打开后的下一条提示词复用该会话。 | +| 修改回调选择的 Agent | 编辑此示例后运行 `paseo plugin reload agent-tools`,然后在 Agent 空闲时发送提示词。 | +| 立即应用 | 调用 `await paseo.agents.ref(id).reload({ requestId: "reload-notes" })`。`requestId` 可选。这会重新打开 Provider 会话,以 `reason: "refresh"` 再次运行钩子,并中断正在运行的轮次。 | +| 读取当前快照 | 调用 `await paseo.agents.ref(id).refresh()`。这只读取状态,不会重新打开会话。 | + +如果自动重新打开失败,触发它的提示词会返回该错误。参阅 +[完整 agent-tools 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools)。 + +## agent.close.after {#agentcloseafter} + +运行中的 Agent 实例关闭后触发,收到 `{ agent }`。 + +```ts +server.addHook("agent.close.after", async ({ agent }) => { + console.log(agent); +}); +``` + +## agent.archive.after {#agentarchiveafter} + +归档状态保存后触发,收到 `{ agent, archivedAt }`;运行时清理可能尚未完成。 + +```ts +server.addHook("agent.archive.after", async ({ agent, archivedAt }) => { + console.log(agent, archivedAt); +}); +``` + +## agent.turn.before {#agentturnbefore} + +实时轮次开始后触发,收到 `{ agent, turnId }`。历史重放不会触发。 + +```ts +server.addHook("agent.turn.before", async ({ agent, turnId }) => { + console.log(agent, turnId); +}); +``` + +## agent.turn.after {#agentturnafter} + +实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome, timeline }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。`timeline` 已弃用且始终为空;请按下面的示例通过 SDK 获取历史。 + +### 在轮次结束时发送后续消息 {#send-a-follow-up-when-a-turn-ends} + +把 [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) 复制到你的插件中。该辅助文件从 `@getpaseo/protocol/agent-types` 导入类型;请把与 daemon 版本相同的 `@getpaseo/protocol` 加入开发依赖,并在加载插件之前安装它们。`latestOutputText` 会拼接最近一条用户消息之后的文本片段。 + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { latestOutputText } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.turn.after", async (event, context) => { + if (event.outcome.kind === "canceled") { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); + const text = latestOutputText(page.entries.map(({ item }) => item)); + if (/out of credits/i.test(text)) { + await agent.send("Try again."); + } + }); + + return () => {}; +} +``` + +```text +Turn ends: "out of credits" + → plugin sends "Try again." + → a new turn starts +``` + +该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 `cursor` 和 `direction` 继续翻页。 + +这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。 + +## agent.permission.before {#agentpermissionbefore} + +权限或问题进入待处理状态后触发,收到 `{ agent, request }`;`request` 为 `AgentPermissionRequest`。通过 SDK 回应。 + +### 回应权限请求 {#answer-a-permission-request} + +使用同一个[辅助文件](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts)中的 `shellCommand`: + +```ts +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { shellCommand } from "./server/inspect"; + +export default function contribute(server: PluginServerContext) { + server.addHook("agent.permission.before", async (event, context) => { + const command = shellCommand(event.request); + if (command === null) { + return; + } + + const agent = context.paseo.agents.ref(event.agent.id); + if (/\brm\s+-rf\b/.test(command)) { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "deny", message: "Recursive deletion is blocked." }, + }); + return; + } + + if (command.trim() === "git status") { + await agent.respondToPermission({ + requestId: event.request.id, + response: { behavior: "allow" }, + }); + } + }); + + return () => {}; +} +``` + +| 请求 | 结果 | +| ---------------- | -------------------- | +| `rm -rf build` | 拒绝 | +| `git status` | 批准 | +| 其他命令或请求 | 保持待处理,留给用户 | +| 已经处理过的请求 | SDK 响应失败 | + +这个正则表达式只是一个示例策略,而不是 shell 解析器。权限请求也可能是问题、计划和模式变更;请求权限不会结束当前轮次。 + +## agent.permission.after {#agentpermissionafter} + +待处理请求得到回应或清除后触发,收到 `{ agent, requestId, resolution }`;`resolution` 为 `AgentPermissionResponse`。 + +```ts +server.addHook("agent.permission.after", async ({ agent, requestId, resolution }) => { + console.log(agent, requestId, resolution); +}); +``` + +Agent 钩子不包括 daemon 的私有临时辅助 Agent。公开的后台工作区及其普通 Agent 使用相同钩子。 + +## 顺序与返回值 {#ordering-and-returned-values} + +```text +Creation request + → agent.create.before hooks (plugin-ID order; registration order within each plugin) + → resolve defaults and validate provider configuration + → derive launch configuration with Paseo runtime tools and daemon prompt + → agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers) + → set PASEO_AGENT_ID and PASEO_AGENT_CWD + → open provider session and save agent configuration +``` + +| 回调返回 | 下一个回调收到 | +| ------------------------------------------------------- | --------------------------------- | +| `{ ...request, env: { ...request.env, REGION: "eu" } }` | 之前的请求,添加或替换了 `REGION` | +| `{ ...request, env: { REGION: "eu" } }` | 之前的请求,整个覆盖映射被替换 | +| `undefined` | 未改变的请求 | +| 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 | + +没有自动的深度合并。`env` 在插件之间累积;`tools` 和 `mcpServers` 只在同一插件的回调之间累积。 +返回替换的映射或数组会替换该范围内之前的值。 + +## context 与清理 {#context-and-cleanup} + +| 契约 | 行为 | +| -------------------------- | ---------------------------------------------------------------------------------- | +| `context.paseo` | 已连接到此 daemon 的现有 SDK | +| `context.signal` | 在调用超时或插件停止时中止;请把它传给外部请求 | +| 输入数据 | 分离的快照;通过返回的请求或 SDK 命令改变状态 | +| 注册结果 | 幂等的移除函数,例如 `const remove = server.addHook(...); remove();` | +| 重新加载、停用、移除、关闭 | 移除剩余的注册 | +| 未知的钩子名称 | 注册失败 | +| 钩子超时 | 30 秒;会中止 signal。等待型钩子会让待处理的操作失败;事件处理函数会记录一条错误。 | +| 事件处理函数出错 | 记录在插件名下;原操作继续进行 | +| 事件投递 | 实时、尽力而为;没有重放、持久化或自动重试 | +| 事件并发 | 不同的事件可能重叠;不保证回调完成的顺序 | + +## 完整示例 {#complete-examples} + +| 插件 | 包含 | +| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- | +| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | Agent、工作区和 worktree 钩子;隐去环境变量值的 JSON 日志 | +| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree | +| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 | +| [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | 按标签选择 Agent,并在插件进程中执行文件操作的工具 | + +用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。 + +## 已重命名的钩子 {#renamed-hooks} + +旧注册方法已弃用,会在 2027-04-30 之后移除。现有插件继续按原来的方式运行。同一插件中混合使用新旧注册方法时,处理函数按注册顺序运行。`addHook` 只接受新名称。 + +| 旧注册方式 | 新注册方式 | +| -------------------------------------------------- | --------------------------------------------------------- | +| `server.before("workspace.create", handler)` | `server.addHook("workspace.create.before", asyncHandler)` | +| `server.on("workspace.created", handler)` | `server.addHook("workspace.create.after", asyncHandler)` | +| `server.on("workspace.archived", handler)` | `server.addHook("workspace.archive.after", asyncHandler)` | +| `server.before("agent.create", handler)` | `server.addHook("agent.create.before", asyncHandler)` | +| `server.on("agent.created", handler)` | `server.addHook("agent.create.after", asyncHandler)` | +| `server.before("agent.session_open", handler)` | `server.addHook("agent.open.before", asyncHandler)` | +| `server.on("agent.closed", handler)` | `server.addHook("agent.close.after", asyncHandler)` | +| `server.on("agent.archived", handler)` | `server.addHook("agent.archive.after", asyncHandler)` | +| `server.on("agent.turn_started", handler)` | `server.addHook("agent.turn.before", asyncHandler)` | +| `server.on("agent.turn_ended", handler)` | `server.addHook("agent.turn.after", asyncHandler)` | +| `server.on("agent.permission_requested", handler)` | `server.addHook("agent.permission.before", asyncHandler)` | +| `server.on("agent.permission_resolved", handler)` | `server.addHook("agent.permission.after", asyncHandler)` | diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 2f4951669c..9a87dfb155 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -287,6 +287,11 @@ Paseo refreshes an agent by closing its current provider session and opening it configuration and persistence. Re-read credentials, environment, global configuration, and MCP servers during `session.open`. There is no separate reload operation. +Conversation and combined rewind require a core-restored session with persistence. After a +successful rewind, Paseo closes the session and reopens its latest persistence value with +`history: "replay"`. Publish the retained native branch before `session.ready`, including its +children. File-only rewind keeps the current session and conversation history. + Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and recreate it while restoring the parent. @@ -306,6 +311,52 @@ turn or finish as a side effect with a `completed` prompt result. Advertise only capabilities you implement. If steering is unsupported, omit `prompt.steer`; Paseo can replace the active turn instead. +## Permit idle runtime cleanup + +Paseo can release an idle resumable session after one hour when its daemon baseline allows cleanup. +Add a provider-owned veto by advertising `session.can_stop_runtime` on the connection and the +session's `session.opened` capabilities. Without this capability, the same baseline and one-hour +idle period apply, and the plugin receives no safety request. + +Handle the safety query in your existing `send(input)` handler. It addresses the provider's own +session ID, including when one connection serves several sessions: + +```ts +case "session.can_stop_runtime": { + try { + const session = sessions.get(input.sessionId); + if (!session) throw new Error("Session not found"); + emit({ + type: "session.can_stop_runtime", + requestId: input.requestId, + sessionId: input.sessionId, + canStop: await session.canStopRuntime(), + }); + } catch (error) { + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: error instanceof Error ? error.message : String(error) }, + }); + } + return; +} +``` + +`canStopRuntime(): Promise` belongs to your provider's session implementation. Return +`true` only when releasing the runtime loses no background shells, monitors, native children, +queued completion reports, or pending autonomous replies. This query must not stop or mutate the +session. Return `false` while work remains. A rejected query, invalid response, or timeout also +keeps the runtime alive. Never respond with `request.completed` for this query. + +Paseo always checks foreground work, permissions, queued deliveries, lifecycle operations, +working managed/native descendants, and outstanding local or remote finish subscriptions for the +caller. Subscription protection lasts through registration, recovery and notification dispatch +acknowledgment. A positive provider response never overrides these blockers. Paseo rechecks +activity before the existing `session.close` operation. After protected work finishes, a fresh +one-hour idle period starts. The saved provider persistence must resume the same conversation. +The host setting `agents.idleRuntimeCleanup: false` disables collection for every provider. + ## Publish timeline items Publish complete snapshots through `timeline.item`. Reuse an item `id` when updating streamed text, diff --git a/public-docs/plugins/providers.zh-CN.md b/public-docs/plugins/providers.zh-CN.md index d49e559d49..6cc0a2cb14 100644 --- a/public-docs/plugins/providers.zh-CN.md +++ b/public-docs/plugins/providers.zh-CN.md @@ -269,6 +269,38 @@ Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,然后用当 只声明你实现了的能力。如果不支持引导,请省略 `prompt.steer`;Paseo 可以改为替换当前轮次。 +## 允许清理空闲运行时 {#permit-idle-runtime-cleanup} + +当 daemon 的基础条件允许清理时,Paseo 可以在可恢复的会话空闲一小时后释放其运行时。在连接和会话的 `session.opened` capabilities 中声明 `session.can_stop_runtime`,即可添加 Provider 自己的否决检查。没有此能力时,会话仍使用相同的基础条件和一小时空闲期,插件不会收到安全查询。 + +在现有的 `send(input)` 处理函数中处理安全查询。请求使用 Provider 自己的会话 ID,包括同一连接服务多个会话的情况: + +```ts +case "session.can_stop_runtime": { + try { + const session = sessions.get(input.sessionId); + if (!session) throw new Error("Session not found"); + emit({ + type: "session.can_stop_runtime", + requestId: input.requestId, + sessionId: input.sessionId, + canStop: await session.canStopRuntime(), + }); + } catch (error) { + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: error instanceof Error ? error.message : String(error) }, + }); + } + return; +} +``` + +`canStopRuntime(): Promise` 由你的 Provider 会话实现。只有释放运行时不会丢失后台 shell、监控任务、原生子 Agent、排队的完成报告或待处理的自主回复时,才返回 `true`。此查询不得停止或修改会话。仍有工作时返回 `false`。查询拒绝、响应无效或超时也会保留运行时。不要用 `request.completed` 响应此查询。 + +Paseo 始终检查前台工作、权限、排队投递、生命周期操作、工作中的托管或原生后代 Agent,以及调用方尚未完成的本地或远程完成通知订阅。订阅保护覆盖注册、恢复,直到通知投递获确认。Provider 返回 `true` 也不能绕过这些阻止条件。Paseo 会在调用现有的 `session.close` 操作之前重新检查活动。受保护的工作完成后,会重新开始一小时的空闲期。保存的 Provider 持久化数据必须能恢复同一个会话。主机设置 `agents.idleRuntimeCleanup: false` 会禁用所有 Provider 的清理。 + ## 发布时间线条目 {#publish-timeline-items} 通过 `timeline.item` 发布完整的快照。更新流式文本、运行中的工具或待办列表时,复用条目的 `id`。Paseo 会推导出实时增量,并保持正常的时间线。 diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 81b99b0dd0..a5565b71c7 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2,7 +2,7 @@ title: Plugin reference description: Local plugin files, client and server runtimes, platform limits, contributions, RPCs, lifecycle, hosts, and CLI commands. nav: Reference -order: 48 +order: 49 category: Plugins --- @@ -628,340 +628,11 @@ export default function contribute(client: PluginClientContext) { } ``` -Cleanup can be async. Release timers, watchers, sockets, and other resources created by the plugin. Paseo also removes registrations, unmounts surfaces, rejects pending RPCs, closes the plugin's daemon session, and stops its subprocess on reload, disable, removal, disconnect, or daemon shutdown. +Cleanup can be async. Release timers, watchers, sockets, and other resources created by the plugin. Reload, disable, and removal dispose the affected installation. Stopping a server plugin also closes its daemon session and stops its subprocess. Client installations follow [Hosts and lifecycle](#hosts-and-lifecycle), including retention during a temporary disconnect. ## Lifecycle hooks -In `index.server.ts`: - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.turn_ended", (event) => { - console.log(event.agent.id, event.outcome); - }); - - return () => {}; -} -``` - -| Register | Callback receives | Return | -| ------------------------------- | ---------------------------------- | ------------------------------------------------------------ | -| `server.on(name, callback)` | `(event, { paseo, signal })` | `void` or `Promise` | -| `server.before(name, callback)` | `({ request }, { paseo, signal })` | Modified request, or `undefined` to keep it; async supported | - -Hooks run on the daemon while the plugin is enabled, even with no app connected. - -### Change configuration and inject an MCP server - -Add this callback inside `contribute(server)`. Replace the placeholder URL with your MCP endpoint. - -```ts -server.before("agent.create", ({ request }) => { - if (request.config.provider !== "codex") { - return request; - } - - return { - ...request, - config: { - ...request.config, - providerOptions: { - ...request.config.providerOptions, - sandbox_mode: "workspace-write", - approval_policy: "on-request", - }, - mcpServers: { - ...request.config.mcpServers, - company: { - type: "http", - url: "https://tools.example.com/mcp", - }, - }, - }, - }; -}); -``` - -| Input | Result | -| ------------------------------------------- | ----------------------------- | -| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | -| `providerOptions.web_search: "disabled"` | Preserved by the spread | -| Existing `mcpServers.search` | Preserved by the spread | -| Existing `mcpServers.company` | Replaced with the entry above | - -The selected provider validates `providerOptions` and must support the configured MCP servers. -Explicit Codex sandbox and approval options override its mode presets. - -### Inject environment variables on every session opening - -```ts -server.before("agent.session_open", ({ request }) => { - return { - ...request, - env: { - ...request.env, - COMPANY_ENV: "development", - }, - }; -}); -``` - -This runs on create, resume, refresh, and import. To inject only on creation, set `env` in an -`agent.create` callback instead. - -### Choose workspace isolation - -```ts -server.before("workspace.create", ({ request }) => { - if (request.source.kind !== "directory") { - return request; - } - - return { - ...request, - source: { - kind: "worktree", - cwd: request.source.path, - action: "branch-off", - }, - }; -}); -``` - -**Result:** an explicit directory creation request becomes a worktree request. Existing workspaces -and directory lookup/import operations are unaffected. - -### Send a follow-up when a turn ends - -Copy [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) -into your plugin. The helper imports types from `@getpaseo/protocol/agent-types`; add -`@getpaseo/protocol` at the same version as your plugin SDK to your development dependencies -and install them before loading the plugin. `latestOutputText` joins text chunks after the latest user message. - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { latestOutputText } from "./server/inspect"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.turn_ended", async (event, context) => { - if (event.outcome.kind === "canceled") { - return; - } - - const agent = context.paseo.agents.ref(event.agent.id); - const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); - const text = latestOutputText(page.entries.map(({ item }) => item)); - if (/out of credits/i.test(text)) { - await agent.send("Try again."); - } - }); - - return () => {}; -} -``` - -```text -Turn ends: "out of credits" - → plugin sends "Try again." - → a new turn starts -``` - -The handler fetches only the last 20 timeline items. Output before that page is not inspected. -Use `cursor` and `direction` to page further when your plugin needs more history. - -This sends a new message with the existing SDK. Persistent matches keep sending follow-ups; -add limits or delays in your plugin when needed. Attachments and tool effects are not replayed. - -### Answer a permission request - -Using `shellCommand` from the same [helper file](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts): - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { shellCommand } from "./server/inspect"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.permission_requested", async (event, context) => { - const command = shellCommand(event.request); - if (command === null) { - return; - } - - const agent = context.paseo.agents.ref(event.agent.id); - if (/\brm\s+-rf\b/.test(command)) { - await agent.respondToPermission({ - requestId: event.request.id, - response: { behavior: "deny", message: "Recursive deletion is blocked." }, - }); - return; - } - - if (command.trim() === "git status") { - await agent.respondToPermission({ - requestId: event.request.id, - response: { behavior: "allow" }, - }); - } - }); - - return () => {}; -} -``` - -| Request | Result | -| ------------------------ | ------------------------- | -| `rm -rf build` | Declined | -| `git status` | Approved | -| Other command or request | Left pending for the user | -| Already-resolved request | SDK response fails | - -The regex is an example policy, not a shell parser. Permission requests can also be questions, -plans, and mode changes; requesting permission does not end the turn. - -### Events - -| Name | Event fields | Trigger | -| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------- | -| `agent.created` | `agent` | Ordinary creation finishes; excludes import/resume | -| `agent.closed` | `agent` | A live agent runtime closes | -| `agent.turn_started` | `agent`, `turnId` | Live turn starts | -| `agent.turn_ended` | `agent`, `turnId`, `outcome`, deprecated `timeline: []` | Live turn completes, fails, or is canceled | -| `agent.permission_requested` | `agent`, `request` | Permission or question becomes pending | -| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | Pending request is answered or cleared | -| `agent.archived` | `agent`, `archivedAt` | Archive state is saved | -| `workspace.created` | `workspace` | Record created; directory available | -| `workspace.archived` | `workspace` | Archive state is saved | - -Agent events exclude the daemon's private disposable helpers. Public background workspaces and -their ordinary agents run the same lifecycle hooks as other workspaces. Archive events can precede -runtime/worktree cleanup; observe lifecycle completion separately when cleanup matters. - -`agent.turn_ended.timeline` is deprecated and always empty. Fetch history on demand with -`context.paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 20 })`. -The hook payload size does not grow with conversation history. - -**Shared payload shapes** (`@getpaseo/plugin/server`): - -```ts -interface PluginHookAgent { - id: string; - workspaceId: string | null; - parentAgentId: string | null; - provider: string; - cwd: string; - title: string | null; -} - -interface PluginHookWorkspace { - id: string; - projectId: string; - cwd: string; - name: string | null; - archivedAt: string | null; -} - -type PluginTurnOutcome = - | { kind: "completed" } - | { kind: "failed"; error: { message: string; code?: string } } - | { kind: "canceled"; reason: string }; -``` - -| Field | Shape / meaning | -| ------------ | ----------------------------------------------------------------------------------------------------- | -| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens | -| `timeline` | `readonly AgentTimelineItem[]`; complete snapshot including earlier conversation; text may span items | -| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` | -| `resolution` | SDK `AgentPermissionResponse` | -| `archivedAt` | Timestamp string | - -### Before hooks - -| Name | Request fields | Editable | -| -------------------- | ----------------------------------------------------------------------- | --------------------------------------- | -| `agent.create` | `config`, optional `env` | Public agent config except `cwd`; `env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | Only `env` | -| `workspace.create` | `source`, optional `title`, `firstAgentContext` | Entire explicit creation request | - -**`agent.create.config`** uses `AgentSessionConfig`: - -| Fields | Constraint | -| --------------------------------------------- | -------------------------------------------------------------------------- | -| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options | -| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections | -| `title`, `systemPrompt` | Agent configuration | -| `providerOptions` | Opaque provider-specific options | -| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals | -| `cwd` | Cannot change | - -**`agent.session_open` request example:** - -```json -{ - "agentId": "agent-123", - "workspaceId": "workspace-456", - "provider": "codex", - "cwd": "/projects/shop", - "reason": "resume", - "purpose": "interactive", - "env": { "COMPANY_ENV": "development" } -} -``` - -| Field | Values | -| ------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `workspaceId` | String or `null` | -| `reason` | `create`, `resume`, `refresh`, `import` | -| `purpose` | `interactive`, `history` | -| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. | - -### Ordering and returned values - -```text -Creation request - → agent.create hooks (plugin-ID order; registration order within each plugin) - → resolve defaults and validate provider configuration - → derive launch configuration with Paseo runtime tools and daemon prompt - → agent.session_open hooks (same ordering; env only) - → set PASEO_AGENT_ID and PASEO_AGENT_CWD - → open provider session and save agent configuration -``` - -| Callback returns | Next callback receives | -| ------------------------------------------------------- | --------------------------------------------------- | -| `{ ...request, env: { ...request.env, REGION: "eu" } }` | Prior request with `REGION` added/replaced | -| `{ ...request, env: { REGION: "eu" } }` | Prior request with the entire override map replaced | -| `undefined` | Unchanged request | -| Throws or returns invalid data | Operation fails; later callbacks do not run | - -No automatic deep merge. Later callbacks can overwrite earlier values. Agent configuration is -saved; environment overrides are not persisted with it. - -### Context and cleanup - -| Contract | Behavior | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `context.paseo` | Existing SDK connected to this daemon | -| `context.signal` | Aborted on invocation timeout or plugin stop; pass to external requests | -| Input data | Detached snapshot; change state through returned requests or SDK commands | -| Registration result | Idempotent remover, e.g. `const remove = server.on(...); remove();` | -| Reload, disable, removal, shutdown | Remaining registrations removed | -| Unknown hook name | Registration fails | -| Hook timeout | 30 seconds; aborts the signal. A before hook fails the pending operation; an event handler logs an error. | -| Event-handler error | Logged against plugin; original operation continues | -| Event delivery | Live, best effort; no replay, persistence, or automatic retry | -| Event concurrency | Different events may overlap; callback completion order is not guaranteed | - -### Complete examples - -| Plugin | Includes | -| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | All eleven hooks; JSON logs with environment values redacted | -| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | Follow-ups, permissions, environment, provider switching, worktree selection | -| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP injection and Codex sandbox/approval options | - -Read logger output with `paseo plugin logs lifecycle-logger` or the host's `daemon.log`. +Register async handlers with `server.addHook(name, handler)`. See [Hooks](/docs/plugins/hooks) for names, payloads, ordering, errors, and complete examples. ## Screens and sidebar items @@ -2346,7 +2017,7 @@ Hiding preserves the registration, so showing it again restores its position. It an action already in progress. `remove()` is idempotent. Updates after removal do nothing. Paseo removes outstanding buttons when -the plugin installation or host connection is torn down. Return cleanup from the client entry for +the plugin installation is disposed (see [Hosts and lifecycle](#hosts-and-lifecycle)). Return cleanup from the client entry for your subscriptions, timers, and other resources. ## Use the Paseo SDK @@ -2674,9 +2345,11 @@ Plugins are installed per daemon. When the same contribution exists on several c Attachment sources remain scoped to each composer's host. -Workspace panels and Command Center items stay scoped to the active host and exact cached context. -Reload replaces their registrations. Disable, removal, host disconnect, and evaluation failure -remove Command Center items and clear the installation's query state. An already-restored panel tab +A dropped connection keeps client plugin registrations, mounted surfaces, local state, and query caches. Reconnecting through the existing `DaemonClient` object keeps unchanged installations and refreshes their settings. Consume SDK observation snapshots to refresh other daemon state; setup does not run again for an unchanged installation. Daemon calls still depend on connectivity. + +Switching connections (for example, from direct to relay) currently replaces the `DaemonClient` object and reinstalls client plugins. Reload, changed bundles or requirements, disable, plugin removal, and host removal also dispose affected installations and clear their query state. A replacement that fails evaluation leaves its contributions unavailable. + +Workspace panels and Command Center items stay scoped to the active host and exact cached context. An already-restored panel tab remains as unavailable until its matching contribution returns or the user closes it. Panel render failures stay inside the plugin error boundary. diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 62caec83e5..5375b98c68 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -504,326 +504,11 @@ export default function contribute(client: PluginClientContext) { } ``` -清理函数可以是异步的。请释放插件创建的计时器、监听器、套接字和其他资源。在重新加载、停用、移除、断开连接或 daemon 关闭时,Paseo 还会移除注册、卸载界面、reject 待处理的 RPC、关闭插件的 daemon 会话并停止它的子进程。 +清理函数可以是异步的。请释放插件创建的计时器、监听器、套接字和其他资源。重新加载、停用和移除会销毁受影响的插件安装。停止服务端插件还会关闭它的 daemon 会话并停止其子进程。客户端安装遵循[主机与生命周期](#hosts-and-lifecycle)中的规则,包括在暂时断开连接时保留安装。 ## 生命周期钩子 {#lifecycle-hooks} -在 `index.server.ts` 中: - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.turn_ended", (event) => { - console.log(event.agent.id, event.outcome); - }); - - return () => {}; -} -``` - -| 注册 | 回调接收 | 返回 | -| ------------------------------- | ---------------------------------- | --------------------------------------------------- | -| `server.on(name, callback)` | `(event, { paseo, signal })` | `void` 或 `Promise` | -| `server.before(name, callback)` | `({ request }, { paseo, signal })` | 修改后的请求,或返回 `undefined` 保持不变;支持异步 | - -只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。 - -### 修改配置并注入 MCP 服务器 {#change-configuration-and-inject-an-mcp-server} - -在 `contribute(server)` 中添加这个回调。把占位 URL 替换为你的 MCP 端点。 - -```ts -server.before("agent.create", ({ request }) => { - if (request.config.provider !== "codex") { - return request; - } - - return { - ...request, - config: { - ...request.config, - providerOptions: { - ...request.config.providerOptions, - sandbox_mode: "workspace-write", - approval_policy: "on-request", - }, - mcpServers: { - ...request.config.mcpServers, - company: { - type: "http", - url: "https://tools.example.com/mcp", - }, - }, - }, - }; -}); -``` - -| 输入 | 结果 | -| ------------------------------------------- | ------------------- | -| `providerOptions.sandbox_mode: "read-only"` | `"workspace-write"` | -| `providerOptions.web_search: "disabled"` | 由展开运算保留 | -| 已有的 `mcpServers.search` | 由展开运算保留 | -| 已有的 `mcpServers.company` | 被替换为上面的条目 | - -所选的 Provider 会校验 `providerOptions`,并且必须支持配置的 MCP 服务器。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。 - -### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening} - -```ts -server.before("agent.session_open", ({ request }) => { - return { - ...request, - env: { - ...request.env, - COMPANY_ENV: "development", - }, - }; -}); -``` - -它会在创建、恢复、刷新和导入时运行。如果只想在创建时注入,请改为在 `agent.create` 回调中设置 `env`。 - -### 选择工作区隔离方式 {#choose-workspace-isolation} - -```ts -server.before("workspace.create", ({ request }) => { - if (request.source.kind !== "directory") { - return request; - } - - return { - ...request, - source: { - kind: "worktree", - cwd: request.source.path, - action: "branch-off", - }, - }; -}); -``` - -**结果**:显式的目录创建请求会变成 worktree 请求。已有的工作区以及目录查找/导入操作不受影响。 - -### 在轮次结束时发送后续消息 {#send-a-follow-up-when-a-turn-ends} - -把 [server/inspect.ts](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts) 复制到你的插件中。该辅助文件从 `@getpaseo/protocol/agent-types` 导入类型;请把与插件 SDK 版本相同的 `@getpaseo/protocol` 加入开发依赖,并在加载插件之前安装它们。`latestOutputText` 会拼接最近一条用户消息之后的文本片段。 - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { latestOutputText } from "./server/inspect"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.turn_ended", async (event, context) => { - if (event.outcome.kind === "canceled") { - return; - } - - const agent = context.paseo.agents.ref(event.agent.id); - const page = await agent.timeline.refetch({ direction: "tail", limit: 20 }); - const text = latestOutputText(page.entries.map(({ item }) => item)); - if (/out of credits/i.test(text)) { - await agent.send("Try again."); - } - }); - - return () => {}; -} -``` - -```text -Turn ends: "out of credits" - → plugin sends "Try again." - → a new turn starts -``` - -该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 `cursor` 和 `direction` 继续翻页。 - -这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。 - -### 回应权限请求 {#answer-a-permission-request} - -使用同一个[辅助文件](https://github.com/getpaseo/paseo/blob/main/plugin-examples/lifecycle-actions/server/inspect.ts)中的 `shellCommand`: - -```ts -import type { PluginServerContext } from "@getpaseo/plugin/server"; -import { shellCommand } from "./server/inspect"; - -export default function contribute(server: PluginServerContext) { - server.on("agent.permission_requested", async (event, context) => { - const command = shellCommand(event.request); - if (command === null) { - return; - } - - const agent = context.paseo.agents.ref(event.agent.id); - if (/\brm\s+-rf\b/.test(command)) { - await agent.respondToPermission({ - requestId: event.request.id, - response: { behavior: "deny", message: "Recursive deletion is blocked." }, - }); - return; - } - - if (command.trim() === "git status") { - await agent.respondToPermission({ - requestId: event.request.id, - response: { behavior: "allow" }, - }); - } - }); - - return () => {}; -} -``` - -| 请求 | 结果 | -| ---------------- | -------------------- | -| `rm -rf build` | 拒绝 | -| `git status` | 批准 | -| 其他命令或请求 | 保持待处理,留给用户 | -| 已经处理过的请求 | SDK 响应失败 | - -这个正则表达式只是一个示例策略,而不是 shell 解析器。权限请求也可能是问题、计划和模式变更;请求权限不会结束当前轮次。 - -### 事件 {#events} - -| 名称 | 事件字段 | 触发时机 | -| ---------------------------- | ----------------------------------------------------- | ----------------------------- | -| `agent.created` | `agent` | 普通创建完成;不包括导入/恢复 | -| `agent.closed` | `agent` | 一个运行中的 Agent 运行时关闭 | -| `agent.turn_started` | `agent`, `turnId` | 实时轮次开始 | -| `agent.turn_ended` | `agent`, `turnId`, `outcome`, 已弃用的 `timeline: []` | 实时轮次完成、失败或被取消 | -| `agent.permission_requested` | `agent`, `request` | 权限请求或问题进入待处理状态 | -| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | 待处理的请求得到回应或被清除 | -| `agent.archived` | `agent`, `archivedAt` | 归档状态已保存 | -| `workspace.created` | `workspace` | 记录已创建;目录可用 | -| `workspace.archived` | `workspace` | 归档状态已保存 | - -Agent 事件不包括 daemon 私有的一次性辅助 Agent。公开的后台工作区及其普通 Agent 与其他工作区运行相同的生命周期钩子。归档事件可能早于运行时/worktree 清理;当清理很重要时,请单独观察生命周期何时完成。 - -`agent.turn_ended.timeline` 已弃用,并且始终为空。需要历史时,请用 `context.paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 20 })` 按需获取。钩子负载的大小不会随对话历史增长。 - -**共享的负载结构**(`@getpaseo/plugin/server`): - -```ts -interface PluginHookAgent { - id: string; - workspaceId: string | null; - parentAgentId: string | null; - provider: string; - cwd: string; - title: string | null; -} - -interface PluginHookWorkspace { - id: string; - projectId: string; - cwd: string; - name: string | null; - archivedAt: string | null; -} - -type PluginTurnOutcome = - | { kind: "completed" } - | { kind: "failed"; error: { message: string; code?: string } } - | { kind: "canceled"; reason: string }; -``` - -| 字段 | 结构 / 含义 | -| ------------ | ---------------------------------------------------------------------------------------- | -| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 | -| `timeline` | `readonly AgentTimelineItem[]`;包含之前对话的完整快照;文本可能跨越多个条目 | -| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` | -| `resolution` | SDK 的 `AgentPermissionResponse` | -| `archivedAt` | 时间戳字符串 | - -### before 钩子 {#before-hooks} - -| 名称 | 请求字段 | 可编辑 | -| -------------------- | ----------------------------------------------------------------------- | ----------------------------------- | -| `agent.create` | `config`,可选的 `env` | 除 `cwd` 外的公开 Agent 配置;`env` | -| `agent.session_open` | `agentId`, `workspaceId`, `provider`, `cwd`, `reason`, `purpose`, `env` | 仅 `env` | -| `workspace.create` | `source`,可选的 `title`,`firstAgentContext` | 整个显式创建请求 | - -**`agent.create.config`** 使用 `AgentSessionConfig`: - -| 字段 | 约束 | -| --------------------------------------------- | -------------------------------------------------------- | -| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 | -| `modeId`, `thinkingOptionId`, `featureValues` | 特定于 Provider 的选择 | -| `title`, `systemPrompt` | Agent 配置 | -| `providerOptions` | 不透明的、特定于 Provider 的选项 | -| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 | -| `cwd` | 不能修改 | - -**`agent.session_open` 请求示例:** - -```json -{ - "agentId": "agent-123", - "workspaceId": "workspace-456", - "provider": "codex", - "cwd": "/projects/shop", - "reason": "resume", - "purpose": "interactive", - "env": { "COMPANY_ENV": "development" } -} -``` - -| 字段 | 取值 | -| ------------- | ------------------------------------------------------------------------------------ | -| `workspaceId` | 字符串或 `null` | -| `reason` | `create`、`resume`、`refresh`、`import` | -| `purpose` | `interactive`、`history` | -| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 | - -### 顺序与返回值 {#ordering-and-returned-values} - -```text -Creation request - → agent.create hooks (plugin-ID order; registration order within each plugin) - → resolve defaults and validate provider configuration - → derive launch configuration with Paseo runtime tools and daemon prompt - → agent.session_open hooks (same ordering; env only) - → set PASEO_AGENT_ID and PASEO_AGENT_CWD - → open provider session and save agent configuration -``` - -| 回调返回 | 下一个回调收到 | -| ------------------------------------------------------- | --------------------------------- | -| `{ ...request, env: { ...request.env, REGION: "eu" } }` | 之前的请求,添加或替换了 `REGION` | -| `{ ...request, env: { REGION: "eu" } }` | 之前的请求,整个覆盖映射被替换 | -| `undefined` | 未改变的请求 | -| 抛出错误或返回无效数据 | 操作失败;后面的回调不会运行 | - -没有自动的深度合并。后面的回调可以覆盖前面的值。Agent 配置会被保存;环境变量覆盖项不会随之持久化。 - -### context 与清理 {#context-and-cleanup} - -| 契约 | 行为 | -| -------------------------- | ----------------------------------------------------------------------------------- | -| `context.paseo` | 已连接到此 daemon 的现有 SDK | -| `context.signal` | 在调用超时或插件停止时中止;请把它传给外部请求 | -| 输入数据 | 分离的快照;通过返回的请求或 SDK 命令改变状态 | -| 注册结果 | 幂等的移除函数,例如 `const remove = server.on(...); remove();` | -| 重新加载、停用、移除、关闭 | 移除剩余的注册 | -| 未知的钩子名称 | 注册失败 | -| 钩子超时 | 30 秒;会中止 signal。before 钩子会让待处理的操作失败;事件处理函数会记录一条错误。 | -| 事件处理函数出错 | 记录在插件名下;原操作继续进行 | -| 事件投递 | 实时、尽力而为;没有重放、持久化或自动重试 | -| 事件并发 | 不同的事件可能重叠;不保证回调完成的顺序 | - -### 完整示例 {#complete-examples} - -| 插件 | 包含 | -| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | -| [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | 全部十一个钩子;隐去环境变量值的 JSON 日志 | -| [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | 后续消息、权限、环境变量、切换 Provider、选择 worktree | -| [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | MCP 注入以及 Codex 沙盒/批准选项 | - -用 `paseo plugin logs lifecycle-logger` 或主机的 `daemon.log` 读取 logger 的输出。 +使用 `server.addHook(name, handler)` 注册异步处理函数。请参阅[钩子](/docs/plugins/hooks),了解命名、载荷、顺序、错误处理和完整示例。 ## 界面和侧边栏项 {#screens-and-sidebar-items} @@ -1069,7 +754,7 @@ export function IssueActions({ theme }: PluginSurfaceProps) { ```tsx import { Markdown } from "@getpaseo/plugin/client/ui"; -{"## 审查\n\n检查**通过**。"}; +{"## Review\n\nThe checks **passed**."}; ``` `children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。 @@ -2033,7 +1718,7 @@ const behavior: PluginButtonBehavior = { 隐藏或禁用按钮会关闭它的浮层。更新它的 behavior 也会关闭浮层。隐藏会保留注册,因此再次显示时会恢复它 原来的位置。它不会取消已经在进行中的操作。 -`remove()` 是幂等的。移除之后的更新不起作用。当插件安装或主机连接被拆除时,Paseo 会移除尚存的按钮。 +`remove()` 是幂等的。移除之后的更新不起作用。当插件安装被销毁时,Paseo 会移除尚存的按钮(参见[主机与生命周期](#hosts-and-lifecycle))。 请从客户端入口返回清理函数,用来释放你的订阅、定时器和其他资源。 ## 使用 Paseo SDK {#use-the-paseo-sdk} @@ -2352,8 +2037,11 @@ Composer 菜单、搜索选择器、选中后的胶囊、草稿状态和提交 附件来源仍然限定在各个 Composer 所在的主机上。 -工作区面板和 Command Center 条目仍然限定在当前活动的主机和确切的缓存上下文中。重新加载会替换它们的 -注册。停用、移除、主机断开和求值失败会移除 Command Center 条目,并清除该安装的查询状态。已经恢复的面板 +连接中断时,客户端插件的注册、已挂载界面、本地状态和查询缓存会保留。通过原有的 `DaemonClient` 对象重新连接时,未变化的安装会保留,其设置会刷新。请使用 SDK 观察快照刷新其他 daemon 状态;未变化的安装不会再次运行初始化函数。调用 daemon 仍然依赖连接。 + +切换连接(例如从直连切换到中继)目前会替换 `DaemonClient` 对象并重新安装客户端插件。重新加载、插件包或版本要求变化、停用、移除插件和移除主机也会销毁受影响的安装,并清除其查询状态。如果替换安装求值失败,它的贡献将不可用。 + +工作区面板和 Command Center 条目仍然限定在当前活动的主机和确切的缓存上下文中。已经恢复的面板 标签页会保持“不可用”状态,直到与之匹配的贡献重新出现或用户关闭它。面板的渲染失败会被限制在插件的错误 边界内。 diff --git a/public-docs/voice.md b/public-docs/voice.md index 79022c15ad..c90e5196bc 100644 --- a/public-docs/voice.md +++ b/public-docs/voice.md @@ -27,27 +27,24 @@ This keeps credentials and execution in your environment and avoids introducing Local speech defaults to model IDs `parakeet-tdt-0.6b-v2-int8` (STT) and `kokoro-en-v0_19` (TTS, speaker 0 / voice 00). -Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files. +Dictation is on by default. Voice mode is off until you turn it on in **Settings > Voice** or set `features.voiceMode.enabled: true`. + +Missing models for enabled features are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files. **Settings > Voice** lists every local model with its size and languages, and lets you download or delete each one. ### Local STT models and language support -| Model ID | Languages | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `parakeet-tdt-0.6b-v2-int8` | English only (default). Includes punctuation and capitalization. | -| `parakeet-tdt-0.6b-v3-int8` | 25 European languages, auto-detected: Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Ukrainian. | - -**To use a non-English language, switch the local STT model to `parakeet-tdt-0.6b-v3-int8`.** v3 detects the spoken language automatically — there is no per-language setting for it. The `language` field below does **not** steer the local Parakeet model (v2 is English-only, v3 auto-detects); it only applies to the OpenAI STT provider. +**Settings > Voice** lists every local STT model with the languages it supports. The default, `parakeet-tdt-0.6b-v2-int8`, is English-only. For other languages, pick a multilingual model such as `parakeet-tdt-0.6b-v3-int8` (25 European languages) or `whisper-base` (99 languages). The `language` field is passed to STT sessions for both providers. Local recognizers currently detect the spoken language themselves; setting `language` does not expand a model’s supported languages. ```json { "version": 1, "features": { "dictation": { - "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8", "language": "en" } + "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8" } }, "voiceMode": { "llm": { "provider": "claude", "model": "haiku" }, - "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8", "language": "en" }, + "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8" }, "tts": { "provider": "local", "model": "kokoro-en-v0_19", "speakerId": 0 } } }, @@ -59,7 +56,7 @@ Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-s } ``` -For multilingual local dictation, set the model to v3 — it auto-detects the language, so no `language` field is needed: +For multilingual local dictation, set a multilingual model. No `language` field is needed: ```json { @@ -72,38 +69,53 @@ For multilingual local dictation, set the model to v3 — it auto-detects the la } ``` -The `language` field applies only to the OpenAI STT provider: set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for voice mode. If voice language is omitted, Paseo uses the dictation language before falling back to `en`. It has no effect on the local Parakeet models. +For either STT provider, set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for voice mode. If voice language is omitted, Paseo uses the dictation language before falling back to `en`. ## OpenAI Voice Option -You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to `openai` and providing OpenAI credentials. +Set `provider` to `openai` and enter credentials at each use site that needs them. +Dictation, listening and speaking each own their key, base URL and model: ```json { "version": 1, "features": { - "dictation": { "stt": { "provider": "openai" } }, + "dictation": { + "stt": { "provider": "openai", "apiKey": "sk-...", "model": "gpt-4o-mini-transcribe" } + }, "voiceMode": { - "stt": { "provider": "openai" }, - "tts": { "provider": "openai" } - } - }, - "providers": { - "openai": { + "enabled": true, "stt": { - "apiKey": "...", - "baseUrl": "https://api.openai.com/v1" + "provider": "openai", + "apiKey": "sk-...", + "baseUrl": "https://api.openai.com/v1", + "model": "gpt-4o-transcribe", + "language": "en" }, - "tts": { - "apiKey": "...", - "baseUrl": "https://api.openai.com/v1" - } + "tts": { "provider": "openai", "apiKey": "sk-...", "model": "tts-1", "voice": "alloy" } } } } ``` -`providers.openai.stt` covers dictation and voice mode speech-to-text, and `providers.openai.tts` covers voice mode text-to-speech. Because they resolve independently, you can point STT and TTS at different endpoints. Each falls back to `providers.openai.apiKey`/`baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when unset. These settings configure only Paseo OpenAI speech traffic, without changing Codex or other OpenAI-backed tools. +| Provider | Speech-to-text fields | Text-to-speech fields | +| -------- | --------------------------------------------------------------- | ------------------------------------- | +| `local` | `model`, `language` | `model`, `speakerId`, `speed` | +| `openai` | `apiKey`, `baseUrl`, `model`, `language`, `confidenceThreshold` | `apiKey`, `baseUrl`, `model`, `voice` | + +Fields other than `provider` are optional. Omit `model` to use the provider's default. +OpenAI model and voice names accept custom IDs from compatible servers. Keys saved +on the host are write-only in the app. + +A saved `apiKey` or `baseUrl` wins over env settings. Without a saved value, STT +uses `OPENAI_STT_API_KEY` / `OPENAI_STT_BASE_URL`, TTS uses +`OPENAI_TTS_API_KEY` / `OPENAI_TTS_BASE_URL`, and both fall back to +`OPENAI_API_KEY` / `OPENAI_BASE_URL`. These settings affect Paseo speech traffic. + +Existing `providers.openai` settings load automatically into OpenAI use sites that +lack inline values. Endpoint settings take precedence over the old shared settings. +The next save writes the inline shape. A legacy key without an OpenAI site to +receive it is dropped with a warning; enter a key when you later select OpenAI. Paseo uses these paths under the configured OpenAI base URL: @@ -113,6 +125,14 @@ Paseo uses these paths under the configured OpenAI base URL: ## Environment Variables +Provider, enablement, model, language, voice and tuning env vars override config. +Keys and base URLs follow the fallback rule above. + +- `PASEO_DICTATION_ENABLED`, `PASEO_VOICE_MODE_ENABLED`, feature enablement +- `STT_MODEL`, model override for both OpenAI STT sites +- `STT_CONFIDENCE_THRESHOLD`, confidence threshold for both OpenAI STT sites +- `TTS_MODEL`, `TTS_VOICE`, OpenAI speaking model and voice + - `PASEO_VOICE_LLM_PROVIDER`, voice agent provider override - `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, speech provider selection (`local` or `openai`) - `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT) @@ -120,8 +140,8 @@ Paseo uses these paths under the configured OpenAI base URL: - `PASEO_LOCAL_MODELS_DIR`, local model storage directory - `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID - `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs -- `PASEO_DICTATION_LANGUAGE`, dictation STT language (OpenAI STT only; ignored by local Parakeet) -- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset (OpenAI STT only; ignored by local Parakeet) +- `PASEO_DICTATION_LANGUAGE`, dictation STT language +- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset - `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning ## Operational Notes diff --git a/public-docs/worktrees.md b/public-docs/worktrees/index.md similarity index 88% rename from public-docs/worktrees.md rename to public-docs/worktrees/index.md index 5bb1db793f..faabb2d81f 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees/index.md @@ -35,6 +35,8 @@ With a custom root, Paseo keeps the same hashed layout under that directory: } ``` +Plugins can choose a location anywhere on disk with [`worktree.create.before`](/docs/plugins/hooks#worktreecreatebefore). The [worktree-placement example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement) places worktrees next to the source checkout. Paseo validates the path, refuses an existing destination, and manages listing, archive, and deletion there. Recovery uses the recorded path without running creation hooks. + 1. Create a workspace with worktree isolation, Paseo creates the worktree and runs your setup hooks 2. Launch one or more agents in that workspace 3. Review the diff against the base branch @@ -80,6 +82,10 @@ paseo workspace create \ Add `--forge ` when Paseo cannot infer the forge from the source checkout. +## Copy local files with .worktreeinclude + +Use [`.worktreeinclude`](/docs/worktrees/worktreeinclude) to copy selected gitignored files, such as `.env`, from your source checkout before setup runs. + ## paseo.json Drop a `paseo.json` in your repo root. Paseo reads it from the committed version of the base branch you picked, so uncommitted changes in other branches don't apply. @@ -99,12 +105,14 @@ Drop a `paseo.json` in your repo root. Paseo reads it from the committed version ## Setup and teardown -`setup` runs once after the worktree is created. A fresh worktree has no installed dependencies and no ignored files (like `.env`), so use setup to install and copy what you need. `teardown` runs during archive, before the directory is removed. +Plugins can use [`worktree.create.after`](/docs/plugins/hooks#worktreecreateafter) to write files before setup, terminals, and the first agent. + +`setup` runs once after the worktree is created. Use setup to install dependencies and run project initialization. Use [`.worktreeinclude`](#copy-local-files-with-worktreeinclude) to copy ignored files such as `.env`. `teardown` runs during archive, before the directory is removed. ```json { "worktree": { - "setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate", + "setup": "npm ci\nnpm run db:migrate", "teardown": "npm run db:drop || true" } } diff --git a/public-docs/worktrees.zh-CN.md b/public-docs/worktrees/index.zh-CN.md similarity index 88% rename from public-docs/worktrees.zh-CN.md rename to public-docs/worktrees/index.zh-CN.md index 0bf3af55a4..cce65fc090 100644 --- a/public-docs/worktrees.zh-CN.md +++ b/public-docs/worktrees/index.zh-CN.md @@ -33,6 +33,8 @@ worktree 默认存放在 `$PASEO_HOME/worktrees/` 下,按源检出路径的哈 } ``` +插件可以通过 [`worktree.create.before`](/docs/plugins/hooks#worktreecreatebefore) 将 worktree 放到磁盘上的自选位置。[worktree-placement 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/worktree-placement) 将其放在源检出目录旁边。Paseo 验证路径并拒绝已存在的目标,同时支持在该位置列出、归档和删除 worktree。恢复时使用记录的原路径,不运行创建钩子。 + 1. 创建一个使用 worktree 隔离的工作区,Paseo 会创建 worktree 并运行你的 setup 钩子 2. 在该工作区中启动一个或多个 Agent 3. 对照基础分支审查 diff @@ -78,6 +80,10 @@ paseo workspace create \ 当 Paseo 无法从源检出推断代码托管平台时,添加 `--forge `。 +## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude} + +使用 [`.worktreeinclude`](/docs/worktrees/worktreeinclude),在 setup 运行前将 `.env` 等选定的 Git 忽略文件从源检出复制到新 worktree。 + ## paseo.json {#paseojson} 在仓库根目录放一个 `paseo.json`。Paseo 从你所选基础分支的已提交版本中读取它,因此其他分支中未提交的改动不会生效。 @@ -97,12 +103,14 @@ paseo workspace create \ ## Setup 与 teardown {#setup-and-teardown} -`setup` 在 worktree 创建后运行一次。新的 worktree 没有安装依赖,也没有被忽略的文件(例如 `.env`),因此请用 setup 安装和复制你需要的内容。`teardown` 在归档期间、目录被删除之前运行。 +插件可以通过 [`worktree.create.after`](/docs/plugins/hooks#worktreecreateafter) 在 setup、终端和第一个 Agent 运行之前写入文件。 + +`setup` 在 worktree 创建后运行一次。使用 setup 安装依赖并初始化项目。使用 [`.worktreeinclude`](#copy-local-files-with-worktreeinclude) 复制 `.env` 等被忽略的文件。`teardown` 在归档期间、目录被删除之前运行。 ```json { "worktree": { - "setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate", + "setup": "npm ci\nnpm run db:migrate", "teardown": "npm run db:drop || true" } } diff --git a/public-docs/worktrees/worktreeinclude.md b/public-docs/worktrees/worktreeinclude.md new file mode 100644 index 0000000000..50c5f0370a --- /dev/null +++ b/public-docs/worktrees/worktreeinclude.md @@ -0,0 +1,61 @@ +--- +title: .worktreeinclude +description: Copy gitignored local files into new Paseo worktrees. +nav: .worktreeinclude +order: 12 +category: Workspaces +--- + +# .worktreeinclude + +Add `.worktreeinclude` at the root of your source checkout to copy local configuration into new [Git worktrees](/docs/worktrees). Paseo copies matching gitignored files before setup, terminals, and the first Agent start. + +## Copy your local configuration + +For a project whose `.gitignore` includes `.env` and `config/*.local.json`, create this `.worktreeinclude`: + +```gitignore +.env +config/*.local.json +``` + +When you create a worktree through Paseo, an existing `.env` and matching files such as `config/dev.local.json` are copied to the same relative paths in the new worktree. Their contents stay out of Git. + +Commit `.worktreeinclude` to share the patterns with your team. Each teammate supplies their own ignored files in the checkout they create worktrees from. + +## Pattern syntax + +Use gitignore syntax, with one pattern per line. Comments start with `#`. Prefix a pattern with `!` to exclude a match: + +```gitignore +# Local environment and configuration +.env +config/*.local.json +!config/production.local.json +``` + +A pattern only selects files that Git already ignores. Adding a path here does not ignore it, so also configure `.gitignore` when needed. Tracked files and untracked files that Git does not ignore are excluded. + +## When files are copied + +The built-in `worktree-include` plugin reads the file from the checkout you create the worktree from. That source can itself be another worktree. Keep the plugin enabled for copying to run. + +- Without `.worktreeinclude`, the plugin copies nothing. +- Matching files must exist in the source checkout. +- Existing destination files are skipped, preserving their contents. +- Copies happen during creation, with no ongoing synchronization between checkouts. +- A copy error aborts creation and removes the new worktree. Avoid including symlinked directories or nested repositories, which cannot be copied as files. + +## Run commands during setup + +Use `.worktreeinclude` for files that already exist locally. Use a [setup hook](/docs/worktrees#setup-and-teardown) to install dependencies, generate files, or fetch secrets: + +```json +{ + "worktree": { + "setup": "npm ci" + } +} +``` + +Put this configuration in `paseo.json` on the base branch used for the new worktree. Copied files are available when setup runs. diff --git a/public-docs/worktrees/worktreeinclude.zh-CN.md b/public-docs/worktrees/worktreeinclude.zh-CN.md new file mode 100644 index 0000000000..e80e386a01 --- /dev/null +++ b/public-docs/worktrees/worktreeinclude.zh-CN.md @@ -0,0 +1,61 @@ +--- +title: .worktreeinclude +description: 将 Git 忽略的本地文件复制到新的 Paseo worktree。 +nav: .worktreeinclude +order: 12 +category: Workspaces +--- + +# .worktreeinclude + +在源检出的根目录添加 `.worktreeinclude`,将本地配置复制到新的 [Git worktree](/docs/worktrees)。Paseo 会在 setup、终端和首个 Agent 启动之前复制匹配的 Git 忽略文件。 + +## 复制本地配置 {#copy-your-local-configuration} + +如果项目的 `.gitignore` 包含 `.env` 和 `config/*.local.json`,创建以下 `.worktreeinclude`: + +```gitignore +.env +config/*.local.json +``` + +通过 Paseo 创建 worktree 时,已有的 `.env` 和 `config/dev.local.json` 等匹配文件会被复制到新 worktree 中的相同相对路径。文件内容仍不会纳入 Git。 + +提交 `.worktreeinclude`,与团队共享这些模式。每位团队成员在用于创建 worktree 的检出中提供自己的忽略文件。 + +## 模式语法 {#pattern-syntax} + +使用 gitignore 语法,每行一个模式。注释以 `#` 开头。模式前加 `!` 可排除匹配项: + +```gitignore +# Local environment and configuration +.env +config/*.local.json +!config/production.local.json +``` + +模式只会选择 Git 已忽略的文件。在此添加路径不会使 Git 忽略该文件,需要时还应配置 `.gitignore`。已跟踪的文件和未被 Git 忽略的未跟踪文件都不会被复制。 + +## 文件复制时机 {#when-files-are-copied} + +内置的 `worktree-include` 插件从用于创建 worktree 的检出读取该文件。源检出也可以是另一个 worktree。保持该插件启用才能执行复制。 + +- 没有 `.worktreeinclude` 时,插件不会复制任何文件。 +- 匹配的文件必须存在于源检出中。 +- 目标中已存在的文件会被跳过,保留原有内容。 +- 复制发生在创建期间,之后不会在检出之间持续同步。 +- 复制错误会中止创建并删除新 worktree。避免包含符号链接目录或嵌套仓库,因为它们无法作为文件复制。 + +## 在 setup 期间运行命令 {#run-commands-during-setup} + +使用 `.worktreeinclude` 复制本地已有文件。使用 [setup 钩子](/docs/worktrees#setup-and-teardown) 安装依赖、生成文件或获取密钥: + +```json +{ + "worktree": { + "setup": "npm ci" + } +} +``` + +将此配置放入新 worktree 所用基准分支的 `paseo.json` 中。setup 运行时,复制的文件已可使用。 diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index c16e772172..97720f46ec 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { relative as relativePath } from "node:path"; import test from "node:test"; import { runInNewContext } from "node:vm"; @@ -21,7 +24,10 @@ const gatedCiJobs = new Map([ "server-integration-windows", { name: "server-integration (windows-latest)", contract: "server" }, ], - ["server-tests-macos", { name: "server-tests (macos-14, file observation)", contract: "server" }], + [ + "server-tests-macos", + { name: "server-tests (macos-latest, file observation)", contract: "server" }, + ], ["desktop-tests-ubuntu", { name: "desktop-tests (ubuntu-latest)", contract: "desktop" }], ["desktop-tests-windows", { name: "desktop-tests (windows-latest)", contract: "desktop" }], ["app-tests", { name: "app-tests", contract: "app" }], @@ -154,6 +160,14 @@ test("focused contracts stay inside existing required checks", () => { assert.ok(!jobs.has("playwright-desktop")); }); +test("ordinary CI keeps app tests without prebuilding Android modules", () => { + const source = readFileSync(ciWorkflowPath, "utf8"); + const app = jobBlocks(source).get("app-tests").join("\n"); + assert.match(app, /npm run build:app-deps/); + assert.match(app, /npm run test --workspace=@getpaseo\/app/); + assert.doesNotMatch(source, /test:android|expo prebuild|gradlew|actions\/setup-java/); +}); + test("server builds exclude test utilities at every domain depth", () => { const tsconfig = JSON.parse(readFileSync(serverTsconfigPath, "utf8")); assert.ok(tsconfig.exclude.includes("src/server/**/test-utils/**")); @@ -480,14 +494,14 @@ test("Windows units and integration run independently with complete routed cover assert.equal(scripts.test, "npm run test:unit && npm run test:integration"); assert.equal(scripts["test:unit"], 'vitest run --fileParallelism --exclude "**/*.e2e.test.ts"'); const invocations = scripts["test:integration"].split(" && "); - // Each vitest start costs about half a minute on Windows, so the four + // Each vitest start costs about half a minute on Windows, so the // name-filtered files share one invocation and one combined filter. assert.equal(invocations.length, 2); assert.ok(invocations.every((command) => command.startsWith("vitest run --maxWorkers=1 "))); assert.doesNotMatch(invocations[0], / -t /); assert.equal( invocations[1], - 'vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses"', + 'vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history"', ); const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8"); assert.doesNotMatch(serverConfig, /maxWorkers/); @@ -530,3 +544,183 @@ test("Windows units and integration run independently with complete routed cover assert.equal(selected(integrationCondition, outputs, true), false); } }); + +test("macOS release retries only transient stapling failures without rebuilding the signed app", async (t) => { + const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8"); + const wrapper = workflow + .match(/<<'STAPLER'\n([\s\S]*?)\n STAPLER/)[1] + .replace(/^ /gm, ""); + const networkError = + 'Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."'; + const cases = [ + { + name: "success", + outcomes: [0], + output: "stapled", + command: "stapler", + expected: 0, + attempts: 1, + }, + { + name: "transient then success", + outcomes: [68, 0], + output: networkError, + command: "stapler", + expected: 0, + attempts: 2, + }, + { + name: "exhausted network retries", + outcomes: [68, 68, 68, 68, 0], + output: networkError, + command: "stapler", + expected: 68, + attempts: 4, + }, + { + name: "non-network staple error", + outcomes: [68, 0], + output: "ticket not found", + command: "stapler", + expected: 68, + attempts: 1, + }, + { + name: "different exit code", + outcomes: [1, 0], + output: networkError, + command: "stapler", + expected: 1, + attempts: 1, + }, + { + name: "notary submission is not retried", + outcomes: [68, 0], + output: networkError, + command: "notarytool", + expected: 68, + attempts: 1, + }, + ]; + for (const scenario of cases) { + await t.test(scenario.name, () => { + const directory = mkdtempSync(join(tmpdir(), "stapler-retry-")); + try { + const statePath = join(directory, "state.json"); + const realXcrun = join(directory, "real-xcrun"); + const wrapperPath = join(directory, "xcrun"); + writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); + writeFileSync(join(directory, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + writeFileSync( + statePath, + JSON.stringify({ outcomes: scenario.outcomes, output: scenario.output, calls: [] }), + ); + writeFileSync( + realXcrun, + `#!/usr/bin/env node +const fs = require("node:fs"); +const state = JSON.parse(fs.readFileSync(process.env.STAPLER_STATE, "utf8")); +state.calls.push(process.argv.slice(2)); +const code = state.outcomes.shift(); +fs.writeFileSync(process.env.STAPLER_STATE, JSON.stringify(state)); +console.log(state.output); +process.exit(code); +`, + { mode: 0o755 }, + ); + const args = [scenario.command, "staple", "-v", "Paseo signed.app"]; + const result = spawnSync("bash", [wrapperPath, ...args], { + encoding: "utf8", + env: { + ...process.env, + PASEO_REAL_XCRUN: realXcrun, + STAPLER_STATE: statePath, + PATH: `${directory}:${process.env.PATH}`, + }, + timeout: 5000, + }); + assert.equal(result.status, scenario.expected, result.stdout + result.stderr); + const state = JSON.parse(readFileSync(statePath, "utf8")); + assert.deepEqual( + state.calls, + Array.from({ length: scenario.attempts }, () => args), + ); + assert.match(result.stdout, /stapled|network connection was lost|ticket not found/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + } +}); + +// GitHub retires macOS 14 on 2026-11-02; brownouts already reject these jobs. +test("workflows avoid retired macOS 14 runners", () => { + for (const path of filesUnder(".github/workflows", (candidate) => /\.ya?ml$/.test(candidate))) { + assert.doesNotMatch( + readFileSync(new URL(path, repoRoot), "utf8"), + /macos-14(?:-large|-xlarge)?\b/, + path, + ); + } +}); + +test("macOS jobs use latest runners while preserving release architectures", () => { + for (const [workflow, jobId] of [ + [ciWorkflowPath, "server-tests-macos"], + [nixWorkflowPath, "build-desktop-darwin"], + ]) { + assert.match( + jobBlocks(readFileSync(workflow, "utf8")).get(jobId).join("\n"), + /^ runs-on: macos-latest$/m, + ); + } + const release = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8"); + assert.match(release, /runner: macos-latest\n\s+electron_arch: arm64/); + // GitHub has no standard macos-latest-intel alias; use the newest Intel image. + assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/); +}); + +test("Nix desktop packaging resolves the workspace-local builder", () => { + const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8"); + const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1]; + assert.ok(invocation, "Nix invokes the desktop packaging tool"); + const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-")); + try { + const desktop = join(directory, "packages/desktop"); + mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true }); + mkdirSync(join(desktop, "node_modules/electron-builder")); + writeFileSync( + join(desktop, "node_modules/electron-builder/package.json"), + JSON.stringify({ + name: "electron-builder", + version: "1.0.0", + bin: { "electron-builder": "../.bin/electron-builder" }, + }), + ); + writeFileSync( + join(directory, "package.json"), + JSON.stringify({ private: true, workspaces: ["packages/desktop"] }), + ); + writeFileSync( + join(desktop, "package.json"), + JSON.stringify({ + name: "desktop-fixture", + private: true, + devDependencies: { "electron-builder": "1.0.0" }, + }), + ); + writeFileSync( + join(desktop, "node_modules/.bin/electron-builder"), + '#!/bin/sh\nprintf "workspace-builder\\n"\n', + { mode: 0o755 }, + ); + const result = spawnSync("sh", ["-c", `${invocation} --version`], { + cwd: desktop, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), "workspace-builder"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/scripts/oxlint/paseo-plugin.mjs b/scripts/oxlint/paseo-plugin.mjs index d6f8d0365e..af58021991 100644 --- a/scripts/oxlint/paseo-plugin.mjs +++ b/scripts/oxlint/paseo-plugin.mjs @@ -34,9 +34,130 @@ const noRawPressable = { }, }; +const SYNC_IO_MODULES = new Set(["fs", "node:fs", "child_process", "node:child_process"]); + +function propertyName(node) { + return node.type === "Identifier" ? node.name : node.value; +} + +function findDefinition(node, sourceCode) { + let scope = sourceCode.getScope(node); + while (scope && !scope.set.has(node.name)) scope = scope.upper; + return scope?.set.get(node.name)?.defs[0]; +} + +function isIoNamespaceImport(definition) { + if (definition?.node.type === "TSImportEqualsDeclaration") { + return SYNC_IO_MODULES.has(definition.node.moduleReference.expression?.value); + } + if (definition?.type !== "ImportBinding") return false; + return ( + (definition.node.type !== "ImportSpecifier" || + propertyName(definition.node.imported) === "default") && + SYNC_IO_MODULES.has(definition.parent.source.value) + ); +} + +// Follow only immutable namespace aliases. Resolve bindings rather than spelling so a parameter +// named `fs` is not mistaken for the imported namespace. +function isIoNamespace(node, sourceCode, seen = new Set()) { + if (!node || seen.has(node)) return false; + seen.add(node); + if (node.type === "AwaitExpression") return isIoNamespace(node.argument, sourceCode, seen); + if (node.type === "ImportExpression") return SYNC_IO_MODULES.has(node.source.value); + if (node.type === "CallExpression") { + return ( + node.callee.type === "Identifier" && + node.callee.name === "require" && + SYNC_IO_MODULES.has(node.arguments[0]?.value) + ); + } + if (node.type !== "Identifier") return false; + const definition = findDefinition(node, sourceCode); + if (isIoNamespaceImport(definition)) return true; + return ( + definition?.type === "Variable" && + definition.parent.kind === "const" && + definition.node.id.type === "Identifier" && + isIoNamespace(definition.node.init, sourceCode, seen) + ); +} + +const noSyncIo = { + meta: { + schema: [ + { + type: "object", + properties: { + allow: { + type: "object", + additionalProperties: { type: "array", items: { type: "string" } }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = context.filename.replaceAll("\\", "/"); + const relativeFile = filename.split("/packages/server/src/")[1]; + const allowed = new Set(context.options[0]?.allow?.[relativeFile] ?? []); + const unused = new Set(allowed); + function check(node, name) { + if (typeof name !== "string" || !name.endsWith("Sync")) return; + if (allowed.has(name)) { + unused.delete(name); + return; + } + context.report({ + node, + message: `Synchronous Node IO (${name}) blocks the daemon event loop. Use an asynchronous capability at the owning module boundary.`, + }); + } + return { + "Program:exit"(node) { + for (const name of unused) { + context.report({ node, message: `Remove the unused sync IO exception for ${name}.` }); + } + }, + ImportDeclaration(node) { + if (node.importKind === "type" || !SYNC_IO_MODULES.has(node.source.value)) return; + for (const specifier of node.specifiers) { + if (specifier.type === "ImportSpecifier" && specifier.importKind !== "type") { + check(specifier, propertyName(specifier.imported)); + } + } + }, + ExportNamedDeclaration(node) { + if (node.exportKind === "type" || !SYNC_IO_MODULES.has(node.source?.value)) return; + for (const specifier of node.specifiers) { + if (specifier.exportKind !== "type") check(specifier, propertyName(specifier.local)); + } + }, + MemberExpression(node) { + if (node.computed && node.property.type !== "Literal") return; + const name = propertyName(node.property); + if (typeof name !== "string" || !name.endsWith("Sync")) return; + if (isIoNamespace(node.object, context.sourceCode)) { + check(node.property, name); + } + }, + VariableDeclarator(node) { + if (node.id.type !== "ObjectPattern" || !isIoNamespace(node.init, context.sourceCode)) { + return; + } + for (const property of node.id.properties) { + if (property.type === "Property") check(property, propertyName(property.key)); + } + }, + }; + }, +}; + export default { meta: { name: "paseo" }, rules: { "no-raw-pressable": noRawPressable, + "no-sync-io": noSyncIo, }, }; diff --git a/scripts/oxlint/paseo-plugin.test.mjs b/scripts/oxlint/paseo-plugin.test.mjs index c30a429026..4312f5e337 100644 --- a/scripts/oxlint/paseo-plugin.test.mjs +++ b/scripts/oxlint/paseo-plugin.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; @@ -10,15 +10,21 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../ const oxlintBin = path.join(repoRoot, "node_modules/.bin/oxlint"); const pluginPath = path.join(repoRoot, "scripts/oxlint/paseo-plugin.mjs"); -function lint(source) { +function lint(source, rule = "paseo/no-raw-pressable", options, filename = "probe.tsx") { + const config = JSON.stringify({ + jsPlugins: [pluginPath], + rules: { [rule]: options ? ["error", options] : "error" }, + }); + return lintWithConfig(source, config, filename); +} + +function lintWithConfig(source, config, filename) { const dir = mkdtempSync(path.join(tmpdir(), "paseo-oxlint-")); try { - writeFileSync( - path.join(dir, ".oxlintrc.json"), - JSON.stringify({ jsPlugins: [pluginPath], rules: { "paseo/no-raw-pressable": "error" } }), - ); - writeFileSync(path.join(dir, "probe.tsx"), source); - const result = spawnSync(oxlintBin, ["-c", ".oxlintrc.json", "probe.tsx"], { + writeFileSync(path.join(dir, ".oxlintrc.json"), config); + mkdirSync(path.dirname(path.join(dir, filename)), { recursive: true }); + writeFileSync(path.join(dir, filename), source); + const result = spawnSync(oxlintBin, ["-c", ".oxlintrc.json", filename], { cwd: dir, encoding: "utf8", }); @@ -52,3 +58,95 @@ test("press primitives and pressable types are allowed", () => { ); assert.equal(result.failed, false, result.output); }); + +for (const source of [ + 'import { readFileSync as read } from "node:fs"; export { read };', + 'import { execSync } from "child_process"; export { execSync };', + 'import fs from "node:fs"; export const read = fs.readFileSync;', + 'import { default as fs } from "node:fs"; export const read = fs.readFileSync;', + 'import fs = require("node:fs"); export const read = fs.readFileSync;', + 'import * as fs from "fs"; export const read = fs["readFileSync"];', + 'import cp from "node:child_process"; export const run = cp.spawnSync;', + 'const { readFileSync: read } = require("node:fs"); export { read };', + 'const fs = require("fs"); export const read = fs.readFileSync;', + 'export const run = require("node:child_process").execFileSync;', + 'import fs from "node:fs"; const other = fs; export const read = other.statSync;', + 'export { readFileSync as read } from "node:fs";', + 'export const read = (await import("node:fs")).readFileSync;', +]) { + test(`sync IO is reported: ${source}`, () => { + const result = lint(source, "paseo/no-sync-io"); + assert.equal(result.failed, true, result.output); + assert.match(result.output, /Synchronous Node IO/); + }); +} + +test("async IO, types, unrelated methods and shadowed namespaces are allowed", () => { + const result = lint( + 'import fs from "node:fs"; import { readFile } from "node:fs/promises";\n' + + 'import type { readFileSync } from "node:fs";\n' + + "export const read = readFile; export const asyncRead = fs.promises.readFile;\n" + + "export function unrelated(fs: { readFileSync(): void }) { fs.readFileSync(); }\n" + + "export type Reader = typeof readFileSync;\n", + "paseo/no-sync-io", + ); + assert.equal(result.failed, false, result.output); +}); + +test("sync IO exceptions allow only named APIs in the named server module", () => { + const options = { allow: { "server/startup.ts": ["readFileSync"] } }; + const source = 'import { readFileSync } from "node:fs"; export { readFileSync };'; + const allowed = lint( + source, + "paseo/no-sync-io", + options, + "packages/server/src/server/startup.ts", + ); + assert.equal(allowed.failed, false, allowed.output); + const otherFile = lint( + source, + "paseo/no-sync-io", + options, + "packages/server/src/server/session.ts", + ); + assert.equal(otherFile.failed, true, otherFile.output); + assert.match(otherFile.output, /Synchronous Node IO/); + const otherApi = lint( + 'import fs from "node:fs"; export const read = fs.statSync;', + "paseo/no-sync-io", + options, + "packages/server/src/server/startup.ts", + ); + assert.equal(otherApi.failed, true, otherApi.output); + assert.match(otherApi.output, /Synchronous Node IO/); +}); + +test("repository config guards request paths while allowing startup APIs and test fixtures", () => { + const config = readFileSync(path.join(repoRoot, ".oxlintrc.json"), "utf8").replace( + '"./scripts/oxlint/paseo-plugin.mjs"', + JSON.stringify(pluginPath), + ); + const source = 'import { existsSync } from "node:fs"; export const probe = existsSync;'; + const request = lintWithConfig(source, config, "packages/server/src/server/probe.ts"); + assert.equal(request.failed, true, request.output); + assert.match(request.output, /Synchronous Node IO/); + for (const filename of [ + "packages/server/src/server/config.ts", + "packages/server/src/server/probe.test.ts", + "packages/app/src/probe.ts", + ]) { + const result = lintWithConfig(source, config, filename); + assert.equal(result.failed, false, result.output); + } +}); + +test("sync IO exceptions must be removed when a module stops using the API", () => { + const result = lint( + 'import { readFile } from "node:fs/promises"; export const read = readFile;', + "paseo/no-sync-io", + { allow: { "server/startup.ts": ["readFileSync"] } }, + "packages/server/src/server/startup.ts", + ); + assert.equal(result.failed, true, result.output); + assert.match(result.output, /Remove the unused sync IO exception for readFileSync/); +}); diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index d82af94788..3dbbeddcaf 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -14,17 +14,35 @@ const patchedPackages = [ nodeModulesPath: "node_modules/expo-modules-core", patchPrefix: "expo-modules-core+", }, + // Backport https://github.com/software-mansion/enriched-markdown/pull/924: preserve settled + // Android measurements across parent clones. Remove those hunks when upgrading past + // 1.2.0-nightly-20261019-926ef06f0; reconcile the other local patch hunks separately. { nodeModulesPath: "packages/app/node_modules/react-native-enriched-markdown", patchPrefix: "react-native-enriched-markdown+", cwd: "packages/app", }, - // Remove after react-native-unistyles ships + // Registry cleanup: remove after react-native-unistyles ships // https://github.com/jpudysz/react-native-unistyles/pull/1203. + // Adaptive themes: keep until colorScheme reads no longer consume listener-owned media changes. + // withUnistyles: also keep the uniProps filter until upstream consumes its control prop. + // Android configuration: backport 3.5.3; remove the two NativePlatform hunks at >= 3.5.3. + // https://github.com/jpudysz/react-native-unistyles/compare/v3.5.2...v3.5.3 + // Layout constraints: backport of https://github.com/jpudysz/react-native-unistyles/pull/1357 + // (commit from the root the commit hands us, so a concurrent window-size commit is kept). + // Remove the ShadowTreeManager hunk when the installed release includes that PR. { nodeModulesPath: "node_modules/react-native-unistyles", patchPrefix: "react-native-unistyles+", }, + // Backport of https://github.com/software-mansion/react-native-reanimated/pull/10435 + // (Reanimated 4.7.1): skip synchronous Android prop updates for unmounted view tags + // instead of throwing and printing a full stack trace on the UI thread per tag per event. + // Remove when the installed Reanimated is >= 4.7.1. + { + nodeModulesPath: "node_modules/react-native-reanimated", + patchPrefix: "react-native-reanimated+", + }, { nodeModulesPath: "node_modules/react-native-draggable-flatlist", patchPrefix: "react-native-draggable-flatlist+", @@ -37,10 +55,15 @@ const patchedPackages = [ // modal stack (see packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx), and // keeps a dismissing modal's status through its final onChange(-1), so a modal with // enableDismissOnClose=false that unmounts while presented still leaves the portal. + // Dimensions subscription cleanup: https://github.com/gorhom/react-native-bottom-sheet/issues/2734 + // and https://github.com/gorhom/react-native-bottom-sheet/pull/2751. Remove the useAnimatedLayout + // hunks once an installed release includes the cleanup; retain the other local hunks. { nodeModulesPath: "node_modules/@gorhom/bottom-sheet", patchPrefix: "@gorhom+bottom-sheet+", }, + // Android resize: retain until upstream clears descendant geometry when the SVG canvas resizes. + // The native pixel regression is included in the patch; preserve the separate transform hunks. { nodeModulesPath: "packages/app/node_modules/react-native-svg", patchPrefix: "react-native-svg+", diff --git a/scripts/postinstall-patches.test.mjs b/scripts/postinstall-patches.test.mjs index 9e4d4c0722..11cd2d157a 100644 --- a/scripts/postinstall-patches.test.mjs +++ b/scripts/postinstall-patches.test.mjs @@ -10,6 +10,43 @@ const bin = path.resolve("node_modules", ".bin"); const packageName = "react-native-draggable-flatlist"; const patchFile = `${packageName}+4.0.3.patch`; +for (const platform of ["ios", "android"]) { + test(`patched Markdown ships ${platform} props generated from its native specs`, () => { + const packageDir = path.resolve("packages/app/node_modules/react-native-enriched-markdown"); + const output = mkdtempSync(path.join(tmpdir(), `markdown-${platform}-codegen-`)); + try { + // includesGeneratedCode makes native builds use the shipped headers instead of + // regenerating them after patch-package changes the TypeScript specs. + const result = spawnSync( + process.execPath, + [ + path.resolve("node_modules/react-native/scripts/generate-codegen-artifacts.js"), + "-p", + packageDir, + "-t", + platform, + "-o", + output, + "-s", + "library", + ], + { encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stdout + result.stderr); + const header = + platform === "ios" + ? "ReactCodegen/EnrichedMarkdownTextSpec/Props.h" + : "jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h"; + assert.equal( + readFileSync(path.join(packageDir, platform, "generated", header), "utf8"), + readFileSync(path.join(output, header), "utf8"), + ); + } finally { + rmSync(output, { recursive: true, force: true }); + } + }); +} + function createCheckout() { const root = mkdtempSync(path.join(tmpdir(), "postinstall-patches-")); writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "checkout" })); diff --git a/scripts/test-support/npm-registry.mts b/scripts/test-support/npm-registry.mts index a0a39e27d9..5640487049 100644 --- a/scripts/test-support/npm-registry.mts +++ b/scripts/test-support/npm-registry.mts @@ -50,6 +50,7 @@ export async function startNpmRegistry(packages: FixturePackage[]) { `registry=${url}/\n//127.0.0.1:${address.port}/:_authToken=fixture-token\ncache=${root}/cache\n`, ); try { + const prepared = []; for (const [index, pkg] of packages.entries()) { const directory = path.join(root, String(index)); await mkdir(directory); @@ -65,11 +66,17 @@ export async function startNpmRegistry(packages: FixturePackage[]) { await mkdir(path.dirname(path.join(directory, file)), { recursive: true }); await writeFile(path.join(directory, file), content); } - const { stdout } = await execCommand("npm", ["pack", "--ignore-scripts", "--json"], { - cwd: directory, - }); - const packed: Array<{ filename: string }> = JSON.parse(stdout); - const artifact = await readFile(path.join(directory, packed[0].filename)); + prepared.push({ directory, manifest, pkg, index }); + } + // Pack all fixtures in one npm process instead of paying startup once per package. + const { stdout } = await execCommand( + "npm", + ["pack", "--ignore-scripts", "--json", ...prepared.map(({ directory }) => directory)], + { cwd: root }, + ); + const packed: Array<{ filename: string }> = JSON.parse(stdout); + for (const { manifest, pkg, index } of prepared) { + const artifact = await readFile(path.join(root, packed[index].filename)); const artifactPath = `/artifacts/${index}.tgz`; artifacts.set(artifactPath, artifact); const published = { diff --git a/skills/paseo-advisor/SKILL.md b/skills/paseo-advisor/SKILL.md index 94d4558e5a..23868d9a2d 100644 --- a/skills/paseo-advisor/SKILL.md +++ b/skills/paseo-advisor/SKILL.md @@ -9,7 +9,7 @@ argument-hint: "[--profile ] " Single agent. Reads the situation you're in. Gives a judgment. You decide what to do — the advisor doesn't drive the work. -**User's request:** $ARGUMENTS +Use the user's request and current conversation as input, including any arguments supplied with the skill invocation. Optional syntax: `[--profile ] `. ## Prerequisites @@ -40,13 +40,15 @@ This is analysis only. Do NOT edit, create, or delete any files. Do NOT write co ## Forwarded skills -If `$ARGUMENTS` contains another skill reference — `/unslop`, `/unslop-risk`, `$unslop`, etc. — the user is asking the advisor to run that skill against the current task. Examples: +If the user's request contains another skill reference, such as `/unslop`, `/skill:unslop`, or `$unslop`, the user is asking the advisor to run that skill against the current task. Examples: - `/paseo-advisor /unslop` → advisor runs `/unslop` on the current diff. - `/paseo-advisor /unslop-risk` → advisor does an unslop-risk review. - `/paseo-advisor $diagnose this build failure` → advisor invokes `/diagnose`. +- `/skill:paseo-advisor /skill:unslop` → advisor runs the `unslop` skill on the current diff in Pi. +- `$paseo-advisor $unslop` → advisor runs the `unslop` skill on the current diff in Codex. -Parse the forwarded skill name out of `$ARGUMENTS` (`/` or `$`). In the briefing, tell the advisor explicitly: +Parse the forwarded skill name from the user's request (`/`, `/skill:`, or `$`), excluding this skill's own invocation. In the briefing, tell the advisor explicitly: ``` Invoke the `` skill against this task. Load it via the Skill tool before doing anything else. diff --git a/skills/paseo-committee/SKILL.md b/skills/paseo-committee/SKILL.md index 2b16189556..e41ca8ba81 100644 --- a/skills/paseo-committee/SKILL.md +++ b/skills/paseo-committee/SKILL.md @@ -8,7 +8,7 @@ user-invocable: true Two agents from contrasting profiles, fresh context, planning a solution in parallel. -**User's additional context:** $ARGUMENTS +Use the user's request and current conversation as input, including any arguments supplied with the skill invocation. ## Prerequisites diff --git a/skills/paseo-handoff/SKILL.md b/skills/paseo-handoff/SKILL.md index b321365306..fac6793e5e 100644 --- a/skills/paseo-handoff/SKILL.md +++ b/skills/paseo-handoff/SKILL.md @@ -8,7 +8,7 @@ user-invocable: true Transfer the current task — context, decisions, failed attempts, constraints — to a fresh agent. The receiving agent starts with **zero context**, so the handoff prompt must be a self-contained briefing. -**User's arguments:** $ARGUMENTS +Use the user's request and current conversation as input, including any arguments supplied with the skill invocation. ## Prerequisites diff --git a/skills/paseo-help/SKILL.md b/skills/paseo-help/SKILL.md index 951c1207bc..52dcf57a44 100644 --- a/skills/paseo-help/SKILL.md +++ b/skills/paseo-help/SKILL.md @@ -7,7 +7,7 @@ description: Answer questions about the Paseo product and app, including setup, You are helping a user understand, configure, or troubleshoot Paseo itself. Answer their question directly, verify the answer against the current public documentation, and include the relevant documentation link. Do not send the user away to read the docs in place of helping them. -**User's question:** $ARGUMENTS +Use the user's question and current conversation as input, including any arguments supplied with the skill invocation. ## Use current documentation diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 028d869085..b54938ad53 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -7,7 +7,7 @@ description: Build and manage trusted local Paseo plugins. Use when the user ask Build or manage the requested plugin directly. Use the current public docs to catch contract changes, but keep working from this skill if the network is unavailable. -**User's request:** $ARGUMENTS +Use the user's request and current conversation as input, including any arguments supplied with the skill invocation. ## Check current documentation @@ -27,28 +27,29 @@ public docs; this skill indexes the references and examples. Pick the contribution that matches the request. Each row names the registration, when it fits, and where the full contract lives. Most plugins combine several: a slash command that calls an RPC, which appends a timeline row, which a renderer draws. -| Contribution | Registration | Use it when | Reference | -| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items | -| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | -| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` | -| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | -| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands | -| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | -| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | -| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | -| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | -| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | -| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | -| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) | -| Creation and launch hooks | `server.before` | Change agent config, provider options, MCP servers, environment, or workspace isolation before the operation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) | -| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | +| Contribution | Registration | Use it when | Reference | +| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items | +| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels | +| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` | +| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items | +| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands | +| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills | +| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` | +| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon | +| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` | +| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` | +| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior | +| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) | +| Creation and launch hooks | `server.before` | `agent.create` edits saved config; `agent.session_open` contributes launch-only `env`, `tools`, `mcpServers` on every open; `workspace.create` chooses isolation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) | +| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK | -| Lifecycle task | Example | -| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | -| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | -| Inject MCP servers and change Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | +| Lifecycle task | Example | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Log all eleven hooks | [lifecycle-logger](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-logger) | +| Follow-ups, permissions, environment, provider switching, worktrees | [lifecycle-actions](https://github.com/getpaseo/paseo/tree/main/plugin-examples/lifecycle-actions) | +| Inject launch-only MCP servers and save Codex sandbox/approval options | [agent-configuration](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-configuration) | +| Attach a tool to selected agents | [agent-tools](https://github.com/getpaseo/paseo/tree/main/plugin-examples/agent-tools) | ## Create the project @@ -377,6 +378,35 @@ Inputs and outputs are validated on both sides. Backend handlers receive the sam Use TanStack Query for async request state, caching, and mutations. +### Contribute tools and MCP servers at session open + +Use `before("agent.session_open")` for per-agent tools and plugin-owned MCP servers. Add this +callback inside `contribute(server)`: + +```ts +server.before("agent.session_open", ({ request }) => { + if (request.provider !== "codex") return; + return { + ...request, + mcpServers: { + ...request.mcpServers, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }; +}); +``` + +`tools` and `mcpServers` start empty for each plugin and accumulate across its own callbacks. +They are launch-only. Use `agent.create` to edit configuration that should be saved with the agent. +See [Attach tools to agents](https://paseo.sh/docs/plugins/reference.md#attach-tools-to-agents) +for `defineAgentTool` and targeting, and +[Inject MCP servers](https://paseo.sh/docs/plugins/reference.md#inject-mcp-servers) for server configuration and collisions. + +Starting, stopping, or reloading a plugin with a session-open hook marks open agent sessions stale. +Each reopens before its next idle prompt without interrupting a running turn. +`await paseo.agents.ref(id).reload()` applies changes immediately and interrupts a running turn; +`refresh()` only reads the current snapshot. + ### Debug daemon-side behavior Backend contributions can use normal Node logging. `console.log()` writes to the plugin's stdout;