diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index a4a248e1cf..f37a8b0cdf 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: PASEO_HOME="$(mktemp -d)" export PASEO_LISTEN=127.0.0.1:6767 - WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log" + WRAPPER_LOG="$PASEO_HOME/paseo-cli.log" cleanup() { if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -47,7 +47,7 @@ jobs: } trap cleanup EXIT - PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 & + PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 & DAEMON_PID=$! deadline=$((SECONDS + 30)) @@ -57,7 +57,22 @@ jobs: echo "$STATUS_JSON" curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html" [[ -s "$PASEO_HOME/web-ui.html" ]] - exit 0 + if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then + break + fi + TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")" + ./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter + deadline=$((SECONDS + 15)) + while (( SECONDS < deadline )); do + if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \ + | jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then + ./result/bin/paseo terminal kill "$TERMINAL_ID" + exit 0 + fi + sleep 1 + done + echo "Nix terminal did not execute the command." + break fi if ! kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -72,7 +87,7 @@ jobs: cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "" echo "::endgroup::" - echo "::group::paseo-server stdout/stderr" + echo "::group::paseo daemon run stdout/stderr" cat "$WRAPPER_LOG" 2>/dev/null || echo "" echo "::endgroup::" diff --git a/.oxlintrc.json b/.oxlintrc.json index 269097aab4..d1566195e9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -84,6 +84,45 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + { + "files": [ + "plugins/*-provider/server/**/*.ts", + "packages/plugin/src/server/acp-internal/**/*.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + }, + { + "name": "child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + } + ] + } + ] + } + }, { "files": ["packages/app/src/**/*.{ts,tsx}"], "rules": { diff --git a/CHANGELOG.md b/CHANGELOG.md index 186dad677e..5219bda03e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,63 +1,40 @@ # Changelog -## 0.11.0-beta.3 - 2026-10-02 - -### Added - -- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876)) - -### Changed - -- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876)) - -### Fixed - -- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876)) -- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876)) -- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876)) - -## 0.11.0-beta.2 - 2026-10-01 - -### Added - -- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) -- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) - -### Changed - -- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844)) -- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844)) -- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844)) - -### Fixed - -- Fixed built-in plugins, including usage sources, failing to start in packaged desktop apps ([#5844](https://github.com/getpaseo/paseo/pull/5844)) -- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae)) -- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838)) - -## 0.11.0-beta.1 - 2026-10-01 +## 0.11.0-beta.4 - 2026-10-05 ### Added - Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95)) -- Added Antigravity as a provider, driving the installed `agy` CLI in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795)) -- Added Usage to the sidebar footer, showing each account's percent or the windows you pin ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805)) -- Added a Usage screen that follows the account each session spends, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes and OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795)) +- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921)) +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876)) - Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m)) - Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi)) - Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet)) -- Added Codex Fast and Ultrafast speeds from the Codex model catalog, with a lightning-bolt trigger on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708)) -- Added a Content width setting for the chat and Markdown preview ([#5680](https://github.com/getpaseo/paseo/pull/5680)) -- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753)) -- Added provider option defaults in `config.json` under `agents.providers..options`, with per-agent overrides, for every provider ([#5780](https://github.com/getpaseo/paseo/pull/5780)) -- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707)) -- Added sidebar header and footer items and screens to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685)) -- Added `activeTurnBehavior` to the SDK's `send()` and `run()` so a caller can steer instead of interrupt an active turn ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy)) +- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680)) +- Added `agents.providers..options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780)) - Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone)) +- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685)) +- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876)) +- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976)) +- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood)) +- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707)) ### Changed -- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805)) +- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975)) +- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708)) +- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942)) +- Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090)) +- Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805)) +- Changed the sidebar workspace row to show last activity by default on new installs ([#6087](https://github.com/getpaseo/paseo/pull/6087)) +- Changed compact sidebar footer buttons to 44×44 tap targets ([#6044](https://github.com/getpaseo/paseo/pull/6044)) - Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds)) - Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750)) - Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550)) @@ -65,19 +42,32 @@ ### Fixed +- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer)) +- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running ([#6067](https://github.com/getpaseo/paseo/pull/6067)) +- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022)) +- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074)) +- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov)) +- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045)) +- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang)) +- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765)) - Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx)) - Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550)) - Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD)) - Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil)) - Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork)) -- Fixed OMP failed tools, Task, Wait, web search, fetch, and `xd://` rows showing raw objects or generic labels ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Fixed OMP tool rows showing raw objects or generic labels for failed tools, Task, Wait, web search, and fetch ([#5550](https://github.com/getpaseo/paseo/pull/5550)) - Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi)) - Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff)) - Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan)) +- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061)) - Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ)) - Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755)) - Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826)) - Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93)) +- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul)) +- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988)) +- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae)) @@ -88,23 +78,39 @@ - Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774)) - Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804)) - Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796)) -- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765)) +- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970)) +- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970)) +- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968)) +- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu)) +- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio)) +- Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone)) +- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart)) +- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898)) +- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922)) +- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694)) - Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh)) - Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722)) - Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585)) -- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579)) -- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727)) -- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636)) -- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703)) -- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598)) -- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8)) -- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620)) -- Fixed the daemon logging `Agent not found` for every new agent started from a draft ([#5602](https://github.com/getpaseo/paseo/pull/5602)) -- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan)) -- Fixed a dialog opened over another sheet on iPad failing to present ([#5772](https://github.com/getpaseo/paseo/pull/5772)) +- Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058)) - Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang)) -- Fixed iPad chat text keeping its narrow width after the Explorer closes ([#5716](https://github.com/getpaseo/paseo/pull/5716)) +- Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716)) +- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598)) +- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703)) +- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636)) +- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed `paseo agent delete --all` and `--cwd` leaving archived agents behind ([#6027](https://github.com/getpaseo/paseo/pull/6027) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed `paseo agent` commands reporting a generic failure instead of `AGENT_NOT_FOUND` for an unknown agent ID ([#6053](https://github.com/getpaseo/paseo/pull/6053)) +- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727)) +- Fixed `paseo restart` exiting with `RESTART_NOT_CONFIRMED` when provider checks take more than a second ([#6009](https://github.com/getpaseo/paseo/pull/6009)) +- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet)) +- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan)) +- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579)) +- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620)) +- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838)) +- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy)) - Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815)) ## 0.10.3 - 2026-10-02 diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 9c4e793fb8..0d3983c343 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -11,7 +11,7 @@ canonical files and are trying to add or change one. | ---------------------------------------- | ----------------------------------------------------------------- | | `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet | | `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip | -| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal | +| `components/ui/hover-card.tsx` | Desktop-web hover card the pointer can move onto; Portal | | `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input | Each handles a different mix of concerns: combobox owns input focus, tooltip is @@ -92,7 +92,7 @@ portal can cover them. Painting and keyboard ownership use the same relative layer model. Register desktop modal, combobox, and dropdown focus scopes with `useWebOverlayRegistration`; the highest painted scope alone receives overlay keys, traps focus, and restores -focus when it closes. Do not add component-local global Escape listeners: two +focus when it closes. Hover cards register with `manageFocus={false}`: they claim overlay keys without moving or trapping focus on pointer hover. Do not add component-local global Escape listeners: two stacked overlays would both close on one keypress. If an overlay is rendered by a global host rather than beneath its opener in diff --git a/docs/glossary.md b/docs/glossary.md index 390acefa19..f9eb458173 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). -- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a tooltip on wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. Only the sheet has Refresh. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). -- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. +- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). +- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage screen on wide layouts and the same bottom sheet on phones. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). diff --git a/docs/hover.md b/docs/hover.md index 46f16af578..fe8b539edc 100644 --- a/docs/hover.md +++ b/docs/hover.md @@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor. -For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`. +For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content. Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook. @@ -150,5 +150,5 @@ Before opening a PR that touches hover: - [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger. - [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`). - [ ] A menu opened from the revealed trigger keeps the trigger rendered while it is open, so losing hover can't strand it. -- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up. +- [ ] If the revealed content sits in a separate layer (portal, floating panel) and the user can point at it, it is a `HoverCard`. - [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state. diff --git a/docs/menus.md b/docs/menus.md index 6791d28e71..50434b9e9d 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -197,7 +197,7 @@ its own. inactive. An async action can navigate before its menu closes; a hidden panel must not leave a portal backdrop blocking the destination. On web the chat suspends one commit after it goes inactive, and that inactive commit is where the surface unmounts. -- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix - positioning bugs there, not at a call site. +- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover + card, and are unit-tested. Fix positioning bugs there, not at a call site. - Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates, status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md). diff --git a/docs/plugins.md b/docs/plugins.md index 510ef68af1..387f24f46b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -248,7 +248,7 @@ dependency; shared types must not refer to React components, hooks, Node APIs, o | Entry | Owns | May depend on | | ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- | | `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only | -| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code | +| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code | | `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code | | `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code | | `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code | @@ -488,9 +488,9 @@ SVG or URL. ## Usage sources -Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. +Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage screen falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. -The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version. +The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. ## Contribute sidebar items diff --git a/docs/providers.md b/docs/providers.md index 47c39fd1a8..c5f37d4161 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -10,7 +10,9 @@ Keep a bundled provider in `plugins//` and register it through import rules belong to [plugins.md](plugins.md#built-in-plugins); the [public provider guide](../public-docs/plugins/providers.md) covers the provider contract. -The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns +The plugin owns the CLI transport, session state, catalog, and capabilities. Launch CLI transports +and probes through the [SDK process helpers](../public-docs/plugins/providers.md#launch-the-provider-cli), +which share Windows launcher handling with core providers. The daemon owns executable resolution and applies `agents.providers..command` and `env` before connecting. Register the provider's icon with the plugin rather than adding it to the app's provider icon map. You do not need a core manifest entry or provider factory. diff --git a/docs/release.md b/docs/release.md index 26af0182eb..cdac451f23 100644 --- a/docs/release.md +++ b/docs/release.md @@ -95,7 +95,7 @@ release push as the changelog and version commit. There are two supported release paths: 1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`). -2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`. +2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`. Paseo has one linear release track even though npm dist-tags are independent pointers. The npm invariant is: @@ -194,7 +194,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z - Desktop assets now come from the Electron package at `packages/desktop` - Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together. - Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published. -- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section. +- **A beta series has one changelog entry.** Each beta folds into it and renames its heading to the new `## X.Y.Z-beta.N`, which `Release Notes Sync` mirrors into that prerelease body. Promotion renames it to the stable heading. See the Changelog policy section. Use the beta path when you need to: @@ -414,7 +414,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w - Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`. - The default download target only moves when you publish the final stable release tag like `v0.1.41`. - The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them. -- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor. +- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor. - The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases. ## Fixing a failed release build @@ -521,17 +521,20 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or ## Changelog policy -- `CHANGELOG.md` includes stable releases and every entry in the current beta series. +- `CHANGELOG.md` holds stable releases and at most one entry for the current beta series. - The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`. -- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag. -- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry. -- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series. +- Each subsequent beta rewrites that entry in place: the heading becomes the new beta number and date, and the body describes the full change from the previous stable release to this beta. Never add a second beta entry. +- The entry is a list of changes, not of PRs. When a later beta reverts, reworks, or follows up on something an earlier beta added, rewrite the bullet to the current behavior or delete it. A stable user never saw the earlier beta behavior, so fixes to it are not "Fixed" bullets. +- Anything that already shipped to stable through a patch release on an older line stays out of the beta entry. +- Stable promotion renames the entry to `## 0.1.60 - YYYY-MM-DD` after a final review. + +Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry. ## Changelog ownership - **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent. - **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding. -- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it. +- Every entry, beta or stable, is drafted from the previous stable release to the release source. For a later beta, read the PRs since the previous beta tag, then rework the existing entry so each bullet still describes current behavior. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it. ## Changelog wording @@ -563,7 +566,7 @@ that changed, the failure that was fixed, or the capability that was added? If t claims a general improvement, rewrite it with the concrete change. - **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**. -- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior. +- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. - **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them. ## Changelog conciseness @@ -631,12 +634,7 @@ Use `git diff ..` as the review input. T Changelog scope follows the release being described: -- **First beta**: `previous stable tag → release source` -- **Later beta**: `previous beta tag → release source` -- **Direct stable release**: `previous stable tag → release source` -- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source` - -Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next. +Every entry covers `previous stable release → release source`: first beta, later beta, direct stable release, and promotion alike. The beta entry always reads as the stable entry would if the beta were promoted today. ## Completion checklist @@ -644,7 +642,7 @@ Each beta entry records what its testers receive. Promotion produces the single - [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green - [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog -- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release +- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release - [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved - [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag - [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully @@ -667,7 +665,7 @@ Each beta entry records what its testers receive. Promotion produces the single - [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog - [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command - [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command -- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release +- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release - [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format - [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag - [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index d00b382083..dae45db386 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -157,6 +157,9 @@ buildNpmPackage { cp -a "$path" "$out/share/paseo-desktop/$path" done < desktop-files.txt + # Shell hooks invoke the retained CLI bin directly, without a system Node. + patchShebangs --build "$out/share/paseo-desktop" + # Keep the same unpackaged monorepo layout expected by main.js. cp package.json $out/share/paseo-desktop/ mkdir -p $out/share/paseo-desktop/packages/app diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index fa431c78ff..338b70da5f 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc= +sha256-aAt1FdwSeAqg8u14oHHHf0B0/eN97qIqApcbxv2y3s0= diff --git a/nix/package.nix b/nix/package.nix index 417f3d2ad1..b032d88396 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -120,6 +120,9 @@ buildNpmPackage rec { cp -a "$path" "$out/lib/paseo/$path" done < daemon-files.txt + # Shell hooks invoke the retained CLI bin directly, without a system Node. + patchShebangs --build "$out/lib/paseo" + # Root package.json lets node resolve the workspace layout when the # CLI/server bin starts from $out. cp package.json $out/lib/paseo/ diff --git a/package-lock.json b/package-lock.json index 01d3bd7f4e..dd5b77c2f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -36413,7 +36413,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37496,12 +37496,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.11.0-beta.3", - "@getpaseo/protocol": "0.11.0-beta.3", - "@getpaseo/server": "0.11.0-beta.3", + "@getpaseo/client": "0.11.0-beta.4", + "@getpaseo/protocol": "0.11.0-beta.4", + "@getpaseo/server": "0.11.0-beta.4", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37748,10 +37748,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "dependencies": { - "@getpaseo/protocol": "0.11.0-beta.3", - "@getpaseo/relay": "0.11.0-beta.3", + "@getpaseo/protocol": "0.11.0-beta.4", + "@getpaseo/relay": "0.11.0-beta.4", "zod": "^4.4.3" }, "devDependencies": { @@ -37762,7 +37762,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -38006,7 +38006,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38902,7 +38902,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38931,14 +38931,14 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.11.0-beta.3", - "@getpaseo/protocol": "0.11.0-beta.3", + "@getpaseo/client": "0.11.0-beta.4", + "@getpaseo/protocol": "0.11.0-beta.4", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -38948,8 +38948,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.11.0-beta.3", - "@getpaseo/protocol": "0.11.0-beta.3", + "@getpaseo/client": "0.11.0-beta.4", + "@getpaseo/protocol": "0.11.0-beta.4", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -38971,7 +38971,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -38998,7 +38998,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -39216,16 +39216,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.11.0-beta.3", - "@getpaseo/highlight": "0.11.0-beta.3", - "@getpaseo/plugin": "0.11.0-beta.3", - "@getpaseo/protocol": "0.11.0-beta.3", - "@getpaseo/relay": "0.11.0-beta.3", + "@getpaseo/client": "0.11.0-beta.4", + "@getpaseo/highlight": "0.11.0-beta.4", + "@getpaseo/plugin": "0.11.0-beta.4", + "@getpaseo/protocol": "0.11.0-beta.4", + "@getpaseo/relay": "0.11.0-beta.4", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -40146,11 +40146,12 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", "@fontsource-variable/geist": "^5.3.0", + "@getpaseo/protocol": "*", "@tanstack/react-router": "^1.170.16", "@tanstack/react-start": "^1.168.26", "framer-motion": "^12.35.2", @@ -40165,8 +40166,7 @@ "remark-directive": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", - "wrangler": "^4.75.0", - "@getpaseo/protocol": "*" + "wrangler": "^4.75.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.8", @@ -41753,7 +41753,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index f27be7301c..da3fca62a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/e2e/browser/agent-relative-time.spec.ts b/packages/app/e2e/browser/agent-relative-time.spec.ts index 17dae013ec..4fe1efe887 100644 --- a/packages/app/e2e/browser/agent-relative-time.spec.ts +++ b/packages/app/e2e/browser/agent-relative-time.spec.ts @@ -4,7 +4,6 @@ import { getServerId } from "../support/helpers/server-id"; import { seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute } from "../support/helpers/mock-agent"; import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab"; -import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar"; test("agent list age advances and command center says just now for a fresh agent", async ({ page, @@ -41,9 +40,6 @@ test("agent list age advances and command center says just now for a fresh agent "3m ago", ); - await openSidebarDisplayPage(page, "sidebar-display-show"); - await page.getByRole("menuitem", { name: "Last activity", exact: true }).click(); - await closeSidebarDisplayPreferences(page); await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m"); } finally { await session.cleanup(); diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index 5d4838f133..e2d9e3c02b 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -120,6 +120,41 @@ async function selectAssistantElement(page: Page, selector: string): Promise { + await assistantMessageBlocks(page).evaluateAll( + (blocks, selected) => { + let startNode: Text | null = null; + for (const block of blocks) { + const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + if (walker.currentNode.textContent?.endsWith(selected.startText)) { + startNode = walker.currentNode as Text; + } + } + } + const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean); + if (!startNode || !end) { + throw new Error(`Could not find ${selected.startText} — ${selected.selector}`); + } + const range = document.createRange(); + range.setStart(startNode, startNode.length); + range.setEnd(end, end.childNodes.length); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, + { startText, selector }, + ); +} + async function selectAssistantText(page: Page, text: string): Promise { await selectAssistantTextRange(page, text, text); } @@ -565,6 +600,19 @@ test("copying an assistant selection preserves Markdown structure and links", as const completeCodeClipboard = await readRichClipboard(page); expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;'); + // Touching the block before it without selecting any of its text is still a + // selection inside the code. + await selectAssistantElementFromEndOf( + page, + "not a generated link.", + '[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]', + ); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe( + 'const answer = "yes";\n return answer;', + ); + // Crossing the block boundary expresses an intent to carry its Markdown structure. await selectAssistantAcrossCodeLines(page, "const", "After code."); await copySelection(page); diff --git a/packages/app/e2e/browser/chat-find.spec.ts b/packages/app/e2e/browser/chat-find.spec.ts index 7ed957f513..0ce6c561ed 100644 --- a/packages/app/e2e/browser/chat-find.spec.ts +++ b/packages/app/e2e/browser/chat-find.spec.ts @@ -428,7 +428,16 @@ async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo } } -test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => { +test("opens and refocuses Find with Control+f from the composer on Linux", async ({ + page, +}, testInfo) => { + // Chromium retains the host navigator.platform even when its user agent is overridden. + const session = await page.context().newCDPSession(page); + await session.send("Emulation.setUserAgentOverride", { + userAgent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + platform: "Linux x86_64", + }); await findFromComposer(page, "Control+f", testInfo); }); diff --git a/packages/app/e2e/browser/codex-speed.spec.ts b/packages/app/e2e/browser/codex-speed.spec.ts index 654f15207d..ca8c3d8991 100644 --- a/packages/app/e2e/browser/codex-speed.spec.ts +++ b/packages/app/e2e/browser/codex-speed.spec.ts @@ -26,6 +26,10 @@ test.use({ }, }); +// Rendered light-theme colors: palette yellow 400 for Fast and Ultrafast, muted foreground for Normal. +const ACTIVE_SPEED_COLOR = "rgb(251, 191, 36)"; +const NORMAL_SPEED_COLOR = "rgb(113, 113, 122)"; + async function openSpeedSelector(page: Page): Promise { const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ }); const features = page.getByRole("button", { name: "Open agent features", exact: true }); @@ -51,6 +55,13 @@ async function selectSpeed(page: Page, label: string): Promise { else await expect(sheetTrigger).toContainText(label); } +async function expectSpeedIconColor(page: Page, color: string): Promise { + const trigger = page + .getByRole("button", { name: /^(Speed: .+|Select speed)$/ }) + .filter({ visible: true }); + await expect(trigger.locator("svg").first()).toHaveCSS("stroke", color); +} + async function expectSpeedChoices(page: Page, choices: string[]): Promise { for (const choice of ["Normal", "Fast", "Ultrafast"]) { const option = page.getByText(choice, { exact: true }).last(); @@ -100,15 +111,21 @@ for (const viewport of [ await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") }); } await openSpeedSelector(page); + await selectSpeed(page, "Fast"); + await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR); + await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-fast-selected.png`) }); + await openSpeedSelector(page); await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) }); await selectSpeed(page, "Ultrafast"); + await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR); await openSpeedSelector(page); await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`), }); await selectSpeed(page, "Normal"); + await expectSpeedIconColor(page, NORMAL_SPEED_COLOR); if (viewport.name === "desktop") { await switchAgentModel(agent.id, "gpt-6-sol"); await openSpeedSelector(page); diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 32a6d7048e..d46ae25d35 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -1,16 +1,21 @@ import type { Locator, Page } from "@playwright/test"; import { expect, test as base } from "../support/fixtures"; -import { expectComposerVisible } from "../support/helpers/composer"; +import { + composerLocator, + expectComposerVisible, + submitMessageWithButton, +} from "../support/helpers/composer"; import { type AgentUsageScript, closeContextWindowSheet, - contextWindowSheet, + contextWindowDetails, expectRefreshButtons, expiredLogin, gate, hoverContextWindowMeter, leaveContextWindowMeter, type MockAgentSession, + moveOntoContextWindowDetails, onPersonalLogin, onWorkLogin, openAgent, @@ -24,6 +29,7 @@ import { } from "../support/helpers/context-window"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item"; +import { installLoginUsage } from "../support/helpers/usage-login"; const test = base.extend<{ agent: MockAgentSession }>({ agent: async ({ page: _page }, provide) => { @@ -107,6 +113,66 @@ const DESKTOP = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/; +for (const layout of [ + { + name: "desktop", + viewport: DESKTOP, + open: hoverContextWindowMeter, + close: leaveContextWindowMeter, + }, + { + name: "compact", + viewport: COMPACT, + open: pressContextWindowMeter, + close: closeContextWindowSheet, + }, +]) { + test(`shows account usage before the first turn (${layout.name})`, async ({ page }) => { + test.setTimeout(180_000); + const source = await installLoginUsage({ + status: "available", + windows: [{ id: "session", label: "Session", usedPct: 31 }], + }); + try { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "context-window-empty-", + title: "Agent before its first turn", + }); + try { + await page.setViewportSize(layout.viewport); + await openAgent(page, agent); + + await test.step("the empty meter opens context details and account usage", async () => { + const details = await layout.open(page, "Context window: No context data"); + await expect(details.getByText("No context data", { exact: true })).toBeVisible(); + await expect(details.getByText("0% used", { exact: true })).toHaveCount(0); + await expect(details.getByText(/tokens$/)).toHaveCount(0); + const account = usageCard(details, "login-journey:account"); + await expect(account.getByText("Session", { exact: true })).toBeVisible(); + await expect(account.getByText("31%", { exact: true })).toBeVisible(); + await qaScreenshot(page, `context-without-data-${layout.name}`); + await layout.close(page); + }); + + await test.step("the first context reading replaces the empty state", async () => { + await submitMessageWithButton(page, "emit 32000 byte file agent stream payload"); + const details = await layout.open(page); + await expect(details.getByText("25% used", { exact: true })).toBeVisible(); + await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible(); + await expect(details.getByText("No context data", { exact: true })).toHaveCount(0); + await expect( + usageCard(details, "login-journey:account").getByText("31%", { exact: true }), + ).toBeVisible(); + }); + } finally { + await agent.cleanup(); + } + } finally { + await source.cleanup(); + } + }); +} + type OpenDetails = (page: Page) => Promise; type Shot = (state: string) => Promise; @@ -142,7 +208,6 @@ async function expectCardsToStreamIn( // Only the agent's login, never the host's default one, and no pins. await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0); await expectUnpinnableRows(details); - await expect(details.getByText("Updated just now", { exact: true })).toHaveCount(2); return details; } @@ -177,30 +242,50 @@ async function expectOnlyContextWindowWithoutUsage( } for (const theme of ["light", "dark"] as const) { - test(`the context window tooltip shows the agent's usage (${theme})`, async ({ page, agent }) => { + test(`the context window hover card shows the agent's usage (${theme})`, async ({ + page, + agent, + }) => { test.setTimeout(240_000); const [claude] = claudeAndCodexReports(); const usage = await scriptAgentUsage(page); await page.emulateMedia({ colorScheme: theme }); await page.setViewportSize(DESKTOP); await openAgent(page, agent); - const shot: Shot = (state) => qaScreenshot(page, `tooltip-desktop-${theme}-${state}`); + const shot: Shot = (state) => qaScreenshot(page, `hover-card-desktop-${theme}-${state}`); await test.step("reports stream in one card at a time, with accessible Refresh", async () => { - const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot); - await expectRefreshButtons(tooltip, 2); + const card = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot); + await expectRefreshButtons(card, 2); const meter = page.getByRole("button", { name: "Context window 25% used" }); await meter.focus(); - await page.keyboard.press("Tab"); - await expect(tooltip.getByRole("button", { name: "Refresh Claude" })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeFocused(); await page.mouse.move(0, 0); await page.waitForTimeout(150); - await expect(tooltip).toBeVisible(); + await expect(card).toBeVisible(); await shot("ready"); expect(usage.agentRequests()).toEqual([agent.agentId]); }); + await test.step("the card stays open while the pointer moves onto it", async () => { + const card = contextWindowDetails(page); + await moveOntoContextWindowDetails(page, card); + await page.waitForTimeout(300); + await expect(card).toBeVisible(); + }); + + await test.step("Refresh replaces the card with the source's new report", async () => { + usage.answerNext([expiredLogin(onWorkLogin(claude!))]); + const card = contextWindowDetails(page); + await refreshUsageCard(card, "Claude"); + await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); + }); + await test.step("after a resume under another login, only that login shows", async () => { + // Refresh can retain focus; return to the composer before testing pointer dismissal. + await composerLocator(page).focus(); // Closed while the old login's report is still on its way. await leaveContextWindowMeter(page); const slowSource = gate(); @@ -214,25 +299,25 @@ for (const theme of ["light", "dark"] as const) { usage.answerNext({ stream: [resumed.promise, onPersonalLogin(claude!), finished.promise], }); - const tooltip = await hoverContextWindowMeter(page); - await expect(tooltip.getByText("Loading usage...", { exact: true })).toBeVisible(); - await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0); + const card = await hoverContextWindowMeter(page); + await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible(); + await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0); // The old login's report lands during the new request, before the new login's. slowSource.open(); resumed.open(); await expect( - usageCard(tooltip, "claude:personal").getByText("personal@example.com", { exact: true }), + usageCard(card, "claude:personal").getByText("personal@example.com", { exact: true }), ).toBeVisible(); - await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0); + await expect(card.getByText("work@example.com", { exact: true })).toHaveCount(0); finished.open(); }); await test.step("a report with a problem shows it on the card", async () => { usage.answerNext([expiredLogin(onWorkLogin(claude!))]); await reloadAgent(page); - const tooltip = await hoverContextWindowMeter(page); - await expect(tooltip.getByText(LOGIN_EXPIRED)).toBeVisible(); + const card = await hoverContextWindowMeter(page); + await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); await shot("problem"); }); @@ -243,7 +328,7 @@ for (const theme of ["light", "dark"] as const) { await test.step("a host without usage reports shows only the context window", async () => { await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot); // One request per open that had usage; this one sends none. - expect(usage.agentRequests()).toHaveLength(5); + expect(usage.agentRequests()).toHaveLength(6); }); }); @@ -262,16 +347,18 @@ for (const theme of ["light", "dark"] as const) { await test.step("reports stream in one card at a time, each with Refresh", async () => { const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot); await expectRefreshButtons(sheet, 2); + // Without hover, each card prints its freshness instead of a Refresh tooltip. + await expect(sheet.getByText("Updated just now", { exact: true })).toHaveCount(2); await shot("ready"); expect(usage.agentRequests()).toEqual([agent.agentId]); }); await test.step("Refresh replaces the card with the source's new report", async () => { usage.answerNext([expiredLogin(onWorkLogin(claude!))]); - const sheet = contextWindowSheet(page); + const sheet = contextWindowDetails(page); await refreshUsageCard(sheet, "Claude"); await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible(); - expect(usage.refreshedReports()).toEqual([["claude:work"]]); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); await shot("problem"); }); @@ -285,7 +372,85 @@ for (const theme of ["light", "dark"] as const) { await test.step("a host without usage reports shows only the context window", async () => { await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot); - expect(usage.agentRequests()).toHaveLength(2); + expect(usage.agentRequests()).toHaveLength(3); }); }); } + +test("context details support keyboard entry and dismiss when the viewport moves", async ({ + page, + agent, +}) => { + const usage = await scriptAgentUsage(page); + await page.setViewportSize(DESKTOP); + await openAgent(page, agent); + await submitMessageWithButton(page, "withhold synthetic user message until interrupted"); + const stop = page.getByRole("button", { name: /stop|cancel/i }).first(); + await expect(stop).toBeVisible(); + const [claude] = claudeAndCodexReports(); + usage.answerNext([onPersonalLogin(claude!)]); + const meter = page.getByTestId("context-window-meter"); + const trigger = page.getByRole("button", { + name: (await meter.getAttribute("aria-label"))!, + exact: true, + }); + await expect(trigger).toHaveAttribute("tabindex", "0"); + await trigger.focus(); + const details = contextWindowDetails(page); + await expect(details).toBeVisible(); + await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeVisible(); + await trigger.press("ArrowDown"); + await expect(details.getByRole("button", { name: "Refresh Claude", exact: true })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(details).toHaveCount(0); + await expect(stop).toBeVisible(); + await expect(trigger).toBeFocused(); + for (const key of ["Enter", " "]) { + usage.answerNext([onPersonalLogin(claude!)]); + await trigger.press(key); + await expect(details).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(details).toHaveCount(0); + await expect(stop).toBeVisible(); + await expect(trigger).toBeFocused(); + } + usage.answerNext([onPersonalLogin(claude!)]); + await trigger.press("ArrowDown"); + await expect(details).toBeVisible(); + await page.setViewportSize({ width: 1280, height: 850 }); + await expect(details).toHaveCount(0); +}); + +test("pointer hovering context details preserves composer focus", async ({ page, agent }) => { + const usage = await scriptAgentUsage(page); + await page.setViewportSize(DESKTOP); + await openAgent(page, agent); + const [claude] = claudeAndCodexReports(); + usage.answerNext([onPersonalLogin(claude!)]); + const input = composerLocator(page); + await input.focus(); + await hoverContextWindowMeter(page); + await expect(input).toBeFocused(); + await leaveContextWindowMeter(page); + await expect(input).toBeFocused(); +}); + +test("Find remains available after dismissing context details", async ({ page, agent }) => { + const usage = await scriptAgentUsage(page); + await page.setViewportSize(DESKTOP); + await openAgent(page, agent); + const [claude] = claudeAndCodexReports(); + usage.answerNext([onPersonalLogin(claude!)]); + const meter = page.getByTestId("context-window-meter"); + const trigger = page.getByRole("button", { + name: (await meter.getAttribute("aria-label"))!, + exact: true, + }); + await trigger.focus(); + await expect(contextWindowDetails(page)).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(contextWindowDetails(page)).toHaveCount(0); + await expect(trigger).toBeFocused(); + await page.keyboard.press("ControlOrMeta+f"); + await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused(); +}); diff --git a/packages/app/e2e/browser/sidebar-help.spec.ts b/packages/app/e2e/browser/sidebar-help.spec.ts index 6d2ed6e96f..2d038abbdb 100644 --- a/packages/app/e2e/browser/sidebar-help.spec.ts +++ b/packages/app/e2e/browser/sidebar-help.spec.ts @@ -190,6 +190,9 @@ async function expectFingerSizedFooterTargets(page: Page): Promise { const bounds = await button.boundingBox(); expect(bounds?.width).toBeGreaterThanOrEqual(44); expect(bounds?.height).toBeGreaterThanOrEqual(44); + // Glyphs stay at the composer toolbar size (some optically smaller); only the target grows. + const glyphWidth = await button.locator("svg").first().getAttribute("width"); + expect(Number(glyphWidth)).toBeLessThanOrEqual(20); } } diff --git a/packages/app/e2e/browser/sidebar-title-width.spec.ts b/packages/app/e2e/browser/sidebar-title-width.spec.ts index 80f96dc68b..77b5a99eb5 100644 --- a/packages/app/e2e/browser/sidebar-title-width.spec.ts +++ b/packages/app/e2e/browser/sidebar-title-width.spec.ts @@ -88,6 +88,7 @@ async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) { await gotoAppShell(page); await openMobileAgentSidebar(page); await expect(workspaceRow(page)).toBeVisible(); + await toggleTrailing(page, "Diff stats", true); await page.mouse.move(0, 0); await page.screenshot({ path: testInfo.outputPath("sidebar.png") }); } @@ -106,6 +107,7 @@ async function openDesktopWorkspaceList(page: Page) { await gotoAppShell(page); const row = workspaceRow(page); await expect(row).toBeVisible(); + await toggleTrailing(page, "Diff stats"); await page.mouse.move(0, 0); await expect(row.getByText("+12.3k", { exact: true })).toBeVisible(); return titleWidth(row); diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 8528f4a0ea..1dfe89ae19 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -27,6 +27,8 @@ import { expectPinnedUsage, pinRow, openUsageOptions, + closeUsageOptions, + expectUsageOptionsFitContent, showUsageAs, refreshAllUsage, togglePin, @@ -243,7 +245,7 @@ test.describe("usage screen", () => { page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/), ).toBeVisible({ timeout: 10_000 }); await qaScreenshot(page, "phase7-usage-update-host"); - await expect(page.getByTestId("usage-options-toggle")).toHaveCount(0); + await expect(page.getByTestId("usage-options-menu")).toHaveCount(0); await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0); expect(usage.listRequests()).toHaveLength(0); }); @@ -320,14 +322,14 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag await gotoAppShell(page); await openUsage(page); await expect( - page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toBeVisible(); await expect(page.getByText("Unavailable", { exact: true })).toBeVisible(); await qaScreenshot(page, "usage-expired-login"); await fixture.setReport(loginWindows); await refreshLoginUsage(page); await expect( - page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toHaveCount(0); await expectPinnedUsage(page, ["31% 5h", "54% wk"]); const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / }); @@ -426,7 +428,7 @@ for (const theme of ["light", "dark"] as const) { desktop: { width: 1440, height: 900 }, compact: { width: 390, height: 844 }, })) { - test(`inline Usage Settings ${size} ${theme}`, async ({ page }, testInfo) => { + test(`Usage Settings cog ${size} ${theme}`, async ({ page }, testInfo) => { test.setTimeout(120_000); await page.addInitScript((value) => { const key = "@paseo:app-settings"; @@ -440,42 +442,24 @@ for (const theme of ["light", "dark"] as const) { await page.goto("/usage"); const screen = page.getByTestId(`usage-host-${getServerId()}`); await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 }); - const settings = screen.getByRole("button", { name: "Settings", exact: true }); - const chevron = settings.locator("svg").locator("../.."); + const settings = page.getByTestId("usage-options-menu"); await expect(settings).toHaveAccessibleName("Settings"); - await expect(settings).toHaveAttribute("aria-expanded", "false"); - await expect(chevron).toHaveCSS("transform", "none"); - await expect(screen.getByTestId("usage-display-used")).toHaveCount(0); - await expect( - pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-pinned"), - ).toHaveCSS("opacity", "1"); - await captureSettingsState(page, testInfo, `${size}-${theme}-collapsed`); + await expect(page.getByTestId("usage-display-used")).toHaveCount(0); + await captureSettingsState(page, testInfo, `${size}-${theme}-cog`); - await togglePin(screen, "Claude", "Weekly"); - await page.mouse.move(0, viewport.height - 1); - await expect( - pinRow(screen, "Claude", "Weekly").getByTestId("usage-pin-glyph-unpinned"), - ).toHaveCSS("opacity", "1"); await openUsageOptions(page); - await expect(settings).toHaveAttribute("aria-expanded", "true"); - await expect(chevron).toHaveCSS("transform", "matrix(0, 1, -1, 0, 0, 0)"); - await expect(screen.getByText("Pinned windows show in the sidebar footer")).toBeVisible(); - await expect(screen.getByTestId("usage-display-used")).toHaveAttribute( - "aria-selected", - "true", - ); - await settings.blur(); - await page.mouse.move(0, viewport.height - 1); - await captureSettingsState(page, testInfo, `${size}-${theme}-expanded`); + await expectUsageOptionsFitContent(page); + await expect(page.getByText("Pinned windows show in the sidebar footer")).toBeVisible(); + await expect(page.getByTestId("usage-display-used")).toHaveAttribute("aria-selected", "true"); + await captureSettingsState(page, testInfo, `${size}-${theme}-settings`); await showUsageAs(page, "remaining"); await expect(screen.getByText("69% left")).toBeVisible(); await showUsageAs(page, "used"); await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName( /^Pin Claude Session, 31% · resets /, ); - await settings.click(); - await expect(settings).toHaveAttribute("aria-expanded", "false"); - await expect(screen.getByTestId("usage-display-used")).toHaveCount(0); + await openUsageOptions(page); + await closeUsageOptions(page); await refreshAllUsage(page); await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]); }); @@ -490,3 +474,64 @@ async function captureSettingsState( await qaScreenshot(page, name); await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" }); } + +test("an account with all logins failing shows each harness and remedy, then only usage when one succeeds", async ({ + page, +}) => { + const fixture = await installLoginUsage([ + { harness: "Codex", report: { status: "error", error: "Usage API returned 500. Try again." } }, + { + harness: "OpenCode", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "opencode" }, + }, + }, + { + harness: "Pi", + report: { status: "unavailable", problem: { kind: "no_quota", detail: "No active plan" } }, + }, + { + harness: "OMP", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 403, refreshedBy: "omp" }, + }, + }, + ]); + try { + await gotoAppShell(page); + await openUsage(page); + await expectLoginErrors(page, [ + "Codex: Usage API returned 500. Try again.", + "OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.", + "Pi: No active plan", + "OMP: Login rejected (HTTP 403). Run omp to refresh it.", + ]); + await fixture.setReport([ + { + harness: "Codex", + report: { status: "error", error: "Usage API returned 500. Try again." }, + }, + { + harness: "OpenCode", + report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }, + }, + ]); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "42%"); + } finally { + await fixture.cleanup(); + } +}); + +async function expectLoginErrors(page: Page, lines: string[]) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByTestId("usage-login-error")).toHaveText(lines.join("\n")); +} + +async function expectLoginUsageOnly(page: Page, percentage: string) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByText(percentage, { exact: true })).toBeVisible(); + await expect(card.getByTestId("usage-login-error")).toHaveCount(0); +} diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index d6ceb2412a..ea98df9d0c 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -95,8 +95,8 @@ test.describe("usage settings", () => { await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible(); await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible(); - // Percentages now live on the Usage screen, and still apply to the host section. - await expect(card.getByTestId("usage-options-toggle")).toHaveCount(0); + // The shared percentages setting applies to the host section. + await expect(page.getByTestId("usage-options-menu")).toBeVisible(); const hostUsageUrl = page.url(); await page.goto("/usage"); await showUsageAs(page, "remaining"); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index c2da27b401..e43ccdc4a3 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -14,6 +14,9 @@ import { import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports, + closeUsageSheet, + closeUsageOptions, + openUsageSheetFromIcon, expectNoUsageItem, expectOnUsageScreen, expectPinnedUsage, @@ -62,6 +65,33 @@ async function footerClip(page: Page) { } test.describe("Usage item", () => { + test("both compact footer entry points open Usage as a sheet", async ({ page }) => { + await page.setViewportSize(COMPACT); + await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: false }]); + await gotoAppShell(page); + await openCompactSidebar(page); + const startingUrl = page.url(); + await expectNoUsageItem(page); + + await test.step("the icon opens a sheet without leaving the current screen", async () => { + await openUsageSheetFromIcon(page); + await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible(); + await expect(page).toHaveURL(startingUrl); + await qaScreenshot(page, "compact-usage-icon-sheet"); + await setSummaryInSidebar(page, true); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + await closeUsageSheet(page); + }); + + await test.step("the summary opens the same sheet", async () => { + await usageItem(page).click(); + await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible(); + await expect(page).toHaveURL(startingUrl); + await qaScreenshot(page, "compact-usage-summary-sheet"); + await closeUsageSheet(page); + }); + }); test("long usage reports stay in an 80% sheet and scroll to the final window", async ({ page, }) => { @@ -189,25 +219,22 @@ test.describe("Usage item", () => { await expect(pinRow(sheet, "Claude", "Session")).toHaveAccessibleName( /^Pin Claude Session, \d+% left( · .+)?$/, ); - // The sheet carries the same inline Settings row and title-row Refresh all. - await expect(sheet.getByTestId("usage-options-toggle")).toHaveAttribute( - "aria-expanded", - "false", - ); + // Settings opens a second sheet; closing it restores the Usage sheet underneath. + await expect(page.locator('[data-testid="usage-options-menu"]:visible')).toBeVisible(); await expect(page.locator('[data-testid="usage-refresh-all"]:visible')).toBeVisible(); await openUsageOptions(page); - await expect(sheet.getByTestId("usage-display-remaining")).toHaveAttribute( + await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute( "aria-selected", "true", ); await qaScreenshot(page, "compact-settings-expanded"); - await sheet.getByTestId("usage-options-toggle").click(); + await closeUsageOptions(page); await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0); await expect(sheet).toBeVisible(); await expect(page).not.toHaveURL(/\/usage$/); await waitForSettledPosition(sheet); const sheetBox = (await sheet.boundingBox())!; - // Settings adds a row; the sheet still reserves the top 20% as its backdrop. + // The Usage sheet reserves the top 20% as its backdrop. expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8); await qaScreenshot(page, "compact-sheet"); @@ -229,7 +256,7 @@ test.describe("Usage item", () => { "true", ); await qaScreenshot(page, "usage-settings-expanded"); - await page.getByTestId("usage-options-toggle").click(); + await closeUsageOptions(page); }); await test.step("the Settings usage section shares the pins and the toggle", async () => { diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index ab53705477..de8ddf0c9b 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -63,6 +63,12 @@ export async function submitMessage(page: Page, text: string): Promise { await input.press("Enter"); } +/** The Send button works on compact screens, where Enter inserts a newline. */ +export async function submitMessageWithButton(page: Page, text: string): Promise { + await fillComposerDraft(page, text); + await page.getByRole("button", { name: "Send message", exact: true }).click(); +} + export async function fillComposerDraft(page: Page, text: string): Promise { await composerInput(page).fill(text); } diff --git a/packages/app/e2e/support/helpers/context-window.ts b/packages/app/e2e/support/helpers/context-window.ts index b35a8373df..21341c7766 100644 --- a/packages/app/e2e/support/helpers/context-window.ts +++ b/packages/app/e2e/support/helpers/context-window.ts @@ -26,38 +26,50 @@ export async function openAgent(page: Page, session: MockAgentSession): Promise< } export async function reloadAgent(page: Page): Promise { - await page.reload({ waitUntil: "commit" }); + // Reload must not reopen the card under a stationary pointer. + await page.mouse.move(0, 0); + await page.reload({ waitUntil: "domcontentloaded" }); await expectComposerVisible(page); } -/** Wide screens show the meter's details in a tooltip while the pointer is on it. */ -export async function hoverContextWindowMeter(page: Page): Promise { - await page.getByRole("button", { name: METER_NAME }).hover({ timeout: 30_000 }); - const tooltip = page.getByRole("tooltip").filter({ hasText: "Context window" }); - await expect(tooltip).toBeVisible(); - return tooltip; +/** Wide screens show the meter's details in a hover card while the pointer is on it. */ +export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Promise { + await page.getByRole("img", { name, exact: true }).hover({ timeout: 30_000 }); + const card = contextWindowDetails(page); + await expect(card).toBeVisible(); + await expect(card).toBeInViewport(); + return card; +} + +/** Walks the pointer from the meter up onto its hover card, across the gap between them. */ +export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise { + const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox(); + const box = await card.boundingBox(); + if (!meter || !box) throw new Error("The meter and its hover card must be on screen."); + await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 }); } export async function leaveContextWindowMeter(page: Page): Promise { await page.mouse.move(0, 0); - await expect(page.getByRole("tooltip").filter({ hasText: "Context window" })).toHaveCount(0); + await expect(contextWindowDetails(page)).toHaveCount(0); } /** Compact screens open the meter's details in a sheet. */ -export async function pressContextWindowMeter(page: Page): Promise { - await page.getByRole("button", { name: METER_NAME }).click({ timeout: 30_000 }); - const sheet = contextWindowSheet(page); +export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise { + await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 }); + const sheet = contextWindowDetails(page); await expect(sheet).toBeVisible(); return sheet; } export async function closeContextWindowSheet(page: Page): Promise { - const sheet = contextWindowSheet(page); + const sheet = contextWindowDetails(page); await sheet.getByRole("button", { name: "Close", exact: true }).click(); await expect(sheet).toHaveCount(0); } -export function contextWindowSheet(page: Page): Locator { +/** The hover card on wide screens and the sheet on compact ones are both this dialog. */ +export function contextWindowDetails(page: Page): Locator { return page.getByRole("dialog", { name: "Context window" }); } @@ -122,8 +134,8 @@ export interface AgentUsageScript { stopReportingUsage(): void; /** The agent of each agent usage request, in order. */ agentRequests(): string[]; - /** Each report Refresh, by report ID. */ - refreshedReports(): string[][]; + /** Each scoped agent Refresh, by agent ID. */ + refreshedAgents(): string[]; } /** @@ -150,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise { supported = false; }, agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []), - refreshedReports: () => - usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])), + refreshedAgents: () => + usage + .listRequests() + .flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])), }; } @@ -159,7 +173,7 @@ export async function scriptAgentUsage(page: Page): Promise { export async function qaScreenshot(page: Page, name: string): Promise { const directory = process.env.PASEO_QA_SCREENSHOT_DIR; if (!directory) return; - // Let the sheet and tooltip animations settle. + // Let the sheet and hover card animations settle. await page.waitForTimeout(600); await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); await page.screenshot({ path: path.join(directory, `${name}.png`) }); diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 234ec2bf1b..985e7bd7ac 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -8,12 +8,15 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { pluginRequirements } from "./plugin-fixture"; /** A real store-backed plugin on the worker's isolated daemon. */ -export async function installLoginUsage(initialReport: UsageReport) { +type LoginReports = Array<{ harness: string; report: UsageReport }>; + +export async function installLoginUsage(initialReport: UsageReport | LoginReports) { const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); const store = path.join(directory, "report.json"); - const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report)); + const setReport = (report: UsageReport | LoginReports) => + writeFile(store, JSON.stringify(report)); const cleanup = async () => { try { await client.removePlugin("login-journey"); @@ -35,9 +38,13 @@ export async function installLoginUsage(initialReport: UsageReport) { import {readFile} from "node:fs/promises"; import {z} from "zod"; export default function contribute(server) { - server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}), - discover: async () => [{key: "account", input: {}}], - fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")), + const read = async () => { + const value = JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")); + return Array.isArray(value) ? value : [{harness: "Claude", report: value}]; + }; + server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({index: z.number()}), + discover: async () => (await read()).map((login, index) => ({key: "account", harness: login.harness, input: {index}})), + fetch: async ({index}) => (await read())[index].report, }); return () => {}; }`, diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index 7b012c171b..d21473867e 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -6,6 +6,7 @@ import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { expect, type Locator, type Page } from "@playwright/test"; import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { pluginRequirements } from "./plugin-fixture"; +import { waitForSettledPosition } from "./sheet-layout"; /** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */ export async function installTallUsageSource() { @@ -194,6 +195,21 @@ export async function openUsageScreenFromIcon(page: Page): Promise { await expectOnUsageScreen(page); } +/** Both footer entry points open Usage over the current screen on a phone. */ +export async function openUsageSheetFromIcon(page: Page): Promise { + await page.getByRole("button", { name: "Usage", exact: true }).click(); + await expect(usageSheet(page)).toBeVisible(); +} + +export async function closeUsageSheet(page: Page): Promise { + const sheet = usageSheet(page); + await waitForSettledPosition(sheet); + const bounds = await sheet.boundingBox(); + if (!bounds) throw new Error("Usage sheet must be visible before closing it."); + await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2); + await expect(sheet).toHaveCount(0); +} + function summaryInSidebarSwitch(page: Page): Locator { return page.getByRole("switch", { name: "Summary in sidebar", exact: true }); } @@ -208,15 +224,39 @@ export async function setSummaryInSidebar(page: Page, on: boolean): Promise { await openUsageOptions(page); await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on }); + await closeUsageOptions(page); } -/** Expand the inline Settings row when its controls are folded. */ +/** Open the cog's settings popover or compact sheet. */ export async function openUsageOptions(page: Page): Promise { - const toggle = visible(page, "usage-options-toggle"); - if ((await toggle.getAttribute("aria-expanded")) === "false") await toggle.click(); + if (!(await visible(page, "usage-display-used").isVisible())) { + await visible(page, "usage-options-menu").click(); + } await expect(visible(page, "usage-display-used")).toBeVisible(); } +export function usageOptionsSurface(page: Page): Locator { + return page + .locator( + '[data-testid="usage-options-surface"]:visible, [data-testid="usage-options-surface-content"]:visible', + ) + .first(); +} + +export async function expectUsageOptionsFitContent(page: Page): Promise { + await waitForSettledPosition(usageOptionsSurface(page)); + const surface = await usageOptionsSurface(page).boundingBox(); + const fields = await page.getByTestId("usage-options-fields").boundingBox(); + if (!surface || !fields) throw new Error("Settings must be visible before measuring its fit."); + expect(surface.y + surface.height - (fields.y + fields.height)).toBeLessThanOrEqual(10); +} + +export async function closeUsageOptions(page: Page): Promise { + await waitForSettledPosition(usageOptionsSurface(page)); + await page.mouse.click(0, 0); + await expect(page.getByTestId("usage-display-used")).toHaveCount(0); +} + export async function showUsageAs(page: Page, displayAs: "used" | "remaining") { await openUsageOptions(page); await visible(page, `usage-display-${displayAs}`).click(); @@ -224,6 +264,7 @@ export async function showUsageAs(page: Page, displayAs: "used" | "remaining") { "aria-selected", "true", ); + await closeUsageOptions(page); } export async function refreshAllUsage(page: Page): Promise { diff --git a/packages/app/package.json b/packages/app/package.json index a44656bc18..5ed29a2e22 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/app/src/agent-controls/policy.ts b/packages/app/src/agent-controls/policy.ts index 827dfadf57..89a4a86daa 100644 --- a/packages/app/src/agent-controls/policy.ts +++ b/packages/app/src/agent-controls/policy.ts @@ -2,6 +2,7 @@ import type { AgentMode } from "@getpaseo/protocol/agent-types"; export const PLAN_MODE_FEATURE_ID = "plan_mode"; export const FAST_MODE_FEATURE_ID = "fast_mode"; +export const SPEED_FEATURE_ID = "service_tier"; export function isPlanningAgentMode(mode: Pick): boolean { return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan"); diff --git a/packages/app/src/assistant-selection-copy/content.browser.test.ts b/packages/app/src/assistant-selection-copy/content.browser.test.ts index 04aa28bd99..995927f106 100644 --- a/packages/app/src/assistant-selection-copy/content.browser.test.ts +++ b/packages/app/src/assistant-selection-copy/content.browser.test.ts @@ -320,6 +320,32 @@ describe("assistant selection copy ranges", () => { ).toBe("6. Second bullet text"); }); + it("keeps the next number when a drag crosses a code block nested in an item", () => { + const message = mountFixture(); + const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]'); + list.outerHTML = [ + '
', + '
', + '
1.
', + "
Install it:
", + '
', + '
brew install foo
', + '', + "
", + "
", + '
', + '
2.
', + "
Done.
", + "
", + "
", + ].join(""); + + const content = copyAcross(message, "Install it:", "Done."); + + expect(content?.plainText).toBe("Install it:\n\n```sh\nbrew install foo\n```\n\n2. Done."); + expect(content?.html).toContain("
2. Done.
"); + }); + it("retains nested markers when a drag includes the complete outer item", () => { const message = mountFixture(); const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]'); @@ -405,10 +431,19 @@ describe("assistant selection copy ranges", () => { * Turndown, so those cases need a fixture shaped like the real thing. */ function highlightedFixture(language: string | null): string { + return [ + '
', + '
Before the block.
', + highlightedCodeBlock(language), + '
After the block.
', + "
", + ].join(""); +} + +function highlightedCodeBlock(language: string | null): string { const languageAttribute = language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`; return [ - '
', `
`, '', "const answer = 1;", @@ -419,8 +454,6 @@ function highlightedFixture(language: string | null): string { "", '
Copy
', "
", - '
After the block.
', - "
", ].join(""); } @@ -598,6 +631,63 @@ describe("assistant selection copy inside highlighted code", () => { ); }); + // A drag that starts or ends in the gap beside a block anchors the selection at the + // edge of the neighbouring block. Nothing outside the code is highlighted or selected. + it("copies code without a fence when the selection only touches the end of the block before it", () => { + const message = mountHighlighted(); + const before = tokenText(message, "Before the block."); + + const content = copyBetween( + [before, before.length], + [tokenText(message, " doThing();"), 14], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("copies code without a fence when the selection only touches the start of the block after it", () => { + const message = mountHighlighted(); + + const content = copyBetween( + [tokenText(message, "const"), 0], + [tokenText(message, "After the block."), 0], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("copies code without a fence when the selection only touches the row before the block", () => { + const transcript = mountTranscript([{ messageId: "message-1", blocks: ["Run this:", "code"] }]); + const codeRow = transcript.querySelectorAll('[data-testid="assistant-message"]')[1]!; + codeRow.innerHTML = highlightedCodeBlock("typescript"); + const before = tokenText(transcript, "Run this:"); + + const content = copyBetween( + [before, before.length], + [tokenText(transcript, " doThing();"), 14], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("keeps an image selected beside the code in the copy", () => { + const message = mountHighlighted(); + const image = document.createElement("div"); + image.setAttribute("data-paseo-markdown-tag", "p"); + image.innerHTML = '
chart
'; + message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image); + const before = tokenText(message, "Before the block."); + + const content = copyBetween( + [before, before.length], + [tokenText(message, " doThing();"), 14], + ); + + expect(content?.plainText).toBe( + "![chart](https://example.test/chart.png)\n\n```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```", + ); + }); + it("retains the fence when a complete code block is selected across its boundary", () => { const message = mountHighlighted(); diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts index d019a23012..ac4643b2ff 100644 --- a/packages/app/src/assistant-selection-copy/content.web.ts +++ b/packages/app/src/assistant-selection-copy/content.web.ts @@ -53,9 +53,7 @@ turndown.addRule("compactListItem", { if (parent?.nodeName !== "OL") { return `${options.bulletListMarker} ${item}\n`; } - const start = Number(parent.getAttribute("start") ?? 1); - const index = Array.from(parent.children).indexOf(node); - return `${start + index}. ${item}\n`; + return `${node.getAttribute("value")}. ${item}\n`; }, }); @@ -67,8 +65,8 @@ export function createAssistantSelectionClipboardContent( } const range = selection.getRangeAt(0); - const parts = selectedMessageParts(range); - if (!parts) { + const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range)); + if (!parts?.length) { return null; } @@ -103,10 +101,12 @@ export function createAssistantSelectionClipboardContent( * * A selection contained inside code always copies as code, even when it contains every * character. A selection that crosses the code boundary stays on the Markdown path so - * a complete block retains its fence. + * a complete block retains its fence. Crossing means selecting content outside the code: + * a drag that starts or ends in the gap beside a block anchors at the edge of the + * neighbouring block, which selects nothing there. */ function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null { - const region = closestCodeRegion(range.commonAncestorContainer, message); + const region = selectedCodeRegion(range, message); if (!region) { return null; } @@ -130,6 +130,18 @@ function createPartialCodeContent(range: Range, message: Element): MarkdownClipb }); } +function selectedCodeRegion(range: Range, message: Element): Element | null { + const edgeRegions = [range.startContainer, range.endContainer].map((node) => + closestCodeRegion(node, message), + ); + return ( + edgeRegions.find( + // Measure against the whole block, so its own hover Copy button is not outside it. + (edge) => edge && selectsNothingOutside(range, edge.closest(CODE_BLOCK_SELECTOR) ?? edge), + ) ?? null + ); +} + function closestCodeRegion(node: Node, message: Element): Element | null { const element = node instanceof Element ? node : node.parentElement; const region = element?.closest(CODE_REGION_SELECTOR) ?? null; @@ -384,6 +396,31 @@ function hasSelectedAllContents(range: Range, element: Element, includeIgnored = return true; } +function selectsContent(range: Range): boolean { + return hasMarkdownContent(range.cloneContents(), true); +} + +function selectsNothingOutside(range: Range, element: Element): boolean { + const contents = document.createRange(); + contents.selectNodeContents(element); + + if (range.compareBoundaryPoints(Range.START_TO_START, contents) < 0) { + const before = range.cloneRange(); + before.setEnd(contents.startContainer, contents.startOffset); + if (selectsContent(before)) { + return false; + } + } + if (range.compareBoundaryPoints(Range.END_TO_END, contents) > 0) { + const after = range.cloneRange(); + after.setStart(contents.endContainer, contents.endOffset); + if (selectsContent(after)) { + return false; + } + } + return true; +} + function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean { if (!includeIgnored) { for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) { @@ -395,6 +432,7 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean) } const visibleVoidSelector = ["br", "hr"] .map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`) + .concat("img") .join(","); return Boolean(fragment.querySelector(visibleVoidSelector)); } @@ -496,6 +534,7 @@ function restoreMarkdownElements(container: HTMLElement): void { if (start) { semanticElement.setAttribute("start", start); } + numberOrderedListItems(semanticElement, Number(start ?? 1)); } if (tagName === "pre") { const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE); @@ -528,6 +567,20 @@ function restoreMarkdownElements(container: HTMLElement): void { } } +/** + * Each item carries its number before Turndown re-parses the HTML. Until then every + * child of the list is one item, including a partly selected item demoted to a `p`. + * The parser splits a `p` that holds a code block into several siblings, so counting + * siblings afterwards numbers the next item too high. + */ +function numberOrderedListItems(list: Element, start: number): void { + Array.from(list.children).forEach((child, index) => { + if (child.tagName === "LI") { + child.setAttribute("value", String(start + index)); + } + }); +} + function unwrapIncompleteTables(container: HTMLElement): void { for (const table of container.querySelectorAll("table")) { if (hasUsableTableColumnContract(table)) { diff --git a/packages/app/src/components/add-remote-ssh-host-modal.tsx b/packages/app/src/components/add-remote-ssh-host-modal.tsx index 2e3e369f39..c7e25bce64 100644 --- a/packages/app/src/components/add-remote-ssh-host-modal.tsx +++ b/packages/app/src/components/add-remote-ssh-host-modal.tsx @@ -1,13 +1,25 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Text, View } from "react-native"; +import { + Pressable, + Text, + View, + type PressableStateCallbackType, + type StyleProp, + type ViewStyle, +} from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { Terminal } from "lucide-react-native"; +import { Eye, EyeOff, Terminal } from "lucide-react-native"; import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport"; import type { HostProfile } from "@/types/host-connection"; import { useHostMutations, useHosts } from "@/runtime/host-runtime"; import { Button } from "@/components/ui/button"; import { Field, FormTextInput } from "@/components/ui/form-field"; +import { + CONTROL_HEIGHTS, + createControlGeometry, + resolveControlInteractionStyles, +} from "@/components/ui/control-geometry"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { useIsCompactFormFactor } from "@/constants/layout"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; @@ -15,18 +27,64 @@ import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; const FLEX_ONE_STYLE = { flex: 1 } as const; const ThemedTerminal = withUnistyles(Terminal); +const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted })); -const styles = StyleSheet.create((theme) => ({ - helper: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - actions: { - flexDirection: "row", - gap: theme.spacing[3], - marginTop: theme.spacing[2], - }, -})); +const styles = StyleSheet.create((theme) => { + const geometry = createControlGeometry(theme); + return { + helper: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + passwordRow: { + flexDirection: "row", + alignItems: "center", + alignSelf: "stretch", + width: "100%", + gap: theme.spacing[2], + }, + passwordInputWrap: { + flex: 1, + minWidth: 0, + }, + // Boxed adornment next to the field: same height and radius as the field + // chrome of the active form size, and the shared interaction phases. + iconButtonSm: { + minHeight: CONTROL_HEIGHTS.compact, + width: CONTROL_HEIGHTS.compact, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + iconButtonMd: { + minHeight: CONTROL_HEIGHTS.field, + width: CONTROL_HEIGHTS.field, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface2, + }, + controlRest: { + ...geometry.controlRest, + }, + controlHover: { + ...geometry.controlHover, + }, + controlActive: { + ...geometry.controlActive, + }, + controlDisabled: { + ...geometry.controlDisabled, + }, + actions: { + flexDirection: "row", + gap: theme.spacing[3], + marginTop: theme.spacing[2], + }, + }; +}); export interface AddRemoteSshHostModalProps { visible: boolean; @@ -51,14 +109,20 @@ export function AddRemoteSshHostModal({ const isCompact = useIsCompactFormFactor(); const { probeAndUpsertRemoteSshConnection } = useHostMutations(); const targetRef = useRef(""); + const passwordRef = useRef(""); const inputRef = useRef(null); + const passwordInputRef = useRef(null); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const header = useMemo(() => ({ title: t("pairing.remoteSsh.title") }), [t]); const clear = useCallback(() => { targetRef.current = ""; + passwordRef.current = ""; inputRef.current?.replaceText(""); + passwordInputRef.current?.replaceText(""); + setIsPasswordVisible(false); setErrorMessage(""); }, []); @@ -94,7 +158,10 @@ export function AddRemoteSshHostModal({ try { setIsSaving(true); setErrorMessage(""); - result = await probeAndUpsertRemoteSshConnection(target); + result = await probeAndUpsertRemoteSshConnection({ + ...target, + password: passwordRef.current === "" ? undefined : passwordRef.current, + }); } catch (error) { const message = error instanceof DaemonConnectionTestError @@ -116,6 +183,34 @@ export function AddRemoteSshHostModal({ const handleTargetChange = useCallback((value: string) => { targetRef.current = value; }, []); + const handlePasswordChange = useCallback((value: string) => { + passwordRef.current = value; + }, []); + const handleTogglePasswordVisibility = useCallback(() => { + setIsPasswordVisible((currentlyVisible) => !currentlyVisible); + }, []); + const iconButtonStyle = useCallback( + ( + state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }, + ): StyleProp => [ + isCompact ? styles.iconButtonMd : styles.iconButtonSm, + resolveControlInteractionStyles( + { + controlRest: styles.controlRest, + controlHover: styles.controlHover, + controlActive: styles.controlActive, + controlDisabled: styles.controlDisabled, + }, + { + hovered: state.hovered, + pressed: state.pressed, + focused: state.focused, + disabled: isSaving, + }, + ), + ], + [isCompact, isSaving], + ); const handleSubmit = useCallback(() => void handleSave(), [handleSave]); return ( @@ -146,6 +241,41 @@ export function AddRemoteSshHostModal({ onSubmitEditing={handleSubmit} /> + + + + + + + {isPasswordVisible ? : } + + + + )} + ); } diff --git a/packages/website/src/routes/plugins/index.tsx b/packages/website/src/routes/plugins/index.tsx index c95273cc60..bd6cbb5690 100644 --- a/packages/website/src/routes/plugins/index.tsx +++ b/packages/website/src/routes/plugins/index.tsx @@ -26,7 +26,7 @@ import { mostInstalled, newestFirst, } from "~/plugins"; -import { ContributeLinks } from "~/plugins/contribute-links"; +import { ContributeSection } from "~/plugins/contribute-links"; import { type BrowseQuery, browseHref, @@ -36,6 +36,7 @@ import { parseSearchTerm, parseSort, parseWindow, + SUBMIT_URL, } from "~/plugins/links"; import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card"; import { PluginSearch } from "~/plugins/plugin-search"; @@ -57,6 +58,7 @@ const CATEGORY_ICONS: Record = { utils: Wrench, }; +const SUBMIT_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground"; const SEE_ALL_CLASS = "inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground"; @@ -113,8 +115,10 @@ function PluginsPage() { @@ -189,6 +193,8 @@ function PluginsPage() { ))} + + ); } diff --git a/plugins/antigravity-provider/server/internal/process.ts b/plugins/antigravity-provider/server/internal/process.ts index 899a88e5cd..c87a17aadb 100644 --- a/plugins/antigravity-provider/server/internal/process.ts +++ b/plugins/antigravity-provider/server/internal/process.ts @@ -1,4 +1,5 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawnProcess } from "@getpaseo/plugin/server"; import { signalProcess } from "./signals.js"; import { createInterface } from "node:readline"; import type { ProviderLaunch, ProviderSessionConfig } from "@getpaseo/plugin/server/provider"; @@ -49,7 +50,7 @@ function driverArgs(options: DriverOptions): string[] { } export function startDriver(options: DriverOptions): Driver { - const child = spawn(options.launch.command, driverArgs(options), { + const child = spawnProcess(options.launch.command, driverArgs(options), { cwd: options.config.cwd, env: { ...options.launch.env, ...options.config.env }, detached: process.platform !== "win32", @@ -183,7 +184,7 @@ interface ProbeOptions { } export function probe(options: ProbeOptions): Promise { return new Promise((resolve, reject) => { - const child = spawn(options.launch.command, [...options.launch.args, ...options.args], { + const child = spawnProcess(options.launch.command, [...options.launch.args, ...options.args], { env: options.launch.env, cwd: options.cwd, stdio: "pipe", diff --git a/plugins/antigravity-provider/server/internal/signals.ts b/plugins/antigravity-provider/server/internal/signals.ts index be34ecc8c9..f7f5b64eab 100644 --- a/plugins/antigravity-provider/server/internal/signals.ts +++ b/plugins/antigravity-provider/server/internal/signals.ts @@ -1,5 +1,4 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { execCommand } from "@getpaseo/plugin/server"; interface SignalOptions { platform: NodeJS.Platform; @@ -11,8 +10,6 @@ type SignalPlan = | { type: "group"; pid: number; signal: NodeJS.Signals } | { type: "tree"; command: "taskkill"; args: string[] }; -const executeFile = promisify(execFile); - export function signalPlan(options: SignalOptions): SignalPlan { if (options.platform === "win32") { return { type: "tree", command: "taskkill", args: ["/PID", String(options.pid), "/T", "/F"] }; @@ -32,7 +29,7 @@ export function signalProcess(options: SignalOptions): void | Promise { async function killTree(plan: Extract): Promise { try { - await executeFile(plan.command, plan.args, { windowsHide: true }); + await execCommand(plan.command, plan.args, { shell: false }); } catch (error) { // taskkill reports an already-exited PID with exit code 128. if (!(error instanceof Error && "code" in error && error.code === 128)) throw error; diff --git a/plugins/antigravity-provider/server/provider.test.ts b/plugins/antigravity-provider/server/provider.test.ts index 8ba12841e0..631f10a6fe 100644 --- a/plugins/antigravity-provider/server/provider.test.ts +++ b/plugins/antigravity-provider/server/provider.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -773,3 +773,19 @@ describe("image prompts", () => { expect((await h.records()).filter((entry) => entry.input)).toHaveLength(0); }); }); + +it.runIf(process.platform === "win32")( + "launches an Antigravity command shim for probes and conversation startup", + async () => { + const h = await harness(); + const shim = path.join(h.cwd, "agy launcher.cmd"); + await writeFile(shim, `@echo off\r\n"${process.execPath}" "${fake}" %*\r\n`); + h.launch.command = shim; + h.launch.args = ["--launch-prefix"]; + expect(await provider.status!({ launch: h.launch })).toEqual({ available: true }); + await h.open(); + expect(await h.wait((event) => event.type === "session.opened")).toMatchObject({ + sessionId: "s", + }); + }, +); diff --git a/plugins/claude-usage-source/server/keychain.test.ts b/plugins/claude-usage-source/server/keychain.test.ts index d85401037a..dd6c58f77a 100644 --- a/plugins/claude-usage-source/server/keychain.test.ts +++ b/plugins/claude-usage-source/server/keychain.test.ts @@ -141,6 +141,7 @@ describe("Claude credential routes", () => { expect(accounts).toEqual([ { key: "fixture-account.fixture-org", + harness: "Claude", input: { route: { store: "claude", path: join(dir, ".credentials.json") } }, }, ]); diff --git a/plugins/claude-usage-source/server/stores.test.ts b/plugins/claude-usage-source/server/stores.test.ts index 75248116cc..c830fca9cc 100644 --- a/plugins/claude-usage-source/server/stores.test.ts +++ b/plugins/claude-usage-source/server/stores.test.ts @@ -237,6 +237,7 @@ test("keychain identity belongs to its token even when Claude Code metadata name ); expect(identity).toEqual({ key: "pi-account.pi-org", + harness: "Claude", input: { route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" }, }, diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index 2a0c91235f..29e78a54e9 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -50,10 +50,10 @@ function mockFetch(handlers: Map Response>): typeof fetch { }) as unknown as typeof fetch; } -function jsonResponse(body: unknown, status = 200): Response { +function jsonResponse(body: unknown, status = 200, headers: Record = {}): Response { return new Response(JSON.stringify(body), { status, - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...headers }, }); } @@ -915,6 +915,7 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc expect(accounts).toEqual([ { key: expect.stringMatching(/^[a-f0-9]{64}$/), + harness: "Claude", input: { route: { store: "keychain", service: "Claude Code-credentials", account: "fixture" }, }, @@ -960,7 +961,11 @@ it.each([401, 403])("a rejected profile HTTP %i keeps a locator-keyed account", const api: typeof fetch = async () => new Response(null, { status }); const accounts = await discover({ kind: "global" }, lookup, api); expect(accounts).toEqual([ - { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: credentialInput(lookup.claudeHome) }, + { + key: expect.stringMatching(/^[a-f0-9]{64}$/), + harness: "Claude", + input: credentialInput(lookup.claudeHome), + }, ]); let fetches = 0; expect( @@ -992,11 +997,42 @@ it.each([ return new Response(null, { status: 500 }); }; const accounts = await discover({ kind: "global" }, lookup, api); - expect(accounts).toEqual([{ key: hashAccountKey(JSON.stringify(input.route)), input }]); + expect(accounts).toEqual([ + { key: hashAccountKey(JSON.stringify(input.route)), harness: "Claude", input }, + ]); await expect(fetchUsage(accounts[0]!.input as UsageInput, api, lookup)).rejects.toThrow(error); }, ); +it("says when a rate-limited login can retry and does not call the API before then", async () => { + let now = Date.parse("2026-10-04T21:00:00Z"); + const lookup = { ...tokenLookup("rate-limited-token"), now: () => now }; + const input = credentialInput(lookup.claudeHome); + let usageCalls = 0; + const api: typeof fetch = async () => { + usageCalls += 1; + if (usageCalls > 1) return jsonResponse(makeClaudeResponse()); + return jsonResponse( + { error: { type: "rate_limit_error", message: "Rate limited. Please try again later." } }, + 429, + { "Retry-After": "2240" }, + ); + }; + + await expect(fetchUsage(input, api, lookup)).rejects.toThrow( + "Rate limited by Claude. Try again in 38m.", + ); + now += 30 * 60_000; + await expect(fetchUsage(input, api, lookup)).rejects.toThrow( + "Rate limited by Claude. Try again in 8m.", + ); + expect(usageCalls).toBe(1); + + now += 8 * 60_000; + expect((await fetchUsage(input, api, lookup)).status).toBe("available"); + expect(usageCalls).toBe(2); +}); + it("re-reads a refreshed login at the original locator", async () => { const lookup = tokenLookup("expiring-token"); writeFileSync( @@ -1042,7 +1078,12 @@ it("session discovery reads only its Claude home and excludes foreign sessions", const lookup = { platform: "linux" as const, home: "/unused-default-home", env: {} }; const accounts = await discover(scope, lookup, api); expect(accounts).toEqual([ - { key: "session-account.org", label: "session@example.test", input: credentialInput(home) }, + { + key: "session-account.org", + label: "session@example.test", + harness: "Claude", + input: credentialInput(home), + }, ]); await discover(scope, lookup, api); expect(calls).toEqual(["https://api.anthropic.com/api/oauth/profile"]); diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index b3781ba86c..9efade2062 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -380,7 +380,10 @@ export async function discover( for (const input of candidates) { const credentials = await resolveClaudeCredentials(input, lookup); if (!credentials) continue; - const fallback = { key: hashAccountKey(JSON.stringify(input.route)), input }; + const harness = { pi: "Pi", omp: "OMP", claude: "Claude", keychain: "Claude" }[ + input.route.store + ]; + const fallback = { key: hashAccountKey(JSON.stringify(input.route)), harness, input }; if (credentials.expires !== undefined && credentials.expires <= (lookup.now ?? Date.now)()) { accounts.push(fallback); continue; @@ -391,7 +394,7 @@ export async function discover( fetchApi, lookup.now ?? Date.now, ); - accounts.push("status" in profile ? fallback : { ...profile, input }); + accounts.push("status" in profile ? fallback : { ...profile, harness, input }); } catch { // Keep the login visible; fetching usage reports the vendor failure. accounts.push(fallback); @@ -521,7 +524,16 @@ export async function fetchUsage( return parsed; } - async function callClaudeApi(token: string): Promise { + async function callClaudeApi(token: string, now: number): Promise { + for (const [key, until] of rateLimitedUntil) { + if (until <= now) rateLimitedUntil.delete(key); + } + const tokenKey = hashAccountKey(token); + const limitedUntil = rateLimitedUntil.get(tokenKey); + if (limitedUntil !== undefined && limitedUntil > now) + throw rateLimitedError(limitedUntil - now); + rateLimitedUntil.delete(tokenKey); + const res = await fetchApi("https://api.anthropic.com/api/oauth/usage", { signal: AbortSignal.timeout(15_000), headers: { @@ -531,6 +543,11 @@ export async function fetchUsage( }, }); if (res.status === 401 || res.status === 403) return res.status; + if (res.status === 429) { + const waitMs = retryAfterMs(res.headers.get("retry-after"), now); + if (waitMs !== null) rateLimitedUntil.set(tokenKey, now + waitMs); + throw rateLimitedError(waitMs); + } if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`); return ClaudeUsageResponseSchema.parse(await res.json()); } @@ -559,7 +576,7 @@ export async function fetchUsage( const { oauth } = credentials; const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); - const resp = await callClaudeApi(oauth.accessToken); + const resp = await callClaudeApi(oauth.accessToken, (credentialLookup.now ?? Date.now)()); if (typeof resp === "number") return unavailable({ kind: "rejected", status: resp, refreshedBy }); @@ -595,6 +612,28 @@ export async function fetchUsage( }; } +// A 429 from the usage endpoint carries Retry-After, observed in the tens of minutes. Until it +// passes, a fetch (including a forced Refresh) answers from here instead of calling again. +const rateLimitedUntil = new Map(); + +function retryAfterMs(header: string | null, now: number): number | null { + if (!header) return null; + const seconds = Number(header); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const at = Date.parse(header); + return Number.isNaN(at) ? null : Math.max(0, at - now); +} + +function rateLimitedError(waitMs: number | null): Error { + const when = waitMs === null ? "later" : `in ${formatWait(waitMs)}`; + return new Error(`Rate limited by Claude. Try again ${when}.`); +} + +function formatWait(ms: number): string { + const minutes = Math.max(1, Math.ceil(ms / 60_000)); + return minutes < 60 ? `${minutes}m` : `${Math.ceil(minutes / 60)}h`; +} + // The OAuth usage endpoint meters the active organization selected by the token. type ClaudeIdentity = { key: string; label?: string } | { status: number }; const PROFILE_TTL_MS = 300_000; diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index 8222aae315..4cdefa2bcd 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -1,3 +1,4 @@ +import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -412,3 +413,55 @@ test("session discovery isolates CODEX_HOME and excludes foreign routes", async await rm(home, { recursive: true, force: true }); } }); + +test("#6155: real Codex and OpenCode stores share an account but keep agent failures separate", async () => { + await writeAuth("fixture-codex", "same-account"); + const data = join(fixtureHome, "data"); + await mkdir(join(data, "opencode"), { recursive: true }); + await writeFile( + join(data, "opencode", "auth.json"), + JSON.stringify({ + openai: { type: "oauth", access: "fixture-opencode", accountId: "same-account", expires: 0 }, + }), + ); + const env = { HOME: fixtureHome, CODEX_HOME: fixtureHome, XDG_DATA_HOME: data }; + const lookup = { home: fixtureHome, env, platform: "linux" as const, now: () => 1_000 }; + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => id === "agent", + usageSession: () => ({ provider: "codex", env, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: (scope) => discover(scope, lookup), + fetch: (input) => + fetchUsage(inputSchema.parse(input), async () => new Response(null, { status: 401 }), lookup), + }); + const [host] = await registry.listReports(); + expect(host?.loginErrors).toEqual([ + { + harness: "Codex", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex" }, + }, + }, + { + harness: "OpenCode", + report: { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: "1970-01-01T00:00:00.000Z", + refreshedBy: "opencode", + }, + }, + }, + ]); + const [agent] = await registry.listReports({ agentId: "agent" }); + expect(agent?.report).toEqual({ + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex" }, + }); + expect(agent?.loginErrors).toEqual([host!.loginErrors![0]]); +}); diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index cf5207ff22..0b211726c6 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -72,7 +72,8 @@ export async function discover( const present: UsageAccount[] = []; for (const input of candidates) { const auth = await readAuth(input, lookup); - if (auth) present.push({ ...accountIdentity(auth, input), input }); + if (auth) + present.push({ ...accountIdentity(auth, input), harness: harnessLabel(input), input }); } return present; } @@ -294,3 +295,8 @@ function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; lab claimString(id?.["email"]); return { key: key ?? hashAccountKey(JSON.stringify(input.route)), ...(label ? { label } : {}) }; } + +function harnessLabel(input: CodexUsageInput): string { + const labels = { codex: "Codex", opencode: "OpenCode", pi: "Pi", omp: "OMP" }; + return labels[input.route.store]; +} diff --git a/plugins/muse-provider/server/catalog.ts b/plugins/muse-provider/server/catalog.ts index d4e483ed79..5e603a9eda 100644 --- a/plugins/muse-provider/server/catalog.ts +++ b/plugins/muse-provider/server/catalog.ts @@ -63,10 +63,7 @@ export class Catalog { export function presentCatalog(response: z.infer): ProviderCatalog { const models = response.models.map((model) => { - let supported: z.infer[] = efforts; - if (Array.isArray(model.reasoningEffortVariants) && model.reasoningEffortVariants.length > 0) - supported = model.reasoningEffortVariants; - else if (model.variants.length > 0) supported = model.variants; + const supported = model.variants.length > 0 ? model.variants : efforts; return { id: model.modelId, label: model.displayLabel, diff --git a/plugins/muse-provider/server/connection.ts b/plugins/muse-provider/server/connection.ts index 100bd101c7..3fca09ef5e 100644 --- a/plugins/muse-provider/server/connection.ts +++ b/plugins/muse-provider/server/connection.ts @@ -1,5 +1,5 @@ import type { ProviderLaunch } from "@getpaseo/plugin/server/provider"; -import { spawn } from "node:child_process"; +import { spawnProcess, terminateProcess } from "@getpaseo/plugin/server"; import { randomBytes } from "node:crypto"; import { createInterface } from "node:readline"; import { setTimeout as delay } from "node:timers/promises"; @@ -35,6 +35,7 @@ export class MspConnection { private stderr = ""; private failure: MuseError | null = null; private closing = false; + private closeResult: Promise | null = null; private readonly exited: Promise; constructor( @@ -42,15 +43,20 @@ export class MspConnection { launch: ProviderLaunch; cwd?: string; timeoutMs?: number; + startupTimeoutMs?: number; serveArgs?: string[]; }, ) { const { launch, cwd } = options; - this.child = spawn(launch.command, [...launch.args, "serve", ...(options.serveArgs ?? [])], { - env: launch.env, - cwd, - stdio: "pipe", - }); + this.child = spawnProcess( + launch.command, + [...launch.args, "serve", ...(options.serveArgs ?? [])], + { + env: launch.env, + cwd, + stdio: "pipe", + }, + ); this.child.stderr.setEncoding("utf8"); this.child.stderr.on("data", (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-8192); @@ -62,7 +68,7 @@ export class MspConnection { } catch (error) { const message = error instanceof Error ? error.message : String(error); this.fail(new MuseError("invalidFrame", `Invalid MSP frame: ${message}`)); - this.child.kill(); + void this.close().catch(() => {}); } }); this.child.stdin.on("error", () => { @@ -89,6 +95,7 @@ export class MspConnection { }, }, initializedSchema, + this.options.startupTimeoutMs ?? 30000, ); if (response.schema.fingerprint !== fingerprint) { process.stderr.write( @@ -123,7 +130,12 @@ export class MspConnection { } } } - async request(method: string, params: object, schema: z.ZodType): Promise { + async request( + method: string, + params: object, + schema: z.ZodType, + timeoutMs = this.options.timeoutMs ?? 10000, + ): Promise { if (this.failure) throw this.failure; if (this.closing) throw new MuseError("closed", "Muse host is closing"); const id = ++this.sequence; @@ -131,22 +143,31 @@ export class MspConnection { const timer = setTimeout(() => { this.pending.delete(id); reject(new MuseError("timeout", `Muse ${method} timed out`)); - }, this.options.timeoutMs ?? 10000); + }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); this.write({ jsonrpc: "2.0", id, method, params }); }); return schema.parse(response); } - async close(): Promise { - if (this.closing) return this.exited; + close(): Promise { + if (this.closeResult) return this.closeResult; this.closing = true; + this.closeResult = this.finishClose(); + return this.closeResult; + } + private async finishClose(): Promise { this.child.stdin.end(); const drained = await Promise.race([ this.exited.then(() => true), - delay(1000).then(() => false), + delay(1000, undefined, { ref: false }).then(() => false), ]); - if (!drained) this.child.kill("SIGKILL"); - await this.exited; + if (drained) return; + await terminateProcess(this.child); + const stopped = await Promise.race([ + this.exited.then(() => true), + delay(5000, undefined, { ref: false }).then(() => false), + ]); + if (!stopped) throw new MuseError("timeout", "Muse host did not close after termination"); } private write(frame: object): void { this.child.stdin.write(JSON.stringify(frame) + "\n"); @@ -170,6 +191,12 @@ export class MspConnection { return; } if (frame.id === undefined) throw new MuseError("invalidFrame", "MSP response has no id"); + if (frame.id === null) { + if (!frame.error) throw new MuseError("invalidFrame", "MSP result has a null id"); + this.fail(new MuseError(frame.error.data?.kind ?? "rpc", frame.error.message)); + void this.close().catch(() => {}); + return; + } const pending = this.pending.get(frame.id); if (!pending) return; clearTimeout(pending.timer); diff --git a/plugins/muse-provider/server/provider.ts b/plugins/muse-provider/server/provider.ts index bef6693f91..8d9a3ddfea 100644 --- a/plugins/muse-provider/server/provider.ts +++ b/plugins/muse-provider/server/provider.ts @@ -10,7 +10,7 @@ import { } from "@getpaseo/plugin/server/provider"; import { serveArgs } from "./options.js"; import { Usage } from "./usage.js"; -import { execFile } from "node:child_process"; +import { execCommand } from "@getpaseo/plugin/server"; import { Catalog, launchKey } from "./catalog.js"; import { MspConnection } from "./connection.js"; import { MuseError, actionableError } from "./errors.js"; @@ -63,24 +63,23 @@ function requireLaunch(launch: ProviderLaunch | undefined): ProviderLaunch { async function status(launch: ProviderLaunch): Promise { let host: MspConnection | undefined; try { - const versionText = await new Promise((resolve, reject) => { - execFile( - launch.command, - [...launch.args, "--version"], - { env: launch.env, timeout: 3000, maxBuffer: 8192 }, - (error, stdout) => { - if (error) reject(new MuseError("version", error.message)); - else resolve(stdout); - }, - ); - }); + const { stdout: versionText } = await execCommand( + launch.command, + [...launch.args, "--version"], + { + env: launch.env, + timeout: 3000, + maxBuffer: 8192, + }, + ); const match = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(versionText); if (!match) return { available: false, diagnostic: "Muse returned an unrecognized version." }; const major = Number(match[1]); const minor = Number(match[2]); if (major < 1 || (major === 1 && minor < 3)) return { available: false, diagnostic: `Update Muse Code: found ${match[0]}, need ≥1.3.0` }; - host = new MspConnection({ launch, timeoutMs: 3000 }); + // Leave room for the version probe, account read and cleanup within the status RPC deadline. + host = new MspConnection({ launch, timeoutMs: 3000, startupTimeoutMs: 20000 }); await host.initialize(); const account = await host.request("account/read", {}, accountSchema); if (account.state === "loggedOut") diff --git a/plugins/muse-provider/server/session.ts b/plugins/muse-provider/server/session.ts index b152361060..31066c5b04 100644 --- a/plugins/muse-provider/server/session.ts +++ b/plugins/muse-provider/server/session.ts @@ -66,8 +66,7 @@ interface Live { export class Session { private readonly host: MspConnection; private liveState: Live | undefined; - private providerId = "meta"; - private cursor: string | undefined; + private providerId: string | undefined; private config: ProviderSessionConfig; private catalog: ProviderCatalog = { models: [], modes }; private readonly approvals = new Map(); @@ -110,9 +109,11 @@ export class Session { if (input.persistence.version !== 1) throw new MuseError("invalidPersistence", "Unsupported Muse persistence version"); const saved = persistenceSchema.parse(input.persistence.data); + // A fresh timeline needs history. MSP cursor resumes return only the suffix, + // so even legacy persistence cursors cannot restore this session's projection. response = await this.host.command( "session/resume", - { sessionId: saved.sessionId, cursor: saved.cursor, config: { mcpServers } }, + { sessionId: saved.sessionId, config: { mcpServers } }, sessionSchema, ); this.config = { @@ -167,11 +168,10 @@ export class Session { }, ), }; - this.providerId = response.session.providerId; - this.cursor = response.viewCursor; + this.providerId = response.session.providerId ?? undefined; this.config = { ...this.config, - model: response.session.modelId, + model: response.session.modelId ?? this.config.model, mode: response.session.approvalMode?.mode ?? this.config.mode ?? "onRequest", }; this.options.emit({ @@ -194,14 +194,7 @@ export class Session { for (const notification of this.buffered) this.enqueue(notification); this.buffered.length = 0; await this.notifications; - this.catalog = presentCatalog( - await this.host.request("model/list", { sessionId: this.live().nativeId }, catalogSchema), - ); - const selected = this.catalog.models.find((model) => model.id === this.config.model); - this.config = { - ...this.config, - thinkingOption: this.config.thinkingOption ?? selected?.defaultThinkingOptionId, - }; + await this.readCatalog(); await this.live().commands.refresh(); this.publishPersistence(); this.publishConfig(); @@ -229,6 +222,19 @@ export class Session { for (const approval of pending.approvals) await this.permission(approval); for (const question of pending.userInputs) this.live().questions.requested(question); } + private async readCatalog(): Promise { + this.catalog = presentCatalog( + await this.host.request("model/list", { sessionId: this.live().nativeId }, catalogSchema), + ); + const model = this.config.model ?? this.catalog.defaultModel; + const selected = this.catalog.models.find((entry) => entry.id === model); + this.config = { + ...this.config, + model, + thinkingOption: this.config.thinkingOption ?? selected?.defaultThinkingOptionId, + }; + } + async prompt(prompt: ProviderPrompt): Promise { if (await this.live().commands.compact(prompt)) return; const id = commandId(); @@ -405,10 +411,6 @@ export class Session { } await this.processNotification(notification); this.live().recovery.activity(envelope.viewCursor, this.activeTurnIds().length > 0); - if (envelope.viewCursor) { - this.cursor = envelope.viewCursor; - this.publishPersistence(); - } } private async processNotification(notification: Notification): Promise { switch (notification.method) { @@ -591,7 +593,6 @@ export class Session { version: 1, data: { sessionId: this.live().nativeId, - ...(this.cursor ? { cursor: this.cursor } : {}), ...(this.config.model ? { model: this.config.model } : {}), ...(this.config.thinkingOption ? { thinkingOption: this.config.thinkingOption } : {}), }, diff --git a/plugins/muse-provider/server/sessions.ts b/plugins/muse-provider/server/sessions.ts index 93a22cd54b..a10879e248 100644 --- a/plugins/muse-provider/server/sessions.ts +++ b/plugins/muse-provider/server/sessions.ts @@ -38,7 +38,10 @@ export class Sessions { updatedAt: session.updatedAt, persistence: { version: 1, - data: { sessionId: session.sessionId, model: session.modelId }, + data: { + sessionId: session.sessionId, + ...(session.modelId === null ? {} : { model: session.modelId }), + }, }, }); if (input.limit && summaries.length >= input.limit) return summaries; diff --git a/plugins/muse-provider/server/usage.ts b/plugins/muse-provider/server/usage.ts index a8c77428d6..e3915c6ddd 100644 --- a/plugins/muse-provider/server/usage.ts +++ b/plugins/muse-provider/server/usage.ts @@ -16,6 +16,13 @@ import { usageSchema } from "./wire.js"; const inputSchema = z.object({ account: z.string() }).strict(); type Observation = z.infer; + +function accountKey(env: ProviderLaunch["env"]): string { + // MSP 1.4.1 has no stable account ID; swapping accounts in one config directory shares a report ID. + const configHome = env.XDG_CONFIG_HOME ?? path.join(env.HOME ?? homedir(), ".config"); + return hashAccountKey(path.resolve(configHome, "muse")); +} + export class Usage { private readonly launches = new Map(); private readonly sessions = new Map< @@ -23,10 +30,7 @@ export class Usage { { account: string; read: () => Promise } >(); remember(launch: ProviderLaunch): { account: string } { - // MSP 1.4.1 has no stable account ID; swapping accounts in one config directory shares a report ID. - const configHome = - launch.env.XDG_CONFIG_HOME ?? path.join(launch.env.HOME ?? homedir(), ".config"); - const key = hashAccountKey(path.resolve(configHome, "muse")); + const key = accountKey(launch.env); this.launches.set(key, launch); return { account: key }; } @@ -60,12 +64,21 @@ export class Usage { label: "Muse Code", icon: "icon.svg", input: inputSchema, - discover: async () => - [...this.launches.keys()].map((account) => ({ + discover: async (scope) => { + let accounts: string[]; + if (scope.kind === "global") { + accounts = [...this.launches.keys()]; + } else { + if (scope.provider !== "muse") return []; + const account = accountKey(scope.env); + accounts = this.launches.has(account) ? [account] : []; + } + return accounts.map((account) => ({ key: account, label: "Muse Code", input: { account }, - })), + })); + }, fetch: async (input) => { const { account } = inputSchema.parse(input); const launch = this.launches.get(account); diff --git a/plugins/muse-provider/server/wire.ts b/plugins/muse-provider/server/wire.ts index 4955acdaa5..45aac6fe4f 100644 --- a/plugins/muse-provider/server/wire.ts +++ b/plugins/muse-provider/server/wire.ts @@ -87,7 +87,7 @@ export const notificationSchema = z.object({ }); export const frameSchema = z.object({ jsonrpc: z.literal("2.0"), - id: z.union([z.string(), z.number()]).optional(), + id: z.union([z.string(), z.number()]).nullable().optional(), method: z.string().optional(), params: z.unknown().optional(), result: z.unknown().optional(), @@ -115,16 +115,20 @@ export const catalogSchema = z.object({ contextLimit: z.number().nullable(), isDefault: z.boolean(), defaultReasoningEffort: effortSchema.nullable().optional(), - reasoningEffortVariants: z.union([z.array(effortSchema), z.literal("unknown")]).default([]), - variants: z.array(effortSchema).default([]), + // Validate consumed fields only. Muse's described subset (reasoningEffortVariants) + // is presentation metadata; its shape must not gate catalog discovery or session startup. + variants: z + .union([z.array(effortSchema), z.literal("unknown")]) + .transform((variants) => (variants === "unknown" ? [] : variants)) + .default([]), }), ), }); export const sessionSchema = z.object({ session: z.object({ sessionId: z.string(), - modelId: z.string(), - providerId: z.string(), + modelId: z.string().nullable(), + providerId: z.string().nullable(), approvalMode: z.object({ mode: approvalModeSchema }).optional(), }), viewCursor: z.string(), @@ -139,11 +143,14 @@ export const sessionSchema = z.object({ }); export const persistenceSchema = z.object({ sessionId: z.string(), - cursor: z.string().optional(), model: z.string().optional(), thinkingOption: effortSchema.optional(), }); -export const deltaSchema = z.object({ itemId: z.string(), field: z.string(), delta: z.string() }); +export const deltaSchema = z.object({ + itemId: z.string(), + field: z.string().default("text"), + delta: z.string(), +}); export const turnSchema = z.object({ turnId: z.string(), commandId: z.string().optional(), @@ -241,7 +248,7 @@ export const sessionListSchema = z.object({ workspaceRoot: z.string().nullable(), title: z.string().optional(), updatedAt: z.string(), - modelId: z.string(), + modelId: z.string().nullable(), }), ), }); diff --git a/plugins/muse-provider/test/fake-muse.mjs b/plugins/muse-provider/test/fake-muse.mjs index fffca0cc89..16cc821b79 100644 --- a/plugins/muse-provider/test/fake-muse.mjs +++ b/plugins/muse-provider/test/fake-muse.mjs @@ -6,9 +6,20 @@ import { fileURLToPath } from "node:url"; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); const scenario = process.env.MUSE_TEST_SCENARIO || "text-reasoning"; if (process.argv.includes("--version")) { + if (process.env.MUSE_TEST_VERSION_ERROR) { + process.stderr.write(process.env.MUSE_TEST_VERSION_ERROR + "\n"); + process.exit(17); + } process.stdout.write(`Muse Code ${process.env.MUSE_TEST_VERSION || "1.4.1"}\n`); process.exit(0); } +if (process.env.MUSE_TEST_STUBBORN) { + setInterval(() => {}, 1000); + appendFileSync( + process.env.MUSE_TEST_REQUESTS, + JSON.stringify({ event: "stubbornHost", pid: process.pid }) + "\n", + ); +} const rows = readFixture(scenario); if (process.env.MUSE_TEST_REQUESTS) appendFileSync( @@ -22,6 +33,14 @@ const receipts = new Set(); const lines = createInterface({ input: process.stdin }); lines.on("line", receive); function receive(line) { + const parsed = JSON.parse(line); + if (parsed.method === "initialize" && process.env.MUSE_TEST_INITIALIZE_DELAY_MS) { + setTimeout(() => receiveFrame(line), Number(process.env.MUSE_TEST_INITIALIZE_DELAY_MS)); + return; + } + receiveFrame(line); +} +function receiveFrame(line) { const frame = JSON.parse(line); if (process.env.MUSE_TEST_REQUESTS) appendFileSync(process.env.MUSE_TEST_REQUESTS, JSON.stringify(frame) + "\n"); @@ -61,11 +80,35 @@ function receive(line) { } else for (const row of messages) emitFixtureMessage(row.msg, recorded, frame); } function controlResponse(frame) { + if (frame.method === "session/resume" && process.env.MUSE_TEST_HISTORY) { + const result = responseFor(readFixture("resume-without-cursor"), "session/resume"); + result.session.sessionId = frame.params.sessionId; + result.history = frame.params.cursor + ? { mode: "none", noneReason: "cursorSuffix", items: null, snapshot: null } + : { + mode: "inline", + items: readFileSync(process.env.MUSE_TEST_HISTORY, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + snapshot: null, + }; + respond(frame, result); + return true; + } if (frame.method === "initialized") return true; if (frame.method === "initialize" && process.env.MUSE_TEST_EXIT) { process.stderr.write("diagnostic tail, not protocol\n"); process.exit(Number(process.env.MUSE_TEST_EXIT)); } + if (frame.method === "initialize" && process.env.MUSE_TEST_NULL_ERROR_ID) { + send({ + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: "Cannot recover request id", data: { kind: "parseError" } }, + }); + return true; + } if (process.env.MUSE_TEST_HANG === frame.method) return true; if (frame.method === "session/setReasoningEffort") { respond(frame, { status: "accepted", commandId: frame.params.commandId }); @@ -138,6 +181,9 @@ function parityResponse(frame) { if (frame.method === "session/list") { const result = responseFor(catalogRows, "session/list"); result.nextCursor = null; + if (process.env.MUSE_TEST_NULL_MODEL) { + for (const session of result.sessions) session.modelId = null; + } respond(frame, result); return true; } @@ -262,6 +308,7 @@ function emitFixtureMessage(recordedMessage, recorded, frame) { return; if (frame.method === "session/start" && message.result?.session?.approvalMode) message.result.session.approvalMode.mode = frame.params.approvalMode; + applyWireVariants(message); if (message.result?.viewCursor && process.env.MUSE_TEST_EMPTY_CURSOR) message.result.viewCursor = ""; applyTestVariants(message); @@ -282,6 +329,19 @@ function emitFixtureMessage(recordedMessage, recorded, frame) { }); } } +function applyWireVariants(message) { + if ( + message.method === "item/delta" && + message.params?.field === "text" && + process.env.MUSE_TEST_IMPLICIT_TEXT_DELTA + ) { + delete message.params.field; + } + if (message.result?.session && process.env.MUSE_TEST_NULL_MODEL) { + message.result.session.modelId = null; + message.result.session.providerId = null; + } +} function parityDelivery(message) { if (process.env.MUSE_TEST_WORKFLOW && message.method === "turn/completed") send({ @@ -394,6 +454,9 @@ function replace(value) { return value; } function send(frame) { + if (frame.method === "item/completed" && process.env.MUSE_TEST_HISTORY) { + appendFileSync(process.env.MUSE_TEST_HISTORY, JSON.stringify(frame.params.item) + "\n"); + } process.stdout.write(JSON.stringify(frame) + "\n"); } function respond(frame, result) { diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index ffb7c0468e..598579d04e 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -1,3 +1,6 @@ +import { execFile } from "node:child_process"; +import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js"; +import { Usage as MuseUsage } from "../server/usage.js"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; import os from "node:os"; @@ -14,6 +17,7 @@ import { type ProviderInput, } from "@getpaseo/plugin/server/provider"; import type { UsageSourceRegistration } from "@getpaseo/plugin/server"; +import { execCommand } from "@getpaseo/plugin/server"; import contribute from "../index.server.js"; const connections: ProviderConnection[] = []; @@ -84,6 +88,7 @@ async function harness( async function open( persistence?: Extract["persistence"], mode = "onRequest", + model: string | null = "meta/muse-spark-1.3", ) { await send({ type: "session.open", @@ -97,7 +102,7 @@ async function harness( mcpServers: {}, persist: true, mode, - model: "meta/muse-spark-1.3", + model: model ?? undefined, thinkingOption: "high", systemPrompt: "Test instructions", }, @@ -218,9 +223,9 @@ test("text and reasoning stream, completion overwrites deltas, user echo retains { type: "text", text: "hello" }, ], }); - expect(h.events.filter((event) => event.type === "session.persistence").length).toBeGreaterThan( - 1, - ); + expect(h.events.findLast((event) => event.type === "session.persistence")).toMatchObject({ + persistence: { version: 1, data: { model: "meta/muse-spark-1.3", thinkingOption: "high" } }, + }); }); test("tool calls classify read, shell, and edit with fetched JSON patch converted to unified diff", async () => { const h = await harness("tools-edit"); @@ -324,23 +329,69 @@ test("interrupt awaits cancelled terminal and reconciles the tool", async () => ), ).toBeGreaterThan(terminalIndex); }); +test("a fresh connection restores earlier messages from persisted session state", async () => { + const original = await harness(); + const historyPath = path.join(original.root, "native-history.ndjson"); + original.launch.env.MUSE_TEST_HISTORY = historyPath; + await original.open(); + await original.prompt(); + await original.wait((event) => event.type === "session.turn" && event.state === "completed"); + const messages = [ + ...new Map( + original.events + .filter((event) => event.type === "timeline.item") + .map((event) => event.item) + .filter((item) => item.type === "user_message" || item.type === "assistant_message") + .map((item) => [item.id, item] as const), + ).values(), + ]; + expect(messages.map((item) => item.type)).toEqual(["user_message", "assistant_message"]); + const saved = original.events.findLast((event) => event.type === "session.persistence"); + if (!saved) throw new Error("Expected session persistence"); + const persistencePath = path.join(original.root, "persistence.json"); + // Older daemon records carry a view cursor even though their timeline is not durable. + await writeFile( + persistencePath, + JSON.stringify({ + ...saved.persistence, + data: { ...saved.persistence.data, cursor: saved.persistence.data.cursor ?? "v:old:7" }, + }), + ); + await original.connection.close(); + const reopened = await harness("text-reasoning", { MUSE_TEST_HISTORY: historyPath }); + expect(await reopened.open(JSON.parse(await readFile(persistencePath, "utf8")))).toMatchObject({ + type: "session.ready", + }); + const restored = reopened.events + .filter((event) => event.type === "timeline.item") + .map((event) => event.item) + .filter((item) => item.type === "user_message" || item.type === "assistant_message"); + expect(restored.map((item) => ({ type: item.type, text: item.text }))).toEqual( + messages.map((item) => ({ type: item.type, text: item.text })), + ); + const reopenedState = reopened.events.findLast((event) => event.type === "session.persistence"); + expect(reopenedState?.persistence.data).not.toHaveProperty("cursor"); + expect(reopened.events.filter((event) => event.type === "session.turn")).toEqual([]); + await reopened.prompt(); + await reopened.wait((event) => event.type === "session.turn" && event.state === "completed"); +}); + for (const cursor of [undefined, "v:old:7"]) { - test(`resume ${cursor ? "with cursor emits suffix and ignores old terminal" : "without cursor replays inline history"}`, async () => { - const h = await harness(cursor ? "resume-with-cursor" : "resume-without-cursor"); + test(`resume ${cursor ? "with a legacy persistence cursor" : "without a persistence cursor"} replays inline history`, async () => { + const h = await harness("resume-without-cursor"); await h.open({ version: 1, data: { sessionId: "saved-session", ...(cursor ? { cursor } : {}) }, }); expect(h.events.filter((event) => event.type === "session.turn")).toEqual([]); const history = h.events.filter((event) => event.type === "timeline.item"); - if (!cursor) - expect(history).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - item: expect.objectContaining({ type: "assistant_message", text: "RESUME_MARKER_OK" }), - }), - ]), - ); + expect(history).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + item: expect.objectContaining({ type: "assistant_message", text: "RESUME_MARKER_OK" }), + }), + ]), + ); await h.prompt(); await h.wait((event) => event.type === "session.turn" && event.state === "completed"); expect( @@ -348,7 +399,10 @@ for (const cursor of [undefined, "v:old:7"]) { ).toHaveLength(1); expect( (await h.recorded()).find((frame) => frame.method === "session/resume").params, - ).toMatchObject({ sessionId: "saved-session", ...(cursor ? { cursor } : {}) }); + ).toMatchObject({ sessionId: "saved-session" }); + expect( + (await h.recorded()).find((frame) => frame.method === "session/resume").params, + ).not.toHaveProperty("cursor"); }); } test("steer sends ifBusy steer and joins the running turn", async () => { @@ -459,6 +513,25 @@ for (const [env, expected] of [ expect(await h.provider.status!({ launch: h.launch })).toEqual(expected); }); } +test("status preserves the version-probe command and stderr diagnostic", async () => { + const h = await harness("catalog-controls", { + MUSE_TEST_VERSION_ERROR: "Muse version probe could not read its installation metadata", + }); + // Compare against the original execFile callback: wrapping in MuseError only changed its kind. + const previousDiagnostic = await new Promise((resolve, reject) => { + execFile(h.launch.command, [...h.launch.args, "--version"], { env: h.launch.env }, (error) => { + if (error) resolve(error.message); + else reject(new Error("Expected the version probe to fail")); + }); + }); + const status = await h.provider.status!({ launch: h.launch }); + expect(status).toEqual({ available: false, diagnostic: previousDiagnostic }); + expect(status.diagnostic).toContain("--version"); + expect(status.diagnostic).toContain( + "Muse version probe could not read its installation metadata", + ); + expect(await h.recorded()).toEqual([]); +}); test("status reports a missing executable", async () => { const h = await harness(); expect( @@ -610,12 +683,15 @@ test("ordinary subagent tool calls render sub_agent details without inventing ch }), ); }); -for (const [variants, reasoningEffortVariants, expected] of [ - [["low", "high"], ["medium"], ["medium"]], - [["low", "high"], "unknown", ["low", "high"]], - [[], [], ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], +for (const [variants, expected] of [ + [ + ["low", "high"], + ["low", "high"], + ], + ["unknown", ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + [[], ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], ] as const) { - test(`catalogue effort discovery ${JSON.stringify(reasoningEffortVariants)} with variants ${JSON.stringify(variants)}`, async () => { + test(`catalogue effort discovery with variants ${JSON.stringify(variants)}`, async () => { const model = { modelId: "test-model", providerId: "meta", @@ -623,7 +699,6 @@ for (const [variants, reasoningEffortVariants, expected] of [ contextLimit: 123456, isDefault: true, variants, - reasoningEffortVariants, defaultReasoningEffort: "high", }; const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify([model]) }); @@ -639,6 +714,153 @@ for (const [variants, reasoningEffortVariants, expected] of [ }); }); } +test("catalogue loads models whose effort tiers carry descriptions", async () => { + const deepest = "Use this for deepest analysis and complex fixes."; + const row = (modelId: string, isDefault: boolean) => ({ + modelId, + providerId: "meta", + displayLabel: modelId, + contextLimit: 1000000, + isDefault, + defaultReasoningEffort: "max", + variants: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffortVariants: isDefault ? [] : [{ tier: "xhigh", description: deepest }], + }); + const models = [ + row("muse-spark-1.3", true), + row("muse-spark-1.3-contributor", false), + row("muse-spark-1.2", false), + row("muse-spark-1.2-contributor", false), + ]; + const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify(models) }); + await h.send({ type: "catalog", requestId: "models" }); + const result = await h.wait( + (event) => event.type === "catalog" || event.type === "request.failed", + ); + const offered = [ + { id: "minimal" }, + { id: "low" }, + { id: "medium" }, + { id: "high" }, + { id: "xhigh" }, + { id: "max", isDefault: true }, + ]; + expect(result).toMatchObject({ + type: "catalog", + catalog: { + defaultModel: "muse-spark-1.3", + models: [ + { id: "muse-spark-1.3", defaultThinkingOptionId: "max" }, + { id: "muse-spark-1.3-contributor", thinkingOptions: offered }, + { id: "muse-spark-1.2", thinkingOptions: offered }, + { id: "muse-spark-1.2-contributor", thinkingOptions: offered }, + ], + }, + }); +}); +for (const [configuredModel, catalogModels, expectedModel] of [ + ["configured-model", [], "configured-model"], + [ + "", + [ + { + modelId: "catalog-model", + providerId: "meta", + displayLabel: "Catalog model", + contextLimit: null, + isDefault: true, + variants: ["high"], + }, + ], + "catalog-model", + ], + ["", [], undefined], +] as const) { + test(`null session model preserves ${expectedModel ?? "unset model"}`, async () => { + const h = await harness("text-reasoning", { + MUSE_TEST_NULL_MODEL: "1", + MUSE_TEST_MODELS: JSON.stringify(catalogModels), + }); + expect(await h.open(undefined, "onRequest", configuredModel || null)).toEqual({ + type: "session.ready", + requestId: "open", + sessionId: "paseo-session", + }); + const config = await h.wait((event) => event.type === "session.config"); + if (config.type !== "session.config") throw new Error("Expected session config"); + expect(config.config.model).toBe(expectedModel); + const saved = h.events.findLast((event) => event.type === "session.persistence"); + if (!saved) throw new Error("Expected persistence"); + expect(saved.persistence.data.model).toBe(expectedModel); + await h.prompt(); + await h.wait((event) => event.type === "session.turn" && event.state === "completed"); + }); +} +test("session startup accepts described catalog efforts and publishes the complete list", async () => { + const model = { + modelId: "meta/muse-spark-1.3", + providerId: "meta", + displayLabel: "Muse Spark", + contextLimit: null, + isDefault: true, + defaultReasoningEffort: "max", + variants: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffortVariants: [{ tier: "xhigh", description: "Deepest analysis" }], + }; + const h = await harness("text-reasoning", { MUSE_TEST_MODELS: JSON.stringify([model]) }); + expect(await h.open()).toEqual({ + type: "session.ready", + requestId: "open", + sessionId: "paseo-session", + }); + expect(await h.wait((event) => event.type === "session.config")).toMatchObject({ + config: { + models: [{ id: model.modelId, defaultThinkingOptionId: "max" }], + thinkingOptions: model.variants.map((id) => ({ id, isDefault: id === "max" })), + }, + }); + await h.send({ + type: "session.configure", + requestId: "choose-max", + sessionId: "paseo-session", + changes: { thinkingOption: "max" }, + }); + await h.wait((event) => event.type === "request.completed" && event.requestId === "choose-max"); + await h.prompt(); + await h.wait((event) => event.type === "session.turn" && event.state === "completed"); + expect( + (await h.recorded()).find((frame) => frame.method === "turn/start").params.reasoningEffort, + ).toBe("max"); +}); +for (const metadata of [ + "unknown", + [{ tier: "future-tier", description: { text: "Future metadata" } }], +]) { + test(`catalog ignores unused effort descriptions ${JSON.stringify(metadata)}`, async () => { + const model = { + modelId: "test-model", + providerId: "meta", + displayLabel: "Muse Spark", + contextLimit: null, + isDefault: true, + defaultReasoningEffort: "high", + variants: ["low", "high"], + reasoningEffortVariants: metadata, + }; + const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify([model]) }); + await h.send({ type: "catalog", requestId: "models" }); + expect( + await h.wait((event) => event.type === "catalog" || event.type === "request.failed"), + ).toMatchObject({ + type: "catalog", + catalog: { + models: [ + { id: model.modelId, thinkingOptions: [{ id: "low" }, { id: "high", isDefault: true }] }, + ], + }, + }); + }); +} test("status accepts future credential states and drains its maintenance host", async () => { const h = await harness("catalog-controls", { MUSE_TEST_ACCOUNT: "futureCredential" }); expect(await h.provider.status!({ launch: h.launch })).toEqual({ available: true }); @@ -691,7 +913,7 @@ test("fingerprint mismatch is logged but does not prevent opening", async () => }); for (const restoring of [false, true]) { test(`MCP servers map to MSP stdio and streamableHttp on ${restoring ? "resume" : "start"}`, async () => { - const h = await harness(restoring ? "resume-with-cursor" : "text-reasoning"); + const h = await harness(restoring ? "resume-without-cursor" : "text-reasoning"); await h.send({ type: "session.open", sessionId: "paseo-session", @@ -1073,6 +1295,46 @@ test("resuming a session reapplies its provider options to the new host", async ]); expect(frames.some((f) => f.method === "session/resume")).toBe(true); }); +test("an uncorrelated MSP parse error keeps the provider diagnostic", async () => { + const h = await harness("text-reasoning", { MUSE_TEST_NULL_ERROR_ID: "1" }); + expect(await h.open()).toMatchObject({ + type: "request.failed", + error: { code: "parseError", message: "Cannot recover request id" }, + }); +}); +test("text deltas with an omitted field stream as text", async () => { + const h = await harness("text-reasoning", { MUSE_TEST_IMPLICIT_TEXT_DELTA: "1" }); + await h.open(); + await h.wait((e) => e.type === "session.ready"); + await h.prompt(); + await h.wait((e) => e.type === "session.turn" && e.state === "completed"); + expect( + h.events.some( + (e) => + e.type === "timeline.item" && + e.item.type === "assistant_message" && + e.item.text === "Sum with", + ), + ).toBe(true); +}); +test("sessions with a null model import and resume without a null persistence model", async () => { + const h = await harness("resume-without-cursor", { MUSE_TEST_NULL_MODEL: "1" }); + await h.send({ + type: "sessions", + requestId: "null-list", + cwd: "/tmp/muse-phase0/repo", + limit: 2, + }); + const listed = await h.wait((e) => e.type === "sessions"); + if (listed.type !== "sessions") throw new Error("Expected sessions"); + expect(listed.sessions).toHaveLength(2); + expect(listed.sessions[0]!.persistence.data.model).toBeUndefined(); + await h.open(listed.sessions[0]!.persistence); + await h.wait((e) => e.type === "session.ready"); + expect( + h.events.some((e) => e.type === "session.config" && e.config.model === "meta/muse-spark-1.3"), + ).toBe(true); +}); test("sessions list filters workspace and imported persistence opens via resume", async () => { const h = await harness("resume-without-cursor"); await h.send({ @@ -1155,7 +1417,7 @@ for (const usage of [ test(`usage source presents ${"usage" in usage ? "subscription windows" : "real route absence"} from discovered accounts`, async () => { const h = await harness("catalog-controls", { MUSE_TEST_USAGE: JSON.stringify(usage) }); await h.open(); - const inputs = await h.usageSource.discover(); + const inputs = await h.usageSource.discover({ kind: "global" }); expect(inputs).toHaveLength(1); const account = inputs[0]!; expect(account).toEqual({ @@ -1306,7 +1568,7 @@ test("usage identity follows the resolved config directory across credential and META_BASE_URL: route!, }); await h.open(); - const inputs = await h.usageSource.discover(); + const inputs = await h.usageSource.discover({ kind: "global" }); expect(inputs).toHaveLength(1); identities.push(inputs[0]!.key); } @@ -1316,7 +1578,7 @@ test("usage identity follows the resolved config directory across credential and test.each(["no sessions", "unrelated environment"])("Muse discovery is empty with %s", async () => { const { Usage } = await import("../server/usage.js"); - expect(await new Usage().registration().discover()).toEqual([]); + expect(await new Usage().registration().discover({ kind: "global" })).toEqual([]); }); test("Muse window identity follows the reported duration instead of assuming five hours", async () => { @@ -1331,7 +1593,7 @@ test("Muse window identity follows the reported duration instead of assuming fiv }), }); await h.open(); - const [account] = await h.usageSource.discover(); + const [account] = await h.usageSource.discover({ kind: "global" }); expect(await h.usageSource.fetch(account!.input)).toMatchObject({ status: "available", windows: [ @@ -1340,3 +1602,131 @@ test("Muse window identity follows the reported duration instead of assuming fiv ], }); }); + +test("status accepts initialization slower than the account deadline without isolating history", async () => { + const h = await harness("catalog-controls", { MUSE_TEST_INITIALIZE_DELAY_MS: "3500" }); + expect(await h.provider.status!({ launch: h.launch })).toEqual({ available: true }); + expect(await h.recorded()).toContainEqual({ event: "hostClosed" }); +}, 10000); + +for (const extension of ["cmd", "bat"]) { + test.runIf(process.platform === "win32")( + `launches the discovered Muse .${extension} for status, catalog and sessions`, + async () => { + const h = await harness("catalog-controls"); + const shim = path.join(h.root, `muse launcher.${extension}`); + await writeFile(shim, `@echo off\r\n"${process.execPath}" "${h.launch.args[0]}" %*\r\n`); + h.launch.command = shim; + h.launch.args = []; + expect(await h.provider.status!({ launch: h.launch })).toEqual({ available: true }); + await h.send({ type: "catalog", requestId: "shim-catalog" }); + expect(await h.wait((event) => event.type === "catalog")).toMatchObject({ + requestId: "shim-catalog", + }); + await h.open(); + expect(await h.wait((event) => event.type === "session.opened")).toMatchObject({ + sessionId: "paseo-session", + }); + }, + 20000, + ); +} + +test("force-stops a Muse host that ignores stdin EOF", async () => { + const h = await stubbornHostHarness(); + try { + const result = await Promise.race([ + h.provider.status!({ launch: h.launch }), + delay(10000).then(() => { + throw new Error("Muse status did not finish closing its host"); + }), + ]); + expect(result).toEqual({ available: true }); + const hosts = (await h.recorded()).filter((row) => row.event === "stubbornHost"); + expect(hosts).toHaveLength(1); + expect(() => process.kill(hosts[0].pid, 0)).toThrow(); + } finally { + await cleanupStubbornHosts(h); + } +}, 15000); + +test("closes the whole Muse host after an uncorrelated MSP error", async () => { + const h = await stubbornHostHarness({ MUSE_TEST_NULL_ERROR_ID: "1" }); + try { + const result = await h.provider.status!({ launch: h.launch }); + expect(result).toEqual({ available: false, diagnostic: "Cannot recover request id" }); + const recorded = await h.recorded(); + expect(recorded.some((row) => row.event === "hostClosed")).toBe(true); + const hosts = recorded.filter((row) => row.event === "stubbornHost"); + expect(hosts).toHaveLength(1); + expect(() => process.kill(hosts[0].pid, 0)).toThrow(); + } finally { + await cleanupStubbornHosts(h); + } +}, 15000); + +async function stubbornHostHarness(env: Record = {}) { + const h = await harness("catalog-controls", { MUSE_TEST_STUBBORN: "1", ...env }); + if (process.platform === "win32") { + const shim = path.join(h.root, "stubborn muse.cmd"); + await writeFile(shim, `@echo off\r\n"${process.execPath}" "${h.launch.args[0]}" %*\r\n`); + h.launch.command = shim; + h.launch.args = []; + } + return h; +} +async function cleanupStubbornHosts(h: Awaited>) { + // Keep a failing Windows regression from leaving its deliberately stubborn child alive. + for (const host of (await h.recorded()).filter((row) => row.event === "stubbornHost")) { + if (process.platform === "win32") { + await execCommand("taskkill.exe", ["/PID", String(host.pid), "/T", "/F"], { + shell: false, + }).catch(() => {}); + } else { + try { + process.kill(host.pid, "SIGKILL"); + } catch {} + } + } +} + +test("Muse reports belong only to the session's account and remain available globally", async () => { + const usage = new MuseUsage(); + const homeEnv = { HOME: "/usage/home" }; + const configEnv = { HOME: "/usage/home", XDG_CONFIG_HOME: "/usage/work" }; + const accountIds = [homeEnv, configEnv].map((env, index) => { + const launch = { command: "muse", args: [], env }; + usage.attach(`session-${index}`, launch, async () => ({ + usage: { + observedAtMs: 1700000000000, + tier: "Pro", + window: { usedPercent: 11, resetsAtMs: 1700018000000, windowDurationMins: 120 }, + weekly: { usedPercent: 22, resetsAtMs: 1700604800000 }, + }, + })); + return `muse:${usage.remember(launch).account}`; + }); + const sessions = new Map([ + ["claude", { provider: "claude", env: homeEnv, sessionKey: "claude" }], + ["codex", { provider: "codex", env: configEnv, sessionKey: "codex" }], + ["muse-home", { provider: "muse", env: homeEnv, sessionKey: "muse-home" }], + ["muse-work", { provider: "muse", env: configEnv, sessionKey: "muse-work" }], + ["muse-unknown", { provider: "muse", env: { HOME: "/usage/unknown" }, sessionKey: "unknown" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + registry.register(usage.registration()); + + expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds); + expect(await registry.listReports({ agentId: "claude" })).toEqual([]); + expect(await registry.listReports({ agentId: "codex" })).toEqual([]); + for (const [index, agentId] of ["muse-home", "muse-work"].entries()) { + const reports = await registry.listReports({ agentId }); + expect(reports.map((report) => report.id)).toEqual([accountIds[index]]); + expect(reports[0]!.report.status).toBe("available"); + } + expect(await registry.listReports({ agentId: "muse-unknown" })).toEqual([]); + expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds); +}); diff --git a/plugins/package.json b/plugins/package.json index b650ffa697..61d1231759 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.11.0-beta.3", + "version": "0.11.0-beta.4", "private": true, "type": "module", "scripts": { diff --git a/public-docs/connectivity.md b/public-docs/connectivity.md index 3263e61510..89dd596d95 100644 --- a/public-docs/connectivity.md +++ b/public-docs/connectivity.md @@ -45,7 +45,9 @@ paseo --host 'ssh://user@host?daemonPort=7777' ls -a Put `--host` before the command. `paseo daemon status` observes the default local home; use `paseo --host ssh://user@host daemon status` to query a remote daemon. `paseo --host ssh://user@host run --cwd /path/on/remote ...` requires a working directory that exists on the remote host. -In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination. +In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination, including `:port` or `?daemonPort=` when those differ from the defaults. + +If the remote daemon has a password, enter it in **Daemon password**; it is stored with the host and sent on every connection, the same as a direct connection's password. SSH login itself stays key-based — Paseo never prompts for an SSH password. ## Paseo relay @@ -123,6 +125,7 @@ If the host was already paired through the relay, Paseo adds the direct connecti - **SSH authentication failed:** Run `ssh user@host` in a terminal and fix the key, agent, host key, or `~/.ssh/config` entry there. Paseo does not prompt for SSH passwords. - **SSH connects but Paseo is refused:** Run `paseo daemon status` on the remote host. SSH transport does not start the daemon. +- **SSH connects but Paseo reports "Password required":** The remote daemon is password-protected. Remove the SSH connection from the host and add it again, this time entering the daemon password in **Daemon password**. - **Connection timed out:** Check that Tailscale is connected on both devices and that you used the daemon machine's Tailscale IP. - **Connection refused:** Run `paseo daemon status` and confirm the daemon is running on the configured IP and port. - **Config change has no effect:** Run `paseo reload`. `daemon.listen` is a startup setting, so restart when the command reports it. diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 69cf206641..2f4951669c 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -73,6 +73,32 @@ The connection has three operations: Keep the native SDK, process, and stream inside the connection implementation. Convert its output to `ProviderEvent` objects before publishing it. +## Launch the provider CLI + +Use `spawnProcess` and `execCommand` from `@getpaseo/plugin/server` for process transports +and CLI probes. Both handle Windows `.cmd`/`.bat` launchers and quote shell arguments. +Raw Node `spawn` and `execFile` reject those launchers on Windows. + +The daemon supplies `request.launch` with the resolved command, argument prefix, and complete +environment. Pass that environment explicitly; the helpers do not apply daemon environment policy. +Keep the process lifecycle and protocol deadlines inside your provider. End stdin for graceful +shutdown; use `terminateProcess(child)` for a forced stop. It kills the process tree on Windows, +including the CLI behind a command-script launcher. Bound your final wait for closed streams. + +```ts +import { spawnProcess, execCommand } from "@getpaseo/plugin/server"; + +const child = spawnProcess(launch.command, [...launch.args, "serve"], { + env: launch.env, + cwd, + stdio: "pipe", +}); +const { stdout } = await execCommand(launch.command, [...launch.args, "--version"], { + env: launch.env, + timeout: 5000, +}); +``` + ## Return models, modes, and thinking options Paseo requests the catalog before creating a session. Return the choices needed by the agent form: diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 760cb1857d..086f910599 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -342,6 +342,7 @@ A source implements two calls: interface UsageAccount { key: string; label?: string; + harness?: string; input: JsonValue; } @@ -375,25 +376,29 @@ type UsageProblem = | { kind: "no_quota"; detail: string }; ``` -`discover({ kind: "global" })` queries machine login stores, including expired logins. Session -scope queries only the login stores selected by that harness's resolved launch environment. -Return `[]` when no login exists or the session does not use your source. Never scan default stores -from session discovery or scan agents from global discovery. Discovery is a query, with no agent +Session discovery returns only the login that agent runs on. The agent popover uses only those +inputs; it never tries logins from global discovery or other agents. For any other provider, +return `[]`. Global discovery lists every account, including expired logins. + +Resolve the session account from that session's launch environment. Return `[]` when no login exists. +Never scan default stores from session discovery or scan agents from global discovery. Discovery is a query, with no agent lifecycle hooks. Closed agents have no session scope until resumed. Inputs name credential stores; never put credentials in inputs or reports. Paseo validates each input against your schema before calling `fetch()`. The same key in any scope is the same report; -agents sharing an account share the fetch cache. Fetches have a 20-second deadline. +fetches share a five-minute cache only when their ordered login inputs match. Each login fetch +has a 20-second deadline. Built-in session routes: -| Source | Session | Login store | -| ------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Claude | `claude` | `CLAUDE_CONFIG_DIR`, or the default; on macOS, the directory's Keychain entry takes precedence | -| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store | -| Codex | `codex` | `CODEX_HOME/auth.json`, or the default | -| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store | -| Other sources | Any | No session discovery | +| Source | Session | Login store | +| ---------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Claude | `claude` | `CLAUDE_CONFIG_DIR`, or the default; on macOS, the directory's Keychain entry takes precedence | +| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store | +| Codex | `codex` | `CODEX_HOME/auth.json`, or the default | +| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store | +| Muse | `muse` | That session's own Muse launch/config | +| Other built-in sources | Any | No session discovery | Claude excludes Bedrock, Vertex, and foreign `ANTHROPIC_BASE_URL` sessions. Codex excludes sessions with `OPENAI_BASE_URL` set. @@ -403,9 +408,13 @@ organization whose quota is metered and survives token rotation. Never use a cre email as the key. Use `hashAccountKey()` for sensitive stable identities or a store locator when account metadata is unavailable. Labels can name accounts without becoming their identity. -Return logins in preference order. Several entries with the same key become one card, with their -inputs tried in order until a report is `available`. An unavailable report, error report, or thrown -fetch falls through to the next input. If none succeeds, the card carries the last report. +Return logins in preference order and set `harness` to the owning harness's display name, such as +Codex, OpenCode, Pi, or OMP. The daemon treats inputs as opaque and never derives labels from them. + +The host-wide Usage screen groups logins with the same key into one account card and tries them +concurrently, preferring the discovery order when selecting a result. If any login returns +`available`, it shows usage with no errors. If all fail, it shows +every login's error on a separate line with its harness label and its own remedy. Discovery failures are logged by the daemon and produce no card. Window names must come from provider data: an explicit duration, a named API field such as @@ -485,7 +494,8 @@ metadata or JWT claims. Pi and OMP logins remain discoverable when expired. OMP `usage.list_reports` discovers accounts when called without IDs. With IDs, it refreshes known accounts without rediscovering identity. If a store switches accounts, the existing card shows the new login's quota until the next discovery. Reports are cached for five minutes; `forceRefresh` -bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Clients gate the feature +bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Failed accounts also carry optional +`loginErrors` entries with `harness` and each login's failed `report`. Clients gate the feature on `server_info.features.usageSources`. The older `provider.usage.list` RPC maps the same reports for 0.10 clients and renders problems into its `error` string. diff --git a/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index cb80266c13..2b6e26c3ea 100644 --- a/scripts/trace-daemon.mjs +++ b/scripts/trace-daemon.mjs @@ -13,7 +13,8 @@ // node_modules populated (the Nix build invokes this post-configHook). import { nodeFileTrace } from "@vercel/nft"; -import { glob } from "node:fs/promises"; +import { glob, readFile, readdir } from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -29,6 +30,47 @@ const { sherpaPlatformPackageName } = await import( ); const traceDesktop = process.env.PASEO_TRACE_DESKTOP === "1"; +const terminalModule = "packages/server/dist/server/terminal/terminal.js"; +const sherpaModule = + "packages/server/dist/server/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.js"; +const sherpaEnvModule = + "packages/server/dist/server/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; + +// Resolve-only and computed requires need explicit graph edges, not installed +// paths. Analyze them at their real importer so nft retains the package exports, +// manifests and workspace symlinks used by Node's resolution. +const runtimeDependencies = new Map([ + ["packages/cli/dist/commands/daemon/local-daemon.js", ["@getpaseo/server"]], + [terminalModule, ["@getpaseo/cli/bin/paseo", "node-pty/package.json"]], + [sherpaModule, ["sherpa-onnx-node"]], + [sherpaEnvModule, [`${sherpaPlatformPackageName()}/package.json`]], + ...(traceDesktop + ? [ + ["packages/desktop/dist/daemon/runtime-paths.js", ["@getpaseo/server"]], + ["packages/desktop/dist/integrations/cli-install/paths.js", ["@getpaseo/cli/bin/paseo"]], + ] + : []), +]); + +function requireFrom(importer) { + return createRequire(path.join(REPO_ROOT, importer)); +} + +function resolvedPackageFiles(importer, specifier) { + const manifest = requireFrom(importer).resolve(`${specifier}/package.json`); + return path.join(path.dirname(manifest), "**"); +} + +// Let node-pty select the native build it actually loads. npm hoisting and +// prebuild/build layout are owned by the package, not this trace. Its Darwin +// spawn-helper lives beside the selected addon; retain the files in that dir. +const terminalRequire = requireFrom(terminalModule); +const ptyLoader = terminalRequire.resolve("node-pty/lib/utils"); +const { dir: ptyNativeDir } = terminalRequire(ptyLoader).loadNativeModule("pty"); +const ptyNativeRoot = path.resolve(path.dirname(ptyLoader), ptyNativeDir); +const ptyNativeFiles = (await readdir(ptyNativeRoot, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(ptyNativeRoot, entry.name)); // Daemon entry points. Workers forked into their own Node processes have // independent require trees; nft does not follow fork boundaries, so trace @@ -63,33 +105,40 @@ const additionalInputs = [ "packages/server/dist/server/server/agent/providers/opencode/**/bridge-plugin.bundle.mjs", // Server runtime config files (read by path, not require) "packages/server/.env.example", - // CLI shebang script wrapping dist/index.js - "packages/cli/bin/paseo", - // node-pty's compiled native addon. nft can't trace it because - // node-pty loads it via `require(path.join(__dirname, 'prebuilds//pty.node'))` - // with a runtime-computed platform suffix. Pin to the host platform — - // the Nix derivation builds for one platform at a time and ships only - // its own binaries. - `node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/**`, - // sherpa-onnx-node dynamically resolves a platform-specific native package. - // Copy the wrapper plus the host platform package explicitly. - "node_modules/sherpa-onnx-node/**", - `node_modules/${sherpaPlatformPackageName()}/**`, + ...ptyNativeFiles, + // Resolve native speech packages from the same modules as the runtime. + resolvedPackageFiles(sherpaModule, "sherpa-onnx-node"), + resolvedPackageFiles(sherpaEnvModule, sherpaPlatformPackageName()), ...(traceDesktop ? [ // The unpackaged Nix launcher resolves these beside desktop/dist. "packages/desktop/package.json", "packages/desktop/assets/**", - // resolveExternalCliEntrypoint() looks up the workspace through this - // link at runtime; nft traces the target files but not the link. - "node_modules/@getpaseo/cli", ] : []), ]; +// Fail at build time if a required runtime edge cannot resolve. +for (const [importer, specifiers] of runtimeDependencies) { + for (const specifier of specifiers) requireFrom(importer).resolve(specifier); +} + // Trace. const { fileList, warnings } = await nodeFileTrace(entries, { base: REPO_ROOT, + async readFile(file) { + let source; + try { + source = await readFile(file); + } catch (error) { + if (error.code === "ENOENT" || error.code === "EISDIR") return null; + throw error; + } + const dependencies = runtimeDependencies.get(path.relative(REPO_ROOT, file)); + if (!dependencies) return source; + // These statements exist only in nft's input, never in the shipped code. + return `${source}\n${dependencies.map((specifier) => `require(${JSON.stringify(specifier)});`).join("\n")}`; + }, // Tolerate the conditional / dynamic patterns we already audited: // sherpa-onnx-${platform}-${arch} package resolution (the host package // is copied explicitly above), and a handful of test-only requires that @@ -129,10 +178,10 @@ const expanded = new Set(fileList); for (const pattern of additionalInputs) { if (pattern.includes("*")) { for await (const file of glob(pattern, { cwd: REPO_ROOT })) { - expanded.add(file); + expanded.add(path.relative(REPO_ROOT, path.resolve(REPO_ROOT, file))); } } else { - expanded.add(pattern); + expanded.add(path.relative(REPO_ROOT, path.resolve(REPO_ROOT, pattern))); } }