mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-16 06:12:40 +00:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e791ab3df7 | ||
|
|
f521abfb24 | ||
|
|
b89ebbede6 | ||
|
|
aec01ea605 | ||
|
|
a1da1b8f23 | ||
|
|
73390346ca | ||
|
|
718ed932b7 | ||
|
|
0009a7e717 | ||
|
|
29db2b7980 | ||
|
|
8c096b5e33 | ||
|
|
d0bc188a5b | ||
|
|
06fe97c901 | ||
|
|
f943e790b6 | ||
|
|
23d7a089e2 | ||
|
|
99fc204c55 | ||
|
|
33eb36c021 | ||
|
|
0fc6bf0c94 | ||
|
|
2d55870490 | ||
|
|
ab10a6694c | ||
|
|
2a151a4494 | ||
|
|
9fcae41c98 | ||
|
|
b352dad2e3 | ||
|
|
1ae979d3d0 | ||
|
|
d46164ce0d | ||
|
|
a591320409 | ||
|
|
ee6deda079 | ||
|
|
0555c877d0 | ||
|
|
b6454d7ea4 | ||
|
|
1b88f16124 | ||
|
|
1fae8e89f7 | ||
|
|
22488d4502 | ||
|
|
2a5264c188 | ||
|
|
8ddeb79c8d | ||
|
|
c90587e5b7 | ||
|
|
56f4fca9e4 | ||
|
|
28f7528588 | ||
|
|
f13bb2136c | ||
|
|
2323ee6844 | ||
|
|
1e00f452ea | ||
|
|
a162e1075f | ||
|
|
8f1091da64 | ||
|
|
4dd0e50e78 | ||
|
|
dc785bb29a | ||
|
|
93bfb34e21 | ||
|
|
2d85fe4e04 | ||
|
|
1463396ebc | ||
|
|
d4ea77b910 | ||
|
|
f715221de8 | ||
|
|
aedba5ec27 | ||
|
|
bdc58fb07c | ||
|
|
3f1a04304d | ||
|
|
50cf244e86 | ||
|
|
aaeefdc00b | ||
|
|
c5deaa0516 | ||
|
|
72c180aaf7 | ||
|
|
66b2567be3 | ||
|
|
cb2b569bbd | ||
|
|
2ecf3ba783 | ||
|
|
4515061f88 | ||
|
|
3c478e5967 | ||
|
|
05637a7f05 | ||
|
|
13d21f60f1 | ||
|
|
fc3b2c4eb7 | ||
|
|
a9fef4879a | ||
|
|
a292b402cf | ||
|
|
64c9f263e8 | ||
|
|
48059aeae3 |
+54
-8
@@ -1,12 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0-beta.5 - 2026-10-06
|
||||
## 0.11.2 - 2026-10-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445))
|
||||
|
||||
## 0.11.1 - 2026-10-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308))
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316))
|
||||
- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301))
|
||||
|
||||
## 0.11.0 - 2026-10-07
|
||||
|
||||
Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry).
|
||||
|
||||
**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory.
|
||||
|
||||
### Added
|
||||
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
|
||||
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||
- Added 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 Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216))
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
@@ -16,21 +38,26 @@
|
||||
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
|
||||
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
|
||||
- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
||||
- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1))
|
||||
- Added `agents.providers.<id>.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))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224))
|
||||
- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-items) to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151))
|
||||
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
|
||||
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
|
||||
- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263))
|
||||
- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226))
|
||||
|
||||
### Changed
|
||||
|
||||
- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps.
|
||||
|
||||
- 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 details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185))
|
||||
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot))
|
||||
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
|
||||
@@ -46,12 +73,18 @@
|
||||
### 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, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100))
|
||||
- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson))
|
||||
- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272))
|
||||
- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248))
|
||||
- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268))
|
||||
- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022))
|
||||
- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074))
|
||||
- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov))
|
||||
- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151))
|
||||
- Fixed ACP agents running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273))
|
||||
- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228))
|
||||
- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045))
|
||||
- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
|
||||
@@ -66,12 +99,14 @@
|
||||
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112))
|
||||
- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115))
|
||||
- Fixed reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215))
|
||||
- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061))
|
||||
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
|
||||
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
|
||||
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
|
||||
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
|
||||
- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul))
|
||||
- Fixed OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188))
|
||||
- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum))
|
||||
- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988))
|
||||
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
@@ -85,12 +120,18 @@
|
||||
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
|
||||
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
|
||||
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
|
||||
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172))
|
||||
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138))
|
||||
- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158))
|
||||
- Fixed copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195))
|
||||
- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968))
|
||||
- Fixed text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207))
|
||||
- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219))
|
||||
- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq))
|
||||
- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen))
|
||||
- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu))
|
||||
- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio))
|
||||
@@ -98,12 +139,16 @@
|
||||
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
|
||||
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
|
||||
- Fixed dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
|
||||
- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279))
|
||||
- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117))
|
||||
- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097))
|
||||
- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095))
|
||||
- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
|
||||
- Fixed a failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255))
|
||||
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
|
||||
- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart))
|
||||
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
|
||||
@@ -123,6 +168,7 @@
|
||||
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||
- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239))
|
||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
||||
|
||||
@@ -39,21 +39,33 @@
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android.
|
||||
|
||||
- **Parallel agents:** Run many agents at once, each in its own worktree.
|
||||
- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers.
|
||||
- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want.
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
|
||||
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
|
||||
|
||||
## Plugins
|
||||
|
||||
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
|
||||
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
|
||||
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
|
||||
iOS, and Android. Write a plugin once and it is on your phone.
|
||||
|
||||
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
|
||||
machine and inside connected clients; install only code you trust.
|
||||
- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes.
|
||||
- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends.
|
||||
- **Providers:** add a coding agent as a provider.
|
||||
|
||||
Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory.
|
||||
|
||||
**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)**
|
||||
|
||||
Plugins run with access to your daemon machine and inside connected clients; install only code you trust.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -77,16 +89,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r
|
||||
|
||||
To connect from your phone, open **Settings → your host → Pair Device**.
|
||||
|
||||
### CLI / headless
|
||||
### Server
|
||||
|
||||
Install the CLI and start Paseo:
|
||||
For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
|
||||
Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host.
|
||||
|
||||
For full setup and configuration, see:
|
||||
|
||||
@@ -197,7 +209,7 @@ npm run typecheck
|
||||
|
||||
## Sponsors
|
||||
|
||||
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
|
||||
|
||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||
|
||||
|
||||
+30
-3
@@ -69,11 +69,11 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
|
||||
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
|
||||
@@ -84,6 +84,32 @@ Some providers can create their own child sessions inside one provider runtime.
|
||||
|
||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
||||
|
||||
## Background workspaces
|
||||
|
||||
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
|
||||
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
|
||||
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
|
||||
change it. New workspaces default to false, inherit the caller workspace's value when an agent
|
||||
creates one without an override, and accept explicit true or false.
|
||||
|
||||
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
|
||||
including History requests, exclude background work; `filter.includeBackground` includes it.
|
||||
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
|
||||
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
|
||||
|
||||
Sequenced directories keep complete canonical collections. A filtered read must never replace
|
||||
those collections with its subset. Inclusive and default observations have distinct cursor
|
||||
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
|
||||
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
|
||||
### Private daemon helpers
|
||||
|
||||
Branch-name and commit-message generators still use private disposable internal runtimes.
|
||||
`AgentManager` does not persist them or their provider sessions, excludes them from public
|
||||
discovery and plugin hooks, and drops their timelines on archive. An archived helper retains
|
||||
its exact-ID result briefly to cover completion before a waiter attaches. This internal
|
||||
lifecycle is separate from public workspace visibility and is not a public creation option.
|
||||
|
||||
## Archive
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
@@ -197,10 +223,11 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
|
||||
|
||||
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
|
||||
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
|
||||
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
|
||||
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`.
|
||||
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
|
||||
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
|
||||
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
|
||||
- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one.
|
||||
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
|
||||
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
|
||||
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
|
||||
|
||||
@@ -345,6 +345,10 @@ join active work or reuse committed stages. A persisted resource alone cannot pr
|
||||
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
|
||||
receipt return an unknown outcome instead of being repeated.
|
||||
|
||||
Agent registration commits creation. Rejecting the first prompt is a turn error on that
|
||||
agent; reporting creation failure leaves an orphaned agent and invites another Create.
|
||||
Receipt replay must not resend the prompt.
|
||||
|
||||
`packages/client/src/creation/` owns capability selection and legacy orchestration.
|
||||
Callers always pass the initial prompt to agent creation. On an older host, the client
|
||||
adapts keyed creation to the legacy create/send sequence; it cannot continue that
|
||||
|
||||
+10
-2
@@ -123,6 +123,13 @@ Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
|
||||
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
|
||||
selection is unavailable for registry installs; install an explicit source to select your own.
|
||||
|
||||
Registry overviews are untrusted author content, even before installation. Render them with
|
||||
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
|
||||
URLs, and isolated external links. The website adapter and corpus tests live in
|
||||
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
|
||||
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
|
||||
this policy. Installed-plugin descriptions in the app remain plain text.
|
||||
|
||||
Use `host/owner/slug` for a registry at `https://host`, or set
|
||||
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
||||
Private registry credentials live in daemon config under
|
||||
@@ -152,8 +159,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r
|
||||
## Managed source ownership
|
||||
|
||||
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
|
||||
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
|
||||
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before
|
||||
calling `installPluginSource`; see the public source reference for CLI and app path semantics.
|
||||
Only the daemon reads plugin files and acquires sources.
|
||||
|
||||
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
|
||||
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
|
||||
|
||||
+3
-1
@@ -61,6 +61,8 @@ Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`code
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
Keep the curated Claude catalog: `supportedModels()` has historically omitted older versions and carries less metadata. Discovery is only an opt-in visibility gate, not a replacement catalog or an entitlement guarantee. Claude Code v2.1.197 added `resolvedModel` to disambiguate alias rows; a bare family alias does not establish access to a particular version. Explicit user-configured models must remain selectable when discovery fails or omits them.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
|
||||
|
||||
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
|
||||
@@ -103,7 +105,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. It calls `query.interrupt()` only while a main-session turn is in flight (between `system/init` and that turn's `result`): with the main session idle, an interrupt reaches only background subagents and no result follows it, so the adapter withdraws its unstarted messages and stops there. The adapter also declares `perTaskStopAffordance`, because without it the CLI kills every background subagent on interrupt. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
|
||||
|
||||
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||
|
||||
|
||||
+19
-5
@@ -223,7 +223,7 @@ submission. Overlapping sends settle independently rather than collapsing to one
|
||||
message.
|
||||
|
||||
Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted
|
||||
prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
|
||||
human prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
|
||||
same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted
|
||||
row is recorded before handler output. The app tracks submission transactions only for hosts with this
|
||||
capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC
|
||||
@@ -238,11 +238,25 @@ foreground control ownership remains a separate daemon concern. Cancellation req
|
||||
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
|
||||
remain a separate pre-turn registry and retire on canonical acknowledgement.
|
||||
|
||||
Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
|
||||
the Paseo timeline, so one visible response can span several canonical turns without a user message
|
||||
between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
|
||||
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
|
||||
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
|
||||
prompts, including schedule injections, remain hidden. One visible response can span several canonical
|
||||
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
|
||||
fork positions retain the canonical `turnId` boundaries.
|
||||
|
||||
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
|
||||
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
|
||||
also the projected call ID, so a provider echo cannot create a second row. Sender IDs are opaque and
|
||||
do not imply local ownership or authorization. Capture the sender title at delivery: archived or
|
||||
removed senders must remain understandable without a live directory lookup.
|
||||
|
||||
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
|
||||
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
|
||||
must not construct UI labels or prepend sender labels to the semantic message body. The existing
|
||||
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
|
||||
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
|
||||
XML. Stream reduction and disk-cache serialization preserve the payload without rendering it.
|
||||
|
||||
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
|
||||
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
|
||||
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
|
||||
@@ -250,7 +264,7 @@ second running state. Disconnect preserves the last replicated turn until cache
|
||||
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
|
||||
liveness, never from submission records or whichever timeline rows happen to be mounted.
|
||||
|
||||
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
||||
For human prompts, the daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
|
||||
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
|
||||
the wire. A correlated provider echo records the provider's native identity internally without
|
||||
dispatching another timeline event. Rewind resolves the wire identity to that provider identity at
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed intermittent text clipping in iOS chats and Markdown previews
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
|
||||
sha256-CK96G7MWsmd/VDvkuaFJ23U04EGNmeiXdTYbmR8RDDQ=
|
||||
|
||||
Generated
+29
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36413,7 +36413,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"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.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/server": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/server": "0.11.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37748,10 +37748,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/relay": "0.11.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37762,7 +37762,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"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.5",
|
||||
"version": "0.11.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38902,7 +38902,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"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.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@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.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"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.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38998,7 +38998,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39216,16 +39216,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"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.5",
|
||||
"@getpaseo/highlight": "0.11.0-beta.5",
|
||||
"@getpaseo/plugin": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/highlight": "0.11.2",
|
||||
"@getpaseo/plugin": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/relay": "0.11.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40146,7 +40146,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -40174,6 +40174,7 @@
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"parse5": "^7.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
@@ -41753,7 +41754,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -284,7 +284,6 @@ async function expectRejectedSubmissionRestored(
|
||||
await expectComposerDraft(page, input.prompt);
|
||||
await expectComposerEditable(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
|
||||
if (input.preservesActiveTurn) {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
return;
|
||||
@@ -295,13 +294,21 @@ async function expectRejectedSubmissionRestored(
|
||||
|
||||
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(userMessage).toHaveCount(1);
|
||||
const attempts = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(attempts).toHaveCount(2);
|
||||
const userMessage = attempts.last();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) {
|
||||
const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message"));
|
||||
await expect(messages).toHaveCount(2);
|
||||
await expect(messages.nth(0)).toContainText(prompt);
|
||||
await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`);
|
||||
}
|
||||
|
||||
async function configureSteerInSettings(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
@@ -1173,13 +1180,14 @@ test.describe("Agent message submission", () => {
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
test("restores a rejected submission and accepts its retry", async ({
|
||||
test("retains a rejected submission before its error and accepts its retry", async ({
|
||||
page,
|
||||
rejectionScenario,
|
||||
}) => {
|
||||
const prompt = "Restore this rejected submission.";
|
||||
await submitMessageThatWillBeRejected(page, prompt);
|
||||
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
|
||||
await expectRejectedPromptBeforeError(page, prompt, rejectionScenario.errorMessage);
|
||||
await retryRestoredSubmission(page, prompt);
|
||||
});
|
||||
|
||||
@@ -1209,6 +1217,7 @@ test.describe("Agent message submission", () => {
|
||||
errorMessage: "Requested mock steer transport failure",
|
||||
preservesActiveTurn: true,
|
||||
});
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
|
||||
|
||||
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1);
|
||||
expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore);
|
||||
|
||||
@@ -128,3 +128,46 @@ for (const scenario of [
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of [
|
||||
{
|
||||
name: "agent draft",
|
||||
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.openAgentDraft(),
|
||||
button: "Send message",
|
||||
settled: async (_creation: Awaited<ReturnType<typeof createCreationScenario>>) => {},
|
||||
},
|
||||
{
|
||||
name: "new workspace",
|
||||
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.openWorkspaceForm("local"),
|
||||
button: "Create",
|
||||
settled: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.expectOneCreatedWorkspace(),
|
||||
},
|
||||
]) {
|
||||
test(`${entry.name} becomes an agent tab when the first prompt is rejected`, async ({
|
||||
creation,
|
||||
promptRejection,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
void promptRejection;
|
||||
const prompt =
|
||||
"Continue the attached conversation.\n" + "Earlier conversation context.\n".repeat(100);
|
||||
await entry.open(creation);
|
||||
await creation.submitPrompt(prompt, entry.button);
|
||||
await entry.settled(creation);
|
||||
await creation.expectCreatedAgentError();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
|
||||
await creation.reloadAgent();
|
||||
await creation.expectPromptBeforeError(prompt);
|
||||
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
|
||||
await creation.expectPromptVisible(
|
||||
"emit 1 coalesced agent stream updates for a corrected prompt.",
|
||||
);
|
||||
await creation.expectAssistantReply();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
|
||||
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
|
||||
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
|
||||
await installPluginWithUntrustedDescription(pluginEnvironment, description);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
const text = page.getByText(description, { exact: true });
|
||||
await expect(text).toBeVisible();
|
||||
// This field must remain a text node, with no Markdown links, images, or HTML children.
|
||||
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
|
||||
});
|
||||
|
||||
async function installPluginWithUntrustedDescription(
|
||||
environment: PluginEnvironment,
|
||||
description: string,
|
||||
) {
|
||||
const directory = await createDirectoryPlugin(
|
||||
environment.directory,
|
||||
"plain-text-plugin",
|
||||
description,
|
||||
"Plain text plugin",
|
||||
);
|
||||
await environment.client.installPluginSource({ source: directory });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
@@ -73,3 +74,75 @@ test.describe("Sidebar workspace rename", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Show background", exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
async function expectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// Rows have no single accessible role: their nested title and actions share this container.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectWorkspaceHidden(page: Page, workspaceId: string): Promise<void> {
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function reconnectApp(page: Page): Promise<void> {
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Display preferences", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectWorkerOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Background reviewer", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("Background review prompt", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("background workspace stays directly accessible across sidebar toggles and reconnect", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-background-",
|
||||
title: "Background review",
|
||||
background: true,
|
||||
});
|
||||
try {
|
||||
const worker = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Background reviewer",
|
||||
initialPrompt: "Background review prompt",
|
||||
model: "e2e-fast-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await test.step("Default sidebar hides background work", async () => {
|
||||
await gotoAppShell(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
});
|
||||
await test.step("Show background exposes the fully replicated workspace", async () => {
|
||||
await toggleBackgroundWorkspaces(page);
|
||||
await expectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await reconnectApp(page);
|
||||
await expectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
});
|
||||
await test.step("Direct agent navigation survives hiding and reconnect", async () => {
|
||||
await toggleBackgroundWorkspaces(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: worker.id });
|
||||
await expectWorkerOpen(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
await reconnectApp(page);
|
||||
await expectWorkerOpen(page);
|
||||
await expectWorkspaceHidden(page, workspace.workspaceId);
|
||||
await page.screenshot({ path: testInfo.outputPath("background-direct-open.png") });
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ bash packages/app/e2e/mobile/native-text-reflow/ios.sh
|
||||
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
|
||||
and resizes its content container from 500 to 240 and back to 500 points. It
|
||||
asserts the inner frame follows both widths and the same selection survives.
|
||||
It then recycles the view the way Fabric's pool does and remounts it 30 points
|
||||
wider and at the same size; the inner frame must still match the container.
|
||||
A zeroed frame on recycle comes back 30 or 0 points wide. This assertion checks
|
||||
the intermediate recycled geometry; it does not reproduce visible clipping in a
|
||||
mounted Fabric paragraph or exercise the subsequent `drawRect` call.
|
||||
It uses public UIView/UITextView operations, without mocking React Native.
|
||||
LLDB detaches after the probe. An assertion failure or evaluation error fails the
|
||||
shell command; the log distinguishes them.
|
||||
|
||||
@@ -2,10 +2,17 @@ import lldb
|
||||
|
||||
|
||||
def __lldb_init_module(debugger, _internal_dict):
|
||||
process = debugger.GetSelectedTarget().GetProcess()
|
||||
main_threads = [thread for thread in process if thread.GetQueueName() == "com.apple.main-thread"]
|
||||
if len(main_threads) != 1 or not process.SetSelectedThread(main_threads[0]):
|
||||
raise RuntimeError("Could not select the UIKit main thread")
|
||||
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
|
||||
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
|
||||
frame = process.GetSelectedThread().GetSelectedFrame()
|
||||
options = lldb.SBExpressionOptions()
|
||||
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
|
||||
is_main_thread = frame.EvaluateExpression("(BOOL)[NSThread isMainThread]", options)
|
||||
if is_main_thread.GetError().Fail() or is_main_thread.GetValueAsUnsigned() != 1:
|
||||
raise RuntimeError("Refusing to run UIKit probe outside the main thread")
|
||||
result = frame.EvaluateExpression(
|
||||
r'''({
|
||||
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
|
||||
@@ -39,14 +46,28 @@ def __lldb_init_module(debugger, _internal_dict):
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat wide = text.frame.size.width;
|
||||
auto keptSelection = text.selectedRange;
|
||||
// Fabric recycles the view: the container keeps its size in the pool,
|
||||
// and the next paragraph mounts it 30 points wider (a list-item paragraph
|
||||
// reused by a full-width one) or at the same size.
|
||||
[host performSelector:@selector(prepareForRecycle)];
|
||||
frame.size.width = 530;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat reusedWider = text.frame.size.width;
|
||||
[host performSelector:@selector(prepareForRecycle)];
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat reusedSame = text.frame.size.width;
|
||||
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
|
||||
selection.location == text.selectedRange.location &&
|
||||
selection.length == text.selectedRange.length &&
|
||||
reusedWider == 530 && reusedSame == 530 &&
|
||||
selection.location == keptSelection.location &&
|
||||
selection.length == keptSelection.length &&
|
||||
selection.location == 12 && selection.length == 9;
|
||||
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
|
||||
ok ? @"PASS" : @"FAIL", narrow, wide,
|
||||
(unsigned long)text.selectedRange.location,
|
||||
(unsigned long)text.selectedRange.length];
|
||||
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f recycled=%.0f,%.0f selection=%lu:%lu",
|
||||
ok ? @"PASS" : @"FAIL", narrow, wide, reusedWider, reusedSame,
|
||||
(unsigned long)keptSelection.location,
|
||||
(unsigned long)keptSelection.length];
|
||||
})''',
|
||||
options,
|
||||
)
|
||||
|
||||
@@ -15,4 +15,4 @@ xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
|
||||
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
|
||||
|
||||
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 recycled=530,530 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
|
||||
export const test = base.extend<{
|
||||
startup: { release(): Promise<void>; fail(): Promise<void> };
|
||||
promptRejection: void;
|
||||
creation: Awaited<ReturnType<typeof createCreationScenario>>;
|
||||
promptRetry: ReturnType<typeof createPromptRetryScenario>;
|
||||
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
|
||||
@@ -65,6 +66,37 @@ export default function contribute(server) {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
promptRejection: async ({ page }, provide) => {
|
||||
void page;
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const plugin = await copyPluginExample("lifecycle-logger");
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(plugin.directory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
return server.before("agent.create", ({ request }) => ({
|
||||
...request,
|
||||
config: {
|
||||
...request.config,
|
||||
model: "e2e-fast-stream",
|
||||
featureValues: { ...request.config.featureValues, mockPromptRejections: 1 },
|
||||
},
|
||||
}));
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
await provide();
|
||||
} finally {
|
||||
await client.removePlugin("lifecycle-logger");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await plugin.cleanup();
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
creation: async ({ page }, provide) => {
|
||||
const creation = await createCreationScenario(page);
|
||||
try {
|
||||
|
||||
@@ -98,7 +98,6 @@ async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, i
|
||||
settings: { modeId: "full-access", thinkingOptionId: "low" },
|
||||
title: "Codex worktree restore reproduction",
|
||||
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
|
||||
background: true,
|
||||
}),
|
||||
);
|
||||
agentId = agent.agentId;
|
||||
|
||||
@@ -182,6 +182,47 @@ export async function createCreationScenario(page: Page) {
|
||||
await this.expectWorkspaceReadyBeforeAgentCompletion();
|
||||
expect((await project.client.fetchAgents()).entries).toHaveLength(0);
|
||||
},
|
||||
async expectCreatedAgentError() {
|
||||
await requests.settled();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("assistant-message")
|
||||
.filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEditable();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
},
|
||||
async expectPromptBeforeError(prompt: string) {
|
||||
const messages = page
|
||||
.getByTestId("user-message")
|
||||
.or(page.getByTestId("assistant-message"))
|
||||
.filter({ visible: true });
|
||||
await expect(messages).toHaveCount(2);
|
||||
await expect(messages.nth(0)).toContainText(prompt);
|
||||
await expect(messages.nth(1)).toContainText("[System Error]");
|
||||
await expect(messages.nth(1)).toBeInViewport();
|
||||
},
|
||||
async reloadAgent() {
|
||||
await page.reload();
|
||||
await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
|
||||
await this.expectCreatedAgentError();
|
||||
},
|
||||
async expectAssistantReply() {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("assistant-message")
|
||||
.filter({ visible: true, hasNotText: "[System Error]" })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectStartupFailure() {
|
||||
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
import { en } from "@/i18n/resources/en";
|
||||
import { openPullRequestPanel } from "./workspace-tabs";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
|
||||
page: Page,
|
||||
state: "open" | "merged" | "closed" | "draft",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface SeedDaemonClient {
|
||||
worktreeSlug?: string;
|
||||
};
|
||||
title?: string;
|
||||
background?: boolean;
|
||||
}): Promise<{
|
||||
workspace: SeedWorkspaceDescriptor | null;
|
||||
error: string | null;
|
||||
@@ -223,6 +224,7 @@ export interface SeededWorkspace {
|
||||
export async function seedWorkspace(options: {
|
||||
repoPrefix: string;
|
||||
title?: string;
|
||||
background?: boolean;
|
||||
port?: number;
|
||||
/** Repo fixture options; only applies to git projects (the default). */
|
||||
repo?: Parameters<typeof createTempGitRepo>[1];
|
||||
@@ -238,6 +240,7 @@ export async function seedWorkspace(options: {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: project.path },
|
||||
title: options.title,
|
||||
background: options.background,
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -792,6 +792,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
detail={data.detail}
|
||||
cwd={context.cwd}
|
||||
metadata={data.metadata}
|
||||
agentMessage={data.agentMessage}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import { ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO } from "@/utils/assistant-image-metadata";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { TaskListRow } from "@/components/task-list-row";
|
||||
@@ -3022,6 +3023,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
}
|
||||
|
||||
interface ToolCallProps {
|
||||
agentMessage?: AgentMessage;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
@@ -3041,6 +3043,7 @@ interface ToolCallProps {
|
||||
}
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
agentMessage,
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
@@ -3081,6 +3084,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
const presentation = useMemo(
|
||||
() =>
|
||||
buildToolCallPresentation({
|
||||
agentMessage,
|
||||
toolName,
|
||||
status,
|
||||
error: error ?? null,
|
||||
@@ -3089,7 +3093,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
cwd,
|
||||
resolveIcon: resolveToolCallIcon,
|
||||
}),
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd],
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
|
||||
);
|
||||
const handleOpenFile = useMemo(() => {
|
||||
const openFilePath = presentation.openFilePath;
|
||||
@@ -3105,7 +3109,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
toolName,
|
||||
displayName: presentation.displayName,
|
||||
summary: presentation.summary,
|
||||
detail: effectiveDetail,
|
||||
detail: presentation.detail,
|
||||
errorText: presentation.errorText,
|
||||
icon: presentation.icon,
|
||||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||||
@@ -3117,12 +3121,12 @@ export const ToolCall = memo(function ToolCall({
|
||||
shouldRenderInline,
|
||||
openToolCall,
|
||||
toolName,
|
||||
presentation.detail,
|
||||
presentation.displayName,
|
||||
presentation.summary,
|
||||
presentation.errorText,
|
||||
presentation.icon,
|
||||
presentation.isLoadingDetails,
|
||||
effectiveDetail,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3158,7 +3162,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<ToolCallDetailsContent
|
||||
toolName={toolName}
|
||||
detail={effectiveDetail}
|
||||
detail={presentation.detail}
|
||||
errorText={presentation.errorText}
|
||||
maxHeight={maxDetailHeight}
|
||||
showLoadingSkeleton={presentation.isLoadingDetails}
|
||||
@@ -3167,7 +3171,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
toolName,
|
||||
effectiveDetail,
|
||||
presentation.detail,
|
||||
presentation.errorText,
|
||||
presentation.isLoadingDetails,
|
||||
maxDetailHeight,
|
||||
|
||||
@@ -63,11 +63,7 @@ import {
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import {
|
||||
hasActiveSidebarLabelFilter,
|
||||
useSidebarViewStore,
|
||||
type SidebarGroupMode,
|
||||
} from "@/stores/sidebar-view-store";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -1919,9 +1915,6 @@ export function SidebarWorkspaceList({
|
||||
const onToggleWorkspacePin = useSidebarWorkspacePinController();
|
||||
const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder);
|
||||
const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder);
|
||||
const hasActiveLabelFilter = useSidebarViewStore((state) =>
|
||||
hasActiveSidebarLabelFilter(state.labelFilter),
|
||||
);
|
||||
const handlePinnedWorkspaceReorder = useCallback(
|
||||
(reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
|
||||
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
||||
@@ -1953,11 +1946,10 @@ export function SidebarWorkspaceList({
|
||||
// this whole subtree, which unmounted the header — and the header is where the display menu's
|
||||
// trigger lives, so filtering the last row away closed the menu you were filtering from.
|
||||
//
|
||||
// Only the label filter can get here. The project filter resolves against the projects it can
|
||||
// see and falls back to "all projects" when nothing matches, so it either keeps at least one
|
||||
// project or is not applied at all — it can narrow this list but never empty it.
|
||||
const sidebarFilterEmpty =
|
||||
hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0;
|
||||
// Background visibility can also hide the last project. Keep the header mounted
|
||||
// whenever a local filter empties an otherwise populated directory, so users can
|
||||
// reach the display menu to reveal their work again.
|
||||
const sidebarFilterEmpty = hasProjectsBeforeFilter && projects.length === 0;
|
||||
|
||||
// Project mode is the one that keeps its project headers; every other grouping mode is a flat
|
||||
// list of grouped rows, so a new mode lands in the grouped branch rather than silently in this
|
||||
|
||||
@@ -301,6 +301,13 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
sheetTitle={t("sidebar.display.heading")}
|
||||
testID="sidebar-display-preferences-content"
|
||||
>
|
||||
<MenuItem
|
||||
selected={preferences.showBackground}
|
||||
closeOnSelect={false}
|
||||
onSelect={preferences.toggleBackground}
|
||||
>
|
||||
{t("sidebar.display.showBackground")}
|
||||
</MenuItem>
|
||||
<MenuSubTrigger
|
||||
id="grouping"
|
||||
value={t(GROUPING_LABEL_KEYS[preferences.grouping])}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems }
|
||||
export type SidebarTrailingChoice = Exclude<SidebarWorkspaceTrailing, "none">;
|
||||
|
||||
export interface SidebarDisplayPreferences {
|
||||
showBackground: boolean;
|
||||
toggleBackground: () => void;
|
||||
grouping: SidebarGroupMode;
|
||||
setGrouping: (mode: SidebarGroupMode) => void;
|
||||
titleSource: WorkspaceTitleSource;
|
||||
@@ -48,6 +50,8 @@ export interface SidebarDisplayPreferences {
|
||||
* this for a value and set it; where it lands is this module's problem.
|
||||
*/
|
||||
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
const showBackground = useSidebarViewStore((state) => state.showBackground);
|
||||
const toggleBackground = useSidebarViewStore((state) => state.toggleBackground);
|
||||
const grouping = useSidebarViewStore((state) => state.groupMode);
|
||||
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
@@ -104,6 +108,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
showBackground,
|
||||
toggleBackground,
|
||||
grouping,
|
||||
setGrouping,
|
||||
titleSource: workspaceTitleSource,
|
||||
@@ -125,6 +131,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
clearLabelFilter,
|
||||
}),
|
||||
[
|
||||
showBackground,
|
||||
toggleBackground,
|
||||
grouping,
|
||||
setGrouping,
|
||||
workspaceTitleSource,
|
||||
|
||||
@@ -57,6 +57,7 @@ export function SidebarModelProvider({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const list = useSidebarWorkspacesList({ enabled: active });
|
||||
const showBackground = useSidebarViewStore((state) => state.showBackground);
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
|
||||
const projectFilters = useSidebarViewStore((state) => state.projectFilters);
|
||||
@@ -107,9 +108,12 @@ export function SidebarModelProvider({
|
||||
workspaces: [...workspaceEntriesByKey.values()],
|
||||
projectFilters: resolvedProjectFilters,
|
||||
});
|
||||
const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter });
|
||||
const filtered = filterWorkspacesByLabels({
|
||||
workspaces: byProject.filter((workspace) => showBackground || !workspace.background),
|
||||
...labelFilter,
|
||||
});
|
||||
return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace]));
|
||||
}, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
|
||||
}, [showBackground, labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
|
||||
const visibleWorkspaceKeys = useMemo(
|
||||
() => new Set(filteredWorkspaceEntriesByKey.keys()),
|
||||
[filteredWorkspaceEntriesByKey],
|
||||
@@ -124,16 +128,19 @@ export function SidebarModelProvider({
|
||||
const included = new Set(resolvedProjectFilters);
|
||||
projects = projects.filter((project) => included.has(project.viewKey));
|
||||
}
|
||||
if (hasActiveLabelFilter) {
|
||||
if (hasActiveLabelFilter || !showBackground) {
|
||||
projects = projects.flatMap((project) => {
|
||||
const workspaces = project.workspaces.filter((workspace) =>
|
||||
visibleWorkspaceKeys.has(workspace.workspaceKey),
|
||||
);
|
||||
return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
|
||||
return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0)
|
||||
? [{ ...project, workspaces }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
return projects;
|
||||
}, [
|
||||
showBackground,
|
||||
hasActiveLabelFilter,
|
||||
hasActiveProjectFilter,
|
||||
resolvedProjectFilters,
|
||||
|
||||
@@ -50,7 +50,7 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
|
||||
import { useProviderSubagentStore } from "@/subagents/provider-store";
|
||||
import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -532,9 +532,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
});
|
||||
|
||||
const unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => {
|
||||
if (message.type !== "agent.provider_subagents.update") return;
|
||||
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
|
||||
const unsubProviderSubagentUpdate = feeds.subscribe({
|
||||
snapshot: () => {
|
||||
void resyncProviderSubagents(client, serverId);
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "agent.provider_subagents.update") return;
|
||||
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
|
||||
},
|
||||
});
|
||||
|
||||
const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => {
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
isPipelineActiveStatus,
|
||||
mapPipelineStatus,
|
||||
} from "@/git/forges/gitlab";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
|
||||
import {
|
||||
deriveAvatarColor,
|
||||
formatAge,
|
||||
getActivityVerb,
|
||||
getStateLabel,
|
||||
getActivityVerbKey,
|
||||
getStateLabelKey,
|
||||
mapPrPaneData,
|
||||
} from "./data";
|
||||
|
||||
@@ -779,37 +780,34 @@ describe("formatAge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStateLabel", () => {
|
||||
const english = i18n.getFixedT("en");
|
||||
const french = i18n.getFixedT("fr");
|
||||
|
||||
describe("getStateLabelKey", () => {
|
||||
it.each([
|
||||
["open", "Open"],
|
||||
["draft", "Draft"],
|
||||
["merged", "Merged"],
|
||||
["closed", "Closed"],
|
||||
] as const)("maps %s → %s", (state, expected) => {
|
||||
expect(getStateLabel(state)).toBe(expected);
|
||||
["open", "Open", "Ouverte"],
|
||||
["draft", "Draft", "Brouillon"],
|
||||
["merged", "Merged", "Fusionnée"],
|
||||
["closed", "Closed", "Fermée"],
|
||||
] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => {
|
||||
expect(english(getStateLabelKey(state))).toBe(englishLabel);
|
||||
expect(french(getStateLabelKey(state))).toBe(frenchLabel);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivityVerb", () => {
|
||||
it("returns Commented for comment kind", () => {
|
||||
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
|
||||
});
|
||||
|
||||
it("returns Approved for approved review", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
|
||||
});
|
||||
|
||||
it("returns Requested changes for changes_requested review", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
|
||||
describe("getActivityVerbKey", () => {
|
||||
it.each([
|
||||
[{ kind: "comment" }, "Commented", "A commenté"],
|
||||
[{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"],
|
||||
[
|
||||
{ kind: "review", reviewState: "changes_requested" },
|
||||
"Requested changes",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns Reviewed for a commented review with body (generic case)", () => {
|
||||
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
|
||||
});
|
||||
|
||||
it("returns Reviewed when reviewState is undefined", () => {
|
||||
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
|
||||
"A demandé des modifications",
|
||||
],
|
||||
[{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"],
|
||||
[{ kind: "review" }, "Reviewed", "A relu"],
|
||||
] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => {
|
||||
expect(english(getActivityVerbKey(item))).toBe(englishVerb);
|
||||
expect(french(getActivityVerbKey(item))).toBe(frenchVerb);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getStateLabel(state: PrState): string {
|
||||
if (state === "draft") return "Draft";
|
||||
if (state === "merged") return "Merged";
|
||||
if (state === "closed") return "Closed";
|
||||
return "Open";
|
||||
export function getStateLabelKey(state: PrState) {
|
||||
if (state === "draft") return "workspace.git.pr.states.draft";
|
||||
if (state === "merged") return "workspace.git.pr.states.merged";
|
||||
if (state === "closed") return "workspace.git.pr.states.closed";
|
||||
return "workspace.git.pr.states.open";
|
||||
}
|
||||
|
||||
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
|
||||
if (item.kind === "comment") return "Commented";
|
||||
if (item.reviewState === "approved") return "Approved";
|
||||
if (item.reviewState === "changes_requested") return "Requested changes";
|
||||
return "Reviewed";
|
||||
export function getActivityVerbKey(item: Pick<PrPaneActivity, "kind" | "reviewState">) {
|
||||
if (item.kind === "comment") return "workspace.git.pr.activity.commented";
|
||||
if (item.reviewState === "approved") return "workspace.git.pr.activity.approved";
|
||||
if (item.reviewState === "changes_requested") {
|
||||
return "workspace.git.pr.activity.requestedChanges";
|
||||
}
|
||||
return "workspace.git.pr.activity.reviewed";
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
canAddPullRequestActivityToChat,
|
||||
} from "./context-attachment";
|
||||
import { ChecksSection, getCheckIdentity } from "./checks-section";
|
||||
import { getActivityVerb, getStateLabel } from "./data";
|
||||
import { getActivityVerbKey, getStateLabelKey } from "./data";
|
||||
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
|
||||
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
||||
import { CheckPresentationIcon } from "@/git/check-presentation.view";
|
||||
@@ -538,7 +538,7 @@ export function PullRequestPane({
|
||||
<View style={styles.metaLine}>
|
||||
<StateIcon size={14} uniProps={statePresentation.iconColor} />
|
||||
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
|
||||
{getStateLabel(data.state)}
|
||||
{t(getStateLabelKey(data.state))}
|
||||
</Text>
|
||||
{nativeHeaderMeta}
|
||||
{repoIdentity ? (
|
||||
@@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu
|
||||
}
|
||||
|
||||
function ActivityVerb({ activity }: { activity: PrPaneActivity }) {
|
||||
const verb = getActivityVerb(activity).toLowerCase();
|
||||
const { t } = useTranslation();
|
||||
const verb = t(getActivityVerbKey(activity)).toLowerCase();
|
||||
if (activity.kind === "review" && activity.reviewState === "approved") {
|
||||
return (
|
||||
<View style={styles.verbGroup}>
|
||||
@@ -1065,8 +1066,12 @@ function ThreadBlock({
|
||||
? formatPullRequestThreadPath(thread.location)
|
||||
: t("workspace.git.pr.thread.discussion")}
|
||||
</Text>
|
||||
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
|
||||
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
|
||||
{thread.isResolved ? (
|
||||
<StatusBadge label={t("workspace.git.pr.thread.resolved")} variant="success" />
|
||||
) : null}
|
||||
{thread.location?.isOutdated ? (
|
||||
<StatusBadge label={t("workspace.git.pr.thread.outdated")} />
|
||||
) : null}
|
||||
<View style={styles.headerTrailing}>
|
||||
{collapsed ? (
|
||||
<View style={styles.threadCount}>
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface SidebarWorkspacePlacement {
|
||||
workspaceDirectory?: string;
|
||||
projectKind: WorkspaceStructureProject["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
background?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -164,6 +165,7 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory),
|
||||
projectKind: input.workspace.projectKind,
|
||||
workspaceKind: input.workspace.workspaceKind,
|
||||
background: input.workspace.background,
|
||||
name: input.workspace.name,
|
||||
title: input.workspace.title ?? null,
|
||||
pinnedAt: input.workspace.pinnedAt,
|
||||
|
||||
@@ -76,6 +76,7 @@ const untranslatedLocalFallbacks = [
|
||||
|
||||
const pullRequestPanelSources = [
|
||||
"git/pull-request-panel/pane.tsx",
|
||||
"git/pull-request-panel/data.ts",
|
||||
"git/pull-request-panel/checks-section.tsx",
|
||||
"components/sidebar/sidebar-status-list.tsx",
|
||||
] as const;
|
||||
@@ -87,6 +88,16 @@ const untranslatedPullRequestPanelLabels = [
|
||||
"Adding...",
|
||||
"Comment actions",
|
||||
"Thread actions",
|
||||
"Commented",
|
||||
"Approved",
|
||||
"Requested changes",
|
||||
"Reviewed",
|
||||
"Draft",
|
||||
"Merged",
|
||||
"Closed",
|
||||
"Open",
|
||||
"Resolved",
|
||||
"Outdated",
|
||||
] as const;
|
||||
|
||||
function findUntranslatedPullRequestPanelLabels(): string[] {
|
||||
@@ -244,6 +255,14 @@ describe("translation resources", () => {
|
||||
expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat);
|
||||
expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions);
|
||||
expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions);
|
||||
for (const verb of Object.keys(englishPr.activity) as (keyof typeof englishPr.activity)[]) {
|
||||
expect(pr.activity[verb]).not.toBe(englishPr.activity[verb]);
|
||||
}
|
||||
for (const state of Object.keys(englishPr.states) as (keyof typeof englishPr.states)[]) {
|
||||
expect(pr.states[state]).not.toBe(englishPr.states[state]);
|
||||
}
|
||||
expect(pr.thread.resolved).not.toBe(englishPr.thread.resolved);
|
||||
expect(pr.thread.outdated).not.toBe(englishPr.thread.outdated);
|
||||
expect(resource.sidebar.statusGroupAccessibility).not.toBe(
|
||||
en.sidebar.statusGroupAccessibility,
|
||||
);
|
||||
|
||||
@@ -1070,6 +1070,8 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "سلسلة المناقشة",
|
||||
resolved: "تم الحل",
|
||||
outdated: "قديم",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
|
||||
@@ -1145,6 +1147,7 @@ export const ar: TranslationResources = {
|
||||
done: "تم",
|
||||
},
|
||||
display: {
|
||||
showBackground: "إظهار مساحات العمل في الخلفية",
|
||||
trigger: "تفضيلات العرض",
|
||||
heading: "العرض",
|
||||
grouping: {
|
||||
|
||||
@@ -1078,6 +1078,8 @@ export const en = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Discussion thread",
|
||||
resolved: "Resolved",
|
||||
outdated: "Outdated",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Unable to load pull request status",
|
||||
@@ -1153,6 +1155,7 @@ export const en = {
|
||||
done: "Done",
|
||||
},
|
||||
display: {
|
||||
showBackground: "Show background",
|
||||
trigger: "Display preferences",
|
||||
heading: "Display",
|
||||
grouping: {
|
||||
|
||||
@@ -1093,16 +1093,18 @@ export const es: TranslationResources = {
|
||||
open: "Abierto",
|
||||
},
|
||||
activity: {
|
||||
commented: "Comentado",
|
||||
approved: "Aprobado",
|
||||
requestedChanges: "Cambios solicitados",
|
||||
reviewed: "Revisado",
|
||||
commented: "Comentó",
|
||||
approved: "Aprobó",
|
||||
requestedChanges: "Solicitó cambios",
|
||||
reviewed: "Revisó",
|
||||
},
|
||||
time: {
|
||||
justNow: "En este momento",
|
||||
},
|
||||
thread: {
|
||||
discussion: "Hilo de discusión",
|
||||
resolved: "Resuelto",
|
||||
outdated: "Desactualizado",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción",
|
||||
@@ -1181,6 +1183,7 @@ export const es: TranslationResources = {
|
||||
done: "Terminado",
|
||||
},
|
||||
display: {
|
||||
showBackground: "Mostrar espacios en segundo plano",
|
||||
trigger: "Preferencias de visualización",
|
||||
heading: "Visualización",
|
||||
grouping: {
|
||||
|
||||
@@ -1094,6 +1094,8 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Fil de discussion",
|
||||
resolved: "Résolu",
|
||||
outdated: "Obsolète",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Impossible de charger le statut de la pull request",
|
||||
@@ -1172,6 +1174,7 @@ export const fr: TranslationResources = {
|
||||
done: "Terminé",
|
||||
},
|
||||
display: {
|
||||
showBackground: "Afficher les espaces en arrière-plan",
|
||||
trigger: "Préférences d’affichage",
|
||||
heading: "Affichage",
|
||||
grouping: {
|
||||
|
||||
@@ -1083,6 +1083,8 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "ディスカッションスレッド",
|
||||
resolved: "解決済み",
|
||||
outdated: "古い",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "プルリクエストのステータスを読み込めません",
|
||||
@@ -1158,6 +1160,7 @@ export const ja: TranslationResources = {
|
||||
done: "完了",
|
||||
},
|
||||
display: {
|
||||
showBackground: "バックグラウンドを表示",
|
||||
trigger: "表示設定",
|
||||
heading: "表示",
|
||||
grouping: {
|
||||
|
||||
@@ -1077,6 +1077,8 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "토론 스레드",
|
||||
resolved: "해결됨",
|
||||
outdated: "오래됨",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다",
|
||||
@@ -1152,6 +1154,7 @@ export const ko: TranslationResources = {
|
||||
done: "완료",
|
||||
},
|
||||
display: {
|
||||
showBackground: "백그라운드 표시",
|
||||
trigger: "표시 설정",
|
||||
heading: "표시",
|
||||
grouping: {
|
||||
|
||||
@@ -1084,15 +1084,17 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
activity: {
|
||||
commented: "Comentou",
|
||||
approved: "Aprovado",
|
||||
approved: "Aprovou",
|
||||
requestedChanges: "Solicitou alterações",
|
||||
reviewed: "Revisado",
|
||||
reviewed: "Revisou",
|
||||
},
|
||||
time: {
|
||||
justNow: "agora mesmo",
|
||||
},
|
||||
thread: {
|
||||
discussion: "Tópico de discussão",
|
||||
resolved: "Resolvido",
|
||||
outdated: "Desatualizado",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Não foi possível carregar o status da pull request",
|
||||
@@ -1171,6 +1173,7 @@ export const ptBR: TranslationResources = {
|
||||
done: "Concluído",
|
||||
},
|
||||
display: {
|
||||
showBackground: "Mostrar espaços em segundo plano",
|
||||
trigger: "Preferências de exibição",
|
||||
heading: "Exibição",
|
||||
grouping: {
|
||||
|
||||
@@ -1087,6 +1087,8 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
thread: {
|
||||
discussion: "Ветка обсуждения",
|
||||
resolved: "Решено",
|
||||
outdated: "Устарело",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "Не удалось загрузить статус PR",
|
||||
@@ -1162,6 +1164,7 @@ export const ru: TranslationResources = {
|
||||
done: "Готово",
|
||||
},
|
||||
display: {
|
||||
showBackground: "Показать фоновые пространства",
|
||||
trigger: "Настройки отображения",
|
||||
heading: "Отображение",
|
||||
grouping: {
|
||||
|
||||
@@ -1046,22 +1046,24 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
states: {
|
||||
draft: "Draft",
|
||||
merged: "已 merge",
|
||||
draft: "草稿",
|
||||
merged: "已合并",
|
||||
closed: "已关闭",
|
||||
open: "Open",
|
||||
open: "开放",
|
||||
},
|
||||
activity: {
|
||||
commented: "已评论",
|
||||
approved: "已批准",
|
||||
requestedChanges: "请求修改",
|
||||
reviewed: "已 review",
|
||||
reviewed: "已审查",
|
||||
},
|
||||
time: {
|
||||
justNow: "刚刚",
|
||||
},
|
||||
thread: {
|
||||
discussion: "讨论主题",
|
||||
resolved: "已解决",
|
||||
outdated: "已过时",
|
||||
},
|
||||
errors: {
|
||||
statusLoadFailed: "无法加载 Pull Request 状态",
|
||||
@@ -1137,6 +1139,7 @@ export const zhCN: TranslationResources = {
|
||||
done: "已完成",
|
||||
},
|
||||
display: {
|
||||
showBackground: "显示后台工作区",
|
||||
trigger: "显示偏好",
|
||||
heading: "显示",
|
||||
grouping: {
|
||||
|
||||
@@ -33,6 +33,10 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
|
||||
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
supportsBackgroundWorkspaces(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
supportsWorkspaceLabels = false;
|
||||
listWorkspaceLabelsCalls = 0;
|
||||
fetchAgentsCalls = 0;
|
||||
|
||||
@@ -535,6 +535,7 @@ export class DirectorySync {
|
||||
let subscribe = initialSubscribe;
|
||||
while (true) {
|
||||
const query: Parameters<DaemonClient["observeWorkspaces"]>[0] = {
|
||||
...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}),
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
|
||||
...(supportsDirectorySync
|
||||
@@ -634,6 +635,7 @@ export class DirectorySync {
|
||||
const limit = input.page?.limit ?? PAGE_LIMIT;
|
||||
const query: Omit<FetchAgentsOptions, "subscribe"> = {
|
||||
...(input.filter ? { filter: input.filter } : { scope: "active" as const }),
|
||||
...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}),
|
||||
sort: AGENT_SORT,
|
||||
page: cursor ? { limit, cursor } : { limit },
|
||||
...(!input.filter && cursor === null && this.supportsDirectorySync()
|
||||
|
||||
@@ -52,6 +52,9 @@ it("requests the managed connection credential through desktop main without a we
|
||||
});
|
||||
|
||||
class FakeDaemonClient {
|
||||
supportsBackgroundWorkspaces(): boolean {
|
||||
return false;
|
||||
}
|
||||
private state: ConnectionState = { status: "idle" };
|
||||
private listeners = new Set<(status: ConnectionState) => void>();
|
||||
private error: string | null = null;
|
||||
@@ -3047,6 +3050,34 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("drains a queued message as a steer so a turn the agent just started is not interrupted", async () => {
|
||||
const host = makeHost({ serverId: "srv_steer_queue_drain" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_steer_queue_drain",
|
||||
},
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]),
|
||||
);
|
||||
|
||||
store.drainQueuedAgentMessage(host.serverId, "agent");
|
||||
await fakeClient.waitForSentMessages(1);
|
||||
|
||||
expect(fakeClient.sentAgentMessages[0]?.[2]?.activeTurnBehavior).toBe("steer");
|
||||
sessionStore.clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => {
|
||||
const host = makeHost({ serverId: "srv_legacy_queue_attachment" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
|
||||
@@ -2426,6 +2426,11 @@ export class HostRuntimeStore {
|
||||
}),
|
||||
encodeImages,
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
// The queue drains when the agent goes idle, but the agent can start a turn of its own
|
||||
// in that same moment (a wake-up after background work). Steering folds the message
|
||||
// into that turn instead of interrupting it; with no turn running, the daemon starts
|
||||
// a normal one.
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
@@ -859,3 +859,36 @@ describe("ReplicaCache", () => {
|
||||
expect(storage.cleanups).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("retains semantic agent messages across disk-cache reconstruction", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const value = timeline();
|
||||
value.items = [
|
||||
{
|
||||
kind: "tool_call",
|
||||
id: "delivery",
|
||||
timestamp: new Date(1),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 12 },
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "codex",
|
||||
callId: "delivery",
|
||||
name: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: "Done" },
|
||||
agentMessage: {
|
||||
event: "finished",
|
||||
sender: { id: "remote::worker", title: "Reviewer" },
|
||||
text: "Done",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", value);
|
||||
await writer.flush();
|
||||
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(value);
|
||||
});
|
||||
|
||||
@@ -421,6 +421,7 @@ function serializeAgentToolCall(data: AgentToolCallData): StoredToolCall {
|
||||
name: data.name,
|
||||
detail: data.detail,
|
||||
...(data.metadata ? { metadata: data.metadata } : {}),
|
||||
...(data.agentMessage ? { agentMessage: data.agentMessage } : {}),
|
||||
};
|
||||
switch (data.status) {
|
||||
case "running":
|
||||
@@ -584,6 +585,7 @@ function deserializeBuiltinTimelineItem(
|
||||
error: tool.error,
|
||||
detail: tool.detail,
|
||||
...(tool.metadata ? { metadata: tool.metadata } : {}),
|
||||
...(tool.agentMessage ? { agentMessage: tool.agentMessage } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface WorkspaceDescriptor {
|
||||
worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"];
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
||||
background?: boolean;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
pinnedAt?: string | null;
|
||||
@@ -150,6 +151,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
worktreeSlug: payload.worktreeSlug,
|
||||
projectKind: payload.projectKind,
|
||||
workspaceKind: payload.workspaceKind,
|
||||
background: payload.background ?? false,
|
||||
name: payload.name,
|
||||
title: payload.title ?? null,
|
||||
pinnedAt: payload.pinnedAt ?? null,
|
||||
|
||||
@@ -32,6 +32,7 @@ function createMemoryStorage(entries: Record<string, string | null>): MemoryStor
|
||||
describe("sidebar view store", () => {
|
||||
beforeEach(() => {
|
||||
useSidebarViewStore.setState({
|
||||
showBackground: false,
|
||||
groupMode: "project",
|
||||
hostFilters: [],
|
||||
projectFilters: [],
|
||||
@@ -84,6 +85,7 @@ describe("sidebar view store", () => {
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
showBackground: false,
|
||||
groupMode: "status",
|
||||
hostFilters: [],
|
||||
projectFilters: [],
|
||||
@@ -98,6 +100,7 @@ describe("sidebar view store", () => {
|
||||
hostFilter: "host-a",
|
||||
}),
|
||||
).toEqual({
|
||||
showBackground: false,
|
||||
groupMode: "status",
|
||||
hostFilters: ["host-a"],
|
||||
projectFilters: [],
|
||||
@@ -112,6 +115,7 @@ describe("sidebar view store", () => {
|
||||
hostFilters: ["host-a", "host-b"],
|
||||
}),
|
||||
).toEqual({
|
||||
showBackground: false,
|
||||
groupMode: "status",
|
||||
hostFilters: ["host-a", "host-b"],
|
||||
projectFilters: [],
|
||||
@@ -222,6 +226,7 @@ describe("sidebar view store", () => {
|
||||
projectFilters: ["project-a", "project-b"],
|
||||
}),
|
||||
).toEqual({
|
||||
showBackground: false,
|
||||
groupMode: "project",
|
||||
hostFilters: ["host-a"],
|
||||
projectFilters: ["project-a", "project-b"],
|
||||
@@ -231,6 +236,7 @@ describe("sidebar view store", () => {
|
||||
|
||||
it("never keeps project filters from state the schema rejects", () => {
|
||||
expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({
|
||||
showBackground: false,
|
||||
groupMode: "project",
|
||||
hostFilters: [],
|
||||
projectFilters: [],
|
||||
|
||||
@@ -9,7 +9,7 @@ export type SidebarGroupMode = "project" | "status";
|
||||
|
||||
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
|
||||
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
|
||||
const SIDEBAR_VIEW_STORE_VERSION = 6;
|
||||
const SIDEBAR_VIEW_STORE_VERSION = 7;
|
||||
|
||||
/**
|
||||
* The key standing for "this workspace carries no labels at all".
|
||||
@@ -47,6 +47,7 @@ function toggleFilterEntry(list: readonly string[], key: string): string[] {
|
||||
|
||||
interface SidebarViewStoreState {
|
||||
groupMode: SidebarGroupMode;
|
||||
showBackground: boolean;
|
||||
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
|
||||
hostFilters: string[];
|
||||
/**
|
||||
@@ -61,6 +62,7 @@ interface SidebarViewStoreState {
|
||||
*/
|
||||
projectFilters: string[];
|
||||
labelFilter: SidebarLabelFilter;
|
||||
toggleBackground: () => void;
|
||||
setGroupMode: (mode: SidebarGroupMode) => void;
|
||||
toggleHostFilter: (serverId: string) => void;
|
||||
clearHostFilters: () => void;
|
||||
@@ -74,6 +76,7 @@ interface SidebarViewStoreState {
|
||||
|
||||
interface SidebarViewPersistedState {
|
||||
groupMode: SidebarGroupMode;
|
||||
showBackground: boolean;
|
||||
hostFilters: string[];
|
||||
projectFilters: string[];
|
||||
labelFilter: SidebarLabelFilter;
|
||||
@@ -84,6 +87,7 @@ const SidebarLabelFilterSchema = z.object({
|
||||
labels: z.array(z.string()),
|
||||
});
|
||||
const SidebarViewPersistedStateSchema = z.strictObject({
|
||||
showBackground: z.boolean().optional(),
|
||||
groupMode: PersistedSidebarGroupModeSchema.optional(),
|
||||
hostFilters: z.array(z.string()).optional(),
|
||||
hostFilter: z.string().nullable().optional(),
|
||||
@@ -122,6 +126,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
|
||||
const result = SidebarViewPersistedStateSchema.safeParse(persistedState);
|
||||
if (!result.success) {
|
||||
return {
|
||||
showBackground: false,
|
||||
groupMode: "project",
|
||||
hostFilters: [],
|
||||
projectFilters: [],
|
||||
@@ -133,6 +138,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
|
||||
const legacyGroupMode = readLegacyGroupMode(state);
|
||||
if (legacyGroupMode) {
|
||||
return {
|
||||
showBackground: false,
|
||||
groupMode: legacyGroupMode,
|
||||
hostFilters: [],
|
||||
projectFilters: [],
|
||||
@@ -141,6 +147,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
|
||||
}
|
||||
|
||||
return {
|
||||
showBackground: state.showBackground ?? false,
|
||||
groupMode: state.groupMode === "status" ? "status" : "project",
|
||||
hostFilters: readHostFilters(state),
|
||||
projectFilters: state.projectFilters ?? [],
|
||||
@@ -178,10 +185,12 @@ export function createSidebarViewStorage(
|
||||
export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
showBackground: false,
|
||||
groupMode: "project",
|
||||
hostFilters: [],
|
||||
projectFilters: [],
|
||||
labelFilter: emptyLabelFilter(),
|
||||
toggleBackground: () => set((state) => ({ showBackground: !state.showBackground })),
|
||||
setGroupMode: (mode) => set({ groupMode: mode }),
|
||||
toggleHostFilter: (serverId) =>
|
||||
set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })),
|
||||
@@ -228,6 +237,7 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
SidebarViewPersistedStateSchema,
|
||||
),
|
||||
partialize: (state) => ({
|
||||
showBackground: state.showBackground ?? false,
|
||||
groupMode: state.groupMode,
|
||||
hostFilters: state.hostFilters,
|
||||
projectFilters: state.projectFilters,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
observeProviderSubagentTimeline,
|
||||
providerSubagentKey,
|
||||
refreshProviderSubagents,
|
||||
resyncProviderSubagents,
|
||||
useProviderSubagentStore,
|
||||
} from "./provider-store";
|
||||
|
||||
@@ -15,6 +18,7 @@ afterEach(() => {
|
||||
descriptors: new Map(),
|
||||
timelines: new Map(),
|
||||
hiddenFromTrack: new Set(),
|
||||
trackedParents: new Set(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -727,3 +731,96 @@ describe("projected child history", () => {
|
||||
expect(current().lastSeq).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resyncing after the update feed resubscribes", () => {
|
||||
function child(
|
||||
id: string,
|
||||
parentAgentId: string,
|
||||
status: ProviderSubagentDescriptorPayload["status"],
|
||||
): ProviderSubagentDescriptorPayload {
|
||||
return {
|
||||
id,
|
||||
parentAgentId,
|
||||
provider: "claude",
|
||||
title: "Explore",
|
||||
description: null,
|
||||
status,
|
||||
createdAt: "2026-10-07T10:00:00.000Z",
|
||||
updatedAt: "2026-10-07T10:00:00.000Z",
|
||||
toolCallId: id,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeDaemon(lists: Record<string, ProviderSubagentDescriptorPayload[]>) {
|
||||
const requested: string[] = [];
|
||||
return {
|
||||
requested,
|
||||
lists,
|
||||
client: {
|
||||
listProviderSubagents: async (parentAgentId: string) => {
|
||||
requested.push(parentAgentId);
|
||||
return {
|
||||
requestId: "list",
|
||||
parentAgentId,
|
||||
subagents: lists[parentAgentId] ?? [],
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const status = (parentAgentId: string, subagentId: string) =>
|
||||
useProviderSubagentStore
|
||||
.getState()
|
||||
.descriptors.get(providerSubagentKey(SERVER_ID, parentAgentId, subagentId))?.status;
|
||||
|
||||
test("catches up on children that finished or started while updates were not delivered", async () => {
|
||||
const daemon = fakeDaemon({ [PARENT_ID]: [child("child-1", PARENT_ID, "running")] });
|
||||
await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
|
||||
expect(status(PARENT_ID, "child-1")).toBe("running");
|
||||
|
||||
// The connection dropped: the daemon finished child-1 and ran child-2 without telling us.
|
||||
daemon.lists[PARENT_ID] = [
|
||||
child("child-1", PARENT_ID, "completed"),
|
||||
child("child-2", PARENT_ID, "completed"),
|
||||
];
|
||||
await resyncProviderSubagents(daemon.client, SERVER_ID);
|
||||
|
||||
expect(status(PARENT_ID, "child-1")).toBe("completed");
|
||||
expect(status(PARENT_ID, "child-2")).toBe("completed");
|
||||
});
|
||||
|
||||
test("catches up on a parent whose first list failed while disconnected", async () => {
|
||||
const daemon = fakeDaemon({});
|
||||
const disconnected = {
|
||||
listProviderSubagents: async () => {
|
||||
throw new DaemonConnectionError("Transport not connected");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
refreshProviderSubagents(disconnected, SERVER_ID, PARENT_ID),
|
||||
).rejects.toBeInstanceOf(DaemonConnectionError);
|
||||
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: child("child-1", PARENT_ID, "running"),
|
||||
});
|
||||
|
||||
// The connection dropped again: the daemon finished child-1 without telling us.
|
||||
daemon.lists[PARENT_ID] = [child("child-1", PARENT_ID, "completed")];
|
||||
await resyncProviderSubagents(daemon.client, SERVER_ID);
|
||||
|
||||
expect(status(PARENT_ID, "child-1")).toBe("completed");
|
||||
});
|
||||
|
||||
test("lists again only the parents this server was showing", async () => {
|
||||
const daemon = fakeDaemon({});
|
||||
await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
|
||||
await refreshProviderSubagents(daemon.client, "server-2", "other-parent");
|
||||
daemon.requested.length = 0;
|
||||
|
||||
await resyncProviderSubagents(daemon.client, SERVER_ID);
|
||||
|
||||
expect(daemon.requested).toEqual([PARENT_ID]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,9 @@ interface ProviderSubagentState {
|
||||
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
|
||||
timelines: Map<string, ProviderSubagentTimelineState>;
|
||||
hiddenFromTrack: Set<string>;
|
||||
/** Parents whose list this client asked for, so a resubscribed update feed can catch them up. */
|
||||
trackedParents: Set<string>;
|
||||
trackParent(serverId: string, parentAgentId: string): void;
|
||||
hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void;
|
||||
replaceList(
|
||||
serverId: string,
|
||||
@@ -84,6 +87,7 @@ export function refreshProviderSubagents(
|
||||
const pending = clientRequests.get(requestKey);
|
||||
if (pending) return pending;
|
||||
|
||||
useProviderSubagentStore.getState().trackParent(serverId, parentAgentId);
|
||||
const request = client
|
||||
.listProviderSubagents(parentAgentId)
|
||||
.then((payload) => {
|
||||
@@ -101,6 +105,27 @@ function parentPrefix(serverId: string, parentAgentId: string): string {
|
||||
return `${serverId}\0${parentAgentId}\0`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates are not replayed across a reconnect, so a child that finished or started while the
|
||||
* update feed was down stays stale until its parent is listed again. Call this once the feed has
|
||||
* resubscribed, so every update after the list is delivered.
|
||||
*/
|
||||
export async function resyncProviderSubagents(
|
||||
client: ProviderSubagentListClient,
|
||||
serverId: string,
|
||||
): Promise<void> {
|
||||
const serverPrefix = `${serverId}\0`;
|
||||
const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents]
|
||||
.filter((key) => key.startsWith(serverPrefix))
|
||||
.map((key) => key.slice(serverPrefix.length, -1));
|
||||
await Promise.all(
|
||||
parentAgentIds.map((parentAgentId) =>
|
||||
// A parent that was archived meanwhile has nothing left to catch up.
|
||||
refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_TIMELINE: ProviderSubagentTimelineState = {
|
||||
tail: [],
|
||||
head: [],
|
||||
@@ -147,6 +172,14 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
descriptors: new Map(),
|
||||
timelines: new Map(),
|
||||
hiddenFromTrack: new Set(),
|
||||
trackedParents: new Set(),
|
||||
trackParent(serverId, parentAgentId) {
|
||||
set((state) => {
|
||||
const prefix = parentPrefix(serverId, parentAgentId);
|
||||
if (state.trackedParents.has(prefix)) return state;
|
||||
return { trackedParents: new Set(state.trackedParents).add(prefix) };
|
||||
});
|
||||
},
|
||||
hideFromTrack(serverId, parentAgentId, subagentIds) {
|
||||
set((state) => {
|
||||
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||
|
||||
@@ -92,3 +92,42 @@ describe("tool-call presentation", () => {
|
||||
expect(presentation.icon).toBe(fakeIcons.brain);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["message", "Message from QA messenger"],
|
||||
["finished", "QA messenger finished"],
|
||||
["errored", "QA messenger errored"],
|
||||
["permission-required", "QA messenger needs permission"],
|
||||
["closed", "QA messenger closed"],
|
||||
] as const)("presents %s from structured provenance without a second label", (event, heading) => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "internal_name_should_not_be_visible",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: "legacy summary", text: "legacy body" },
|
||||
agentMessage: {
|
||||
event,
|
||||
sender: { id: "server::sender", title: "QA messenger" },
|
||||
text: "The delivered message.",
|
||||
},
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
expect(presentation.displayName).toBe(heading);
|
||||
expect(presentation.summary).toBeUndefined();
|
||||
expect(presentation.detail).toEqual({
|
||||
type: "plain_text",
|
||||
icon: "bot",
|
||||
text: "From agent: server::sender\n\nThe delivered message.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an opaque sender ID when no title is available", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
toolName: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
agentMessage: { event: "message", sender: { id: "remote::worker" }, text: "Hello" },
|
||||
resolveIcon: fakeResolveIcon,
|
||||
});
|
||||
expect(presentation.displayName).toBe("Message from remote::worker");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import { buildAgentMessageDisplay } from "@getpaseo/protocol/agent-message-display";
|
||||
import type { PlanOutcome } from "@/components/plan-card";
|
||||
import type { ComponentType } from "react";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -13,6 +15,7 @@ type ToolCallStatus = "executing" | "running" | "completed" | "failed" | "cancel
|
||||
export type ToolCallPresentationIcon = ComponentType<{ size?: number; color?: string }>;
|
||||
|
||||
interface BuildToolCallPresentationInput {
|
||||
agentMessage?: AgentMessage;
|
||||
toolName: string;
|
||||
status: ToolCallStatus;
|
||||
error: unknown;
|
||||
@@ -23,6 +26,7 @@ interface BuildToolCallPresentationInput {
|
||||
}
|
||||
|
||||
export interface ToolCallPresentation {
|
||||
detail: ToolCallDetail | undefined;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
errorText?: string;
|
||||
@@ -51,9 +55,13 @@ function displayDetail(detail: ToolCallDetail | undefined): ToolCallDetail {
|
||||
export function buildToolCallPresentation(
|
||||
input: BuildToolCallPresentationInput,
|
||||
): ToolCallPresentation {
|
||||
const detailForDisplay = displayDetail(input.detail);
|
||||
const detail = input.agentMessage
|
||||
? buildAgentMessageDisplay(input.agentMessage).detail
|
||||
: input.detail;
|
||||
const detailForDisplay = displayDetail(detail);
|
||||
const displayModel = buildToolCallDisplayModel({
|
||||
name: input.toolName,
|
||||
agentMessage: input.agentMessage,
|
||||
status: displayStatus(input.status),
|
||||
error: input.error ?? null,
|
||||
detail: detailForDisplay,
|
||||
@@ -61,21 +69,22 @@ export function buildToolCallPresentation(
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail: input.detail,
|
||||
detail,
|
||||
status: input.status,
|
||||
error: input.error,
|
||||
});
|
||||
const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(input.detail);
|
||||
const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(detail);
|
||||
|
||||
return {
|
||||
detail,
|
||||
displayName: displayModel.displayName,
|
||||
summary: displayModel.summary,
|
||||
errorText: displayModel.errorText,
|
||||
icon: input.resolveIcon(input.toolName, input.detail),
|
||||
icon: input.resolveIcon(input.toolName, detail),
|
||||
isLoadingDetails,
|
||||
hasDetails,
|
||||
canOpenDetails: hasDetails || isLoadingDetails,
|
||||
openFilePath: extractToolCallFilePath(input.detail),
|
||||
openFilePath: extractToolCallFilePath(detail),
|
||||
isPlan: input.detail?.type === "plan",
|
||||
planOutcome: input.detail?.type === "plan" ? resolvePlanOutcome(input) : undefined,
|
||||
};
|
||||
|
||||
@@ -2305,3 +2305,28 @@ describe("notification timeline items", () => {
|
||||
expect(new Set(state.map((item) => item.id)).size).toBe(state.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("retains agent-message provenance through live delivery and repeated history", () => {
|
||||
const agentMessage = {
|
||||
event: "finished" as const,
|
||||
sender: { id: "remote::worker", title: "Reviewer" },
|
||||
text: "Done",
|
||||
};
|
||||
const event = {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "delivery",
|
||||
name: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: "Done" },
|
||||
agentMessage,
|
||||
},
|
||||
} satisfies AgentStreamEventPayload;
|
||||
const live = reduceStreamUpdate([], event, new Date(1));
|
||||
const replay = reduceStreamUpdate(live, event, new Date(2));
|
||||
expect(replay).toHaveLength(1);
|
||||
expect(replay[0]).toMatchObject({ payload: { data: { agentMessage } } });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type {
|
||||
AgentProvider,
|
||||
AgentTimelineItem,
|
||||
@@ -745,6 +746,7 @@ interface OrchestratorToolCallData {
|
||||
}
|
||||
|
||||
export interface AgentToolCallData {
|
||||
agentMessage?: AgentMessage;
|
||||
provider: AgentProvider;
|
||||
callId: string;
|
||||
name: string;
|
||||
@@ -1446,6 +1448,7 @@ function reduceTimelineToolCall(
|
||||
error: item.error,
|
||||
detail: item.detail,
|
||||
metadata: item.metadata,
|
||||
...(item.agentMessage ? { agentMessage: item.agentMessage } : {}),
|
||||
},
|
||||
timestamp,
|
||||
timelineCursor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/server": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/server": "0.11.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -46,10 +46,25 @@ describe("canonical CLI surface", () => {
|
||||
expect(help).toContain("--forge <forge>");
|
||||
});
|
||||
|
||||
it("uses background for execution and reserves detach for ownership", () => {
|
||||
it("uses --no-wait for execution, --internal for visibility, and reserves detach for ownership", () => {
|
||||
const run = createCli().commands.find((command) => command.name() === "run");
|
||||
expect(run?.helpInformation()).toContain("--background");
|
||||
expect(run?.helpInformation()).not.toContain("--detach");
|
||||
const help = run?.helpInformation();
|
||||
expect(help).toContain("--no-wait");
|
||||
expect(help).not.toContain("--background");
|
||||
expect(help).not.toContain("--background");
|
||||
expect(help).not.toContain("--detach");
|
||||
});
|
||||
|
||||
it("hides internal agents and workspaces from listings unless --internal is passed", () => {
|
||||
const cli = createCli();
|
||||
const ls = cli.commands.find((command) => command.name() === "ls");
|
||||
const workspace = cli.commands.find((command) => command.name() === "workspace");
|
||||
const workspaceLs = workspace?.commands.find((command) => command.name() === "ls");
|
||||
const workspaceCreate = workspace?.commands.find((command) => command.name() === "create");
|
||||
|
||||
expect(ls?.helpInformation()).toContain("--background");
|
||||
expect(workspaceLs?.helpInformation()).toContain("--background");
|
||||
expect(workspaceCreate?.helpInformation()).toContain("--background");
|
||||
});
|
||||
|
||||
it("offers thinking configuration when running, updating, and scheduling agents", () => {
|
||||
@@ -96,3 +111,15 @@ describe("canonical CLI surface", () => {
|
||||
).toContain("--id <id>");
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves omitted and explicit false workspace background intent", () => {
|
||||
const readOptions = (args: string[]) => {
|
||||
const workspace = createCli().commands.find((command) => command.name() === "workspace")!;
|
||||
const create = workspace.commands.find((command) => command.name() === "create")!;
|
||||
create.parseOptions(args);
|
||||
return create.opts();
|
||||
};
|
||||
expect(readOptions([]).background).toBeUndefined();
|
||||
expect(readOptions(["--background"]).background).toBe(true);
|
||||
expect(readOptions(["--no-background"]).background).toBe(false);
|
||||
});
|
||||
|
||||
@@ -23,15 +23,21 @@ describe("buildAgentLsFetchOptions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("asks the daemon for internal agents only with --internal", () => {
|
||||
expect(buildAgentLsFetchOptions({ background: true, global: true })).toEqual({
|
||||
filter: { includeBackground: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches global non-archived agents for -g", () => {
|
||||
expect(buildAgentLsFetchOptions({ global: true })).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps -a within the active scope", () => {
|
||||
it("includes archived and background agents with -a", () => {
|
||||
expect(buildAgentLsFetchOptions({ all: true })).toEqual({
|
||||
scope: "active",
|
||||
filter: {
|
||||
includeArchived: true,
|
||||
includeBackground: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -40,6 +46,7 @@ describe("buildAgentLsFetchOptions", () => {
|
||||
expect(buildAgentLsFetchOptions({ all: true, global: true })).toEqual({
|
||||
filter: {
|
||||
includeArchived: true,
|
||||
includeBackground: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,9 @@ type FetchAgentsOptions = NonNullable<
|
||||
export function addLsOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.description("List agents. By default excludes archived agents.")
|
||||
.option("-a, --all", "Include archived agents")
|
||||
.option("-a, --all", "Include archived and background agents")
|
||||
.option("-g, --global", "List agents across all directories")
|
||||
.option("--background", "Include background workspace agents")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Filter by label (can be used multiple times)",
|
||||
@@ -109,6 +110,8 @@ export interface AgentLsOptions extends CommandOptions {
|
||||
all?: boolean;
|
||||
/** -g: List agents across all directories */
|
||||
global?: boolean;
|
||||
/** --background: Include background workspace agents */
|
||||
background?: boolean;
|
||||
/** Filter by specific status */
|
||||
status?: string;
|
||||
/** Filter by specific cwd */
|
||||
@@ -133,7 +136,7 @@ function parseLabelFilters(labels: string[] | undefined): Record<string, string>
|
||||
}
|
||||
|
||||
export function buildAgentLsFetchOptions(
|
||||
options: Pick<AgentLsOptions, "all" | "global" | "label" | "thinking">,
|
||||
options: Pick<AgentLsOptions, "all" | "global" | "background" | "label" | "thinking">,
|
||||
): FetchAgentsOptions {
|
||||
const labelFilters = parseLabelFilters(options.label);
|
||||
const normalizedThinkingOptionId = options.thinking?.trim();
|
||||
@@ -142,6 +145,9 @@ export function buildAgentLsFetchOptions(
|
||||
if (options.all) {
|
||||
daemonFilter.includeArchived = true;
|
||||
}
|
||||
if (options.background || options.all) {
|
||||
daemonFilter.includeBackground = true;
|
||||
}
|
||||
if (Object.keys(labelFilters).length > 0) {
|
||||
daemonFilter.labels = labelFilters;
|
||||
}
|
||||
@@ -150,7 +156,7 @@ export function buildAgentLsFetchOptions(
|
||||
}
|
||||
|
||||
const fetchOptions: FetchAgentsOptions = {};
|
||||
if (!options.global) {
|
||||
if (!options.global && !options.all) {
|
||||
fetchOptions.scope = "active";
|
||||
}
|
||||
if (Object.keys(daemonFilter).length > 0) {
|
||||
@@ -163,7 +169,7 @@ export function buildAgentLsFetchOptions(
|
||||
* Agent ls command semantics:
|
||||
* - `paseo agent ls` → active non-archived agents
|
||||
* - `paseo agent ls -g` → global non-archived agents
|
||||
* - `paseo agent ls -a` → active agents, including archived
|
||||
* - `paseo agent ls -a` → all agents, including archived and background
|
||||
* - `paseo agent ls -ag` → global agents, including archived
|
||||
*/
|
||||
export async function runLsCommand(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { resolveCallerAgentId as resolveRunCallerAgentId } from "../../utils/caller-agent.js";
|
||||
import {
|
||||
resolveExistingRunWorkspace,
|
||||
resolveRunCallerAgentId,
|
||||
runRunCommand,
|
||||
waitsForFinish,
|
||||
type AgentRunOptions,
|
||||
} from "./run";
|
||||
|
||||
@@ -21,6 +22,15 @@ function daemonWithAgents(...agentIds: string[]) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("run wait policy", () => {
|
||||
it("waits by default and stops waiting on --no-wait or a legacy alias", () => {
|
||||
expect(waitsForFinish({})).toBe(true);
|
||||
expect(waitsForFinish({ wait: false })).toBe(false);
|
||||
expect(waitsForFinish({ background: true })).toBe(false);
|
||||
expect(waitsForFinish({ detach: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed agent caller context", () => {
|
||||
it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => {
|
||||
await expect(
|
||||
@@ -38,17 +48,20 @@ describe("managed agent caller context", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails instead of dropping the caller when the lookup loses its connection", async () => {
|
||||
const disconnectedDaemon = {
|
||||
async fetchAgent(): Promise<never> {
|
||||
throw new DaemonConnectionError("Connection lost before message could be sent");
|
||||
},
|
||||
};
|
||||
it.each(["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"] as const)(
|
||||
"preserves %s instead of dropping the caller",
|
||||
async (code) => {
|
||||
const disconnectedDaemon = {
|
||||
async fetchAgent(): Promise<never> {
|
||||
throw new DaemonConnectionError("Caller lookup transport failed", code);
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
|
||||
).rejects.toBeInstanceOf(DaemonConnectionError);
|
||||
});
|
||||
await expect(
|
||||
resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
|
||||
).rejects.toBeInstanceOf(DaemonConnectionError);
|
||||
},
|
||||
);
|
||||
|
||||
it("omits blank caller ids", async () => {
|
||||
await expect(
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
StructuredAgentResponseError,
|
||||
} from "@getpaseo/server/agent-response";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { resolveCallerAgentId } from "../../utils/caller-agent.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -27,10 +27,13 @@ export function addRunOptions(cmd: Command): Command {
|
||||
cmd
|
||||
.description("Create and start an agent with a task")
|
||||
.argument("<prompt>", "The task/prompt for the agent")
|
||||
.option("-d, --background", "Run in background")
|
||||
.option("--no-wait", "Return immediately without waiting for completion")
|
||||
// COMPAT(backgroundRunFlag): --background was the documented spelling of
|
||||
// --no-wait. Added in v0.2.0; remove after 2027-04-01.
|
||||
.addOption(new Option("-d, --background", "Legacy alias for --no-wait").hideHelp())
|
||||
// COMPAT(detachRunFlag): --detach used to mean background execution, not
|
||||
// ownership transfer. Added in v0.2.0; remove after 2027-01-17.
|
||||
.addOption(new Option("--detach", "Legacy alias for --background").hideHelp())
|
||||
.addOption(new Option("--detach", "Legacy alias for --no-wait").hideHelp())
|
||||
.option("--title <title>", "Assign a title to the agent")
|
||||
.addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp())
|
||||
.option(
|
||||
@@ -111,6 +114,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = {
|
||||
};
|
||||
|
||||
export interface AgentRunOptions extends CommandOptions {
|
||||
wait?: boolean;
|
||||
background?: boolean;
|
||||
detach?: boolean;
|
||||
title?: string;
|
||||
@@ -389,19 +393,25 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (options.background) {
|
||||
console.error("Warning: --background (-d) is deprecated. Use --no-wait instead.");
|
||||
}
|
||||
|
||||
validateRunWorkspaceOptions(options);
|
||||
|
||||
if (outputSchema && runsInBackground(options)) {
|
||||
if (outputSchema && !waitsForFinish(options)) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--output-schema cannot be used with --background",
|
||||
message: "--output-schema cannot be used with --no-wait",
|
||||
details: "Structured output requires waiting for the agent to finish",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
|
||||
function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean {
|
||||
return Boolean(options.background || options.detach);
|
||||
export function waitsForFinish(
|
||||
options: Pick<AgentRunOptions, "wait" | "background" | "detach">,
|
||||
): boolean {
|
||||
return options.wait !== false && !options.background && !options.detach;
|
||||
}
|
||||
|
||||
function parseWaitTimeoutOption(waitTimeout: string | undefined): number {
|
||||
@@ -557,7 +567,10 @@ async function resolveRunWorkspace(
|
||||
// TODO: thread the run `prompt` as firstAgentContext so workspace-level
|
||||
// title/branch generation picks up the task description (U8/U6 deferred).
|
||||
const source = buildRunWorkspaceSource(options, cwd);
|
||||
const result = await client.createWorkspace({ source });
|
||||
const result = await client.createWorkspace({
|
||||
source,
|
||||
...(callerAgentId ? { callerAgentId } : {}),
|
||||
});
|
||||
|
||||
if (!result.workspace) {
|
||||
throw {
|
||||
@@ -611,7 +624,7 @@ export async function runRunCommand(
|
||||
const env = parseRunEnv(options.env);
|
||||
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
|
||||
|
||||
const callerAgentId = await resolveRunCallerAgentId(client);
|
||||
const callerAgentId = await resolveCallerAgentId(client);
|
||||
const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId);
|
||||
const workspaceId = workspace.id;
|
||||
const runCwd = workspace.cwd;
|
||||
@@ -707,8 +720,8 @@ export async function runRunCommand(
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
});
|
||||
|
||||
// Default run behavior is foreground: wait for completion unless background execution is set.
|
||||
if (!runsInBackground(options)) {
|
||||
// Default run behavior is foreground: wait for completion unless --no-wait is set.
|
||||
if (waitsForFinish(options)) {
|
||||
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
|
||||
await client.close();
|
||||
|
||||
@@ -744,29 +757,3 @@ export async function runRunCommand(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunCallerLookupClient {
|
||||
fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>;
|
||||
}
|
||||
|
||||
// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run
|
||||
// sent to another daemon (--host or --home) has no caller there, so it runs as
|
||||
// a top-level agent instead of failing on an unknown caller.
|
||||
export async function resolveRunCallerAgentId(
|
||||
client: RunCallerLookupClient,
|
||||
env: { PASEO_AGENT_ID?: string } = process.env,
|
||||
): Promise<string | undefined> {
|
||||
const agentId = env.PASEO_AGENT_ID?.trim();
|
||||
if (!agentId) {
|
||||
return undefined;
|
||||
}
|
||||
const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => {
|
||||
// A daemon without this agent answers with an error. A lost or timed-out
|
||||
// connection is not an answer, so it must not drop the caller.
|
||||
if (error instanceof DaemonConnectionError) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return caller?.agent.id === agentId ? agentId : undefined;
|
||||
}
|
||||
|
||||
@@ -192,7 +192,10 @@ export async function runSendCommand(
|
||||
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
|
||||
|
||||
// Send the message
|
||||
await client.sendAgentMessage(agentIdArg, promptInput, { images });
|
||||
await client.sendAgentMessage(agentIdArg, promptInput, {
|
||||
images,
|
||||
sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined,
|
||||
});
|
||||
|
||||
// If --no-wait, return immediately
|
||||
if (options.wait === false) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { expandUserPath } from "../../classify.js";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { reviewPluginUpdates, type UpdateOutcome } from "./update.js";
|
||||
import { Command } from "commander";
|
||||
import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
formatPluginSourceReference,
|
||||
parsePluginSourceReference,
|
||||
formatPluginIdentity,
|
||||
} from "@getpaseo/protocol/plugin-source-reference";
|
||||
import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
@@ -125,7 +128,18 @@ export async function runPluginInstallCommand(
|
||||
process.stderr.write(
|
||||
"Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n",
|
||||
);
|
||||
const sourceReference = formatPluginSourceReference(source, options.path);
|
||||
const reference = parsePluginSourceReference(source);
|
||||
let resolvedSource = source;
|
||||
if (reference.kind === "directory") {
|
||||
const directory = expandUserPath(reference.source);
|
||||
// An absolute path can target a daemon on a different operating system.
|
||||
const absoluteDirectory =
|
||||
path.posix.isAbsolute(directory) || path.win32.isAbsolute(directory)
|
||||
? directory
|
||||
: path.resolve(directory);
|
||||
resolvedSource = formatPluginSourceReference(absoluteDirectory, reference.pluginPath);
|
||||
}
|
||||
const sourceReference = formatPluginSourceReference(resolvedSource, options.path);
|
||||
const data = await withPluginSourceClient(options.daemonTarget, (client) =>
|
||||
client.installPluginSource({
|
||||
source: sourceReference,
|
||||
|
||||
@@ -93,6 +93,8 @@ describe("plugin scaffold", () => {
|
||||
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
|
||||
{
|
||||
id: "hello-plugin",
|
||||
$comment: expect.stringContaining('"icon": "assets/icon.png"'),
|
||||
media: [],
|
||||
requirements: { paseo: `>=${cliPackageJson.version}` },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -206,7 +206,17 @@ export async function scaffoldPluginDirectory(
|
||||
const files = new Map<string, string>([
|
||||
[
|
||||
"paseo-plugin.json",
|
||||
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id,
|
||||
$comment:
|
||||
'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.',
|
||||
media: [],
|
||||
requirements: { paseo: `>=${version}` },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
],
|
||||
["OVERVIEW.md", OVERVIEW],
|
||||
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { resolveCallerAgentId } from "../../utils/caller-agent.js";
|
||||
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
|
||||
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
|
||||
|
||||
@@ -15,6 +16,7 @@ export interface WorkspaceCreateOptions extends CommandOptions {
|
||||
branch?: string;
|
||||
prNumber?: string;
|
||||
forge?: string;
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
interface WorktreeSourceBase {
|
||||
@@ -144,9 +146,12 @@ export async function runCreateCommand(
|
||||
});
|
||||
|
||||
try {
|
||||
const callerAgentId = await resolveCallerAgentId(client);
|
||||
const payload = await client.createWorkspace({
|
||||
source: buildWorkspaceSource(options),
|
||||
...(options.title ? { title: options.title } : {}),
|
||||
...(options.background !== undefined ? { background: options.background } : {}),
|
||||
...(callerAgentId ? { callerAgentId } : {}),
|
||||
});
|
||||
if (!payload.workspace) {
|
||||
throw new Error(payload.error ?? "Workspace creation failed");
|
||||
|
||||
@@ -34,12 +34,17 @@ export function createWorkspaceCommand(): Command {
|
||||
.option("--base <ref>", "Base ref (--mode branch-off)")
|
||||
.option("--branch <name>", "Existing branch (--mode checkout-branch)")
|
||||
.option("--pr-number <n>", "Pull request or change request number (--mode checkout-pr)")
|
||||
.option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)"),
|
||||
.option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)")
|
||||
.option("--background", "Create a background workspace")
|
||||
.option("--no-background", "Create a visible workspace instead of inheriting background"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(workspace.command("ls").description("List active workspaces")).action(
|
||||
withOutput(runLsCommand),
|
||||
);
|
||||
addJsonAndDaemonHostOptions(
|
||||
workspace
|
||||
.command("ls")
|
||||
.description("List active workspaces")
|
||||
.option("--background", "Include background workspaces"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
workspace
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, ListResult } from "../../output/index.js";
|
||||
import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js";
|
||||
import {
|
||||
toWorkspaceRow,
|
||||
workspaceSchema,
|
||||
workspaceWithBackgroundSchema,
|
||||
type WorkspaceRow,
|
||||
} from "./shared.js";
|
||||
|
||||
export interface WorkspaceLsOptions {
|
||||
host?: string;
|
||||
daemonTarget: import("../../utils/daemon-target.js").DaemonTarget;
|
||||
/** --background: Include background workspaces */
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
export async function runLsCommand(
|
||||
options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget },
|
||||
options: WorkspaceLsOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<WorkspaceRow>> {
|
||||
const host = getDaemonHost({ target: options.daemonTarget });
|
||||
@@ -20,12 +32,17 @@ export async function runLsCommand(
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const payload = await client.fetchWorkspaces({
|
||||
...(options.background ? { filter: { includeBackground: true } } : {}),
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
workspaces.push(...payload.entries.map(toWorkspaceRow));
|
||||
cursor = payload.pageInfo.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
return { type: "list", data: workspaces, schema: workspaceSchema };
|
||||
return {
|
||||
type: "list",
|
||||
data: workspaces,
|
||||
schema: options.background ? workspaceWithBackgroundSchema : workspaceSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface WorkspaceRow {
|
||||
name: string;
|
||||
isolation: "local" | "worktree";
|
||||
cwd: string;
|
||||
background: boolean;
|
||||
}
|
||||
|
||||
export const workspaceSchema: OutputSchema<WorkspaceRow> = {
|
||||
@@ -20,6 +21,15 @@ export const workspaceSchema: OutputSchema<WorkspaceRow> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** `--background` listings add a column so hidden workspaces stand out. */
|
||||
export const workspaceWithBackgroundSchema: OutputSchema<WorkspaceRow> = {
|
||||
...workspaceSchema,
|
||||
columns: [
|
||||
...workspaceSchema.columns,
|
||||
{ header: "BACKGROUND", field: (row) => (row.background ? "yes" : ""), width: 8 },
|
||||
],
|
||||
};
|
||||
|
||||
export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): WorkspaceRow {
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
@@ -27,5 +37,6 @@ export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): Workspace
|
||||
name: workspace.name,
|
||||
isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local",
|
||||
cwd: workspace.workspaceDirectory,
|
||||
background: workspace.background === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
interface CallerLookupClient {
|
||||
fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>;
|
||||
}
|
||||
|
||||
// PASEO_AGENT_ID belongs to the daemon that launched this shell. Commands
|
||||
// targeting another daemon (--host or --home) have no caller on that daemon.
|
||||
export async function resolveCallerAgentId(
|
||||
client: CallerLookupClient,
|
||||
env: { PASEO_AGENT_ID?: string } = process.env,
|
||||
): Promise<string | undefined> {
|
||||
const agentId = env.PASEO_AGENT_ID?.trim();
|
||||
if (!agentId) return undefined;
|
||||
const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => {
|
||||
// A missing agent is a daemon answer; a lost or timed-out connection is not.
|
||||
if (error instanceof DaemonConnectionError) throw error;
|
||||
return null;
|
||||
});
|
||||
return caller?.agent.id === agentId ? agentId : undefined;
|
||||
}
|
||||
@@ -53,8 +53,12 @@ try {
|
||||
console.log("Test 1: run --help shows options");
|
||||
const result = await $`npx paseo run --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "run --help should exit 0");
|
||||
assert(result.stdout.includes("-d"), "help should mention -d flag");
|
||||
assert(result.stdout.includes("--background"), "help should mention --background flag");
|
||||
assert(result.stdout.includes("--no-wait"), "help should mention --no-wait flag");
|
||||
assert(
|
||||
!result.stdout.includes("--internal"),
|
||||
"help should not mention removed --internal flag",
|
||||
);
|
||||
assert(!result.stdout.includes("--background"), "help should hide legacy --background syntax");
|
||||
assert(!result.stdout.includes("--detach"), "help should hide legacy --detach syntax");
|
||||
assert(result.stdout.includes("--title"), "help should mention --title option");
|
||||
assert(result.stdout.includes("--provider"), "help should mention --provider option");
|
||||
@@ -178,18 +182,18 @@ try {
|
||||
console.log("✓ run --output-schema flag is accepted\n");
|
||||
}
|
||||
|
||||
// Test 10: run --output-schema cannot be used with --background
|
||||
// Test 10: run --output-schema cannot be used with --no-wait
|
||||
{
|
||||
console.log("Test 10: run --output-schema cannot be used with --background");
|
||||
console.log("Test 10: run --output-schema cannot be used with --no-wait");
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --background --output-schema ${schemaPath} "test prompt"`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema");
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --no-wait --output-schema ${schemaPath} "test prompt"`.nothrow();
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail with --no-wait and --output-schema");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes("--output-schema cannot be used with --background"),
|
||||
output.includes("--output-schema cannot be used with --no-wait"),
|
||||
"error should explain background incompatibility",
|
||||
);
|
||||
console.log("✓ run --output-schema cannot be used with --background\n");
|
||||
console.log("✓ run --output-schema cannot be used with --no-wait\n");
|
||||
}
|
||||
|
||||
// Test 11: -q (quiet) flag is accepted with run
|
||||
|
||||
@@ -122,10 +122,15 @@ const EXPECTED_CLAUDE_MODELS = [
|
||||
model: "Opus 4.6",
|
||||
descriptionFragment: "Most capable",
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-5-5",
|
||||
model: "Haiku 5.5",
|
||||
descriptionFragment: "Fastest",
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-4-5",
|
||||
model: "Haiku 4.5",
|
||||
descriptionFragment: "Fastest",
|
||||
descriptionFragment: "Previous release",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -435,8 +440,8 @@ try {
|
||||
"--quiet should print the same ordered model IDs returned by --json",
|
||||
);
|
||||
assert(
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"),
|
||||
"captured --json output should include the current Claude everyday model id",
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-haiku-5-5"),
|
||||
"captured --json output should include the current Claude quick-answer model id",
|
||||
);
|
||||
console.log("✓ provider models --quiet outputs model IDs only\n");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import assert from "node:assert";
|
||||
import { basename } from "node:path";
|
||||
import { createPaseoClient } from "@getpaseo/client";
|
||||
import { createE2ETestContext } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Workspace Rename Command ===\n");
|
||||
@@ -51,8 +52,61 @@ try {
|
||||
)?.name,
|
||||
basename(ctx.workDir),
|
||||
);
|
||||
|
||||
await verifyWorkspaceCallerContext();
|
||||
} finally {
|
||||
await ctx.stop();
|
||||
}
|
||||
|
||||
console.log("=== Workspace Rename Command Tests Passed ===");
|
||||
|
||||
async function verifyWorkspaceCallerContext() {
|
||||
const sdk = createPaseoClient({ url: `${ctx.wsUrl}/ws`, reconnect: { enabled: false } });
|
||||
try {
|
||||
await sdk.connect();
|
||||
const parentWorkspace = await sdk.workspaces.create({
|
||||
source: { kind: "directory", path: ctx.workDir },
|
||||
background: true,
|
||||
});
|
||||
// Creation without a prompt stays idle; this visibility test runs no provider turn.
|
||||
const parent = await parentWorkspace.agents.create({ config: { provider: "claude/haiku" } });
|
||||
const cases = [
|
||||
{ caller: parent.id, background: undefined, expected: true },
|
||||
{ caller: parent.id, background: false, expected: false },
|
||||
{ caller: parent.id, background: true, expected: true },
|
||||
{ caller: "foreign-or-missing-agent", background: undefined, expected: false },
|
||||
{ caller: "foreign-or-missing-agent", background: true, expected: true },
|
||||
{ caller: "foreign-or-missing-agent", background: false, expected: false },
|
||||
{ caller: "", background: undefined, expected: false },
|
||||
{ caller: "", background: true, expected: true },
|
||||
];
|
||||
for (const entry of cases) {
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
"workspace",
|
||||
"create",
|
||||
"--host",
|
||||
`127.0.0.1:${ctx.port}`,
|
||||
"--isolation",
|
||||
"local",
|
||||
"--path",
|
||||
ctx.workDir,
|
||||
...(entry.background === undefined
|
||||
? []
|
||||
: [entry.background ? "--background" : "--no-background"]),
|
||||
"--json",
|
||||
],
|
||||
{ env: { PASEO_AGENT_ID: entry.caller } },
|
||||
);
|
||||
assert.strictEqual(result.exitCode, 0, result.stderr);
|
||||
assert.strictEqual(
|
||||
JSON.parse(result.stdout).background,
|
||||
entry.expected,
|
||||
JSON.stringify(entry),
|
||||
);
|
||||
}
|
||||
console.log("✓ workspace create caller inheritance, foreign/blank defaults, and overrides");
|
||||
} finally {
|
||||
await sdk.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
} from "../../../../scripts/test-support/npm-registry.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { once } from "node:events";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { connectToDaemon } from "../../src/utils/client.ts";
|
||||
import { createE2ETestContext } from "../helpers/test-daemon.ts";
|
||||
|
||||
@@ -45,6 +47,81 @@ async function main(): Promise<void> {
|
||||
);
|
||||
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
|
||||
|
||||
// Observe real CLI requests while forwarding them to the isolated daemon.
|
||||
const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const sources: string[] = [];
|
||||
proxy.on("connection", (socket, request) => {
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, {
|
||||
headers: { authorization: request.headers.authorization ?? "" },
|
||||
});
|
||||
const ready = once(upstream, "open");
|
||||
socket.on("message", async (data, isBinary) => {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (
|
||||
message.type === "session" &&
|
||||
message.message.type === "plugin.source.install.request"
|
||||
) {
|
||||
sources.push(message.message.source);
|
||||
}
|
||||
await ready;
|
||||
upstream.send(data, { binary: isBinary });
|
||||
});
|
||||
upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary }));
|
||||
socket.on("close", () => upstream.close());
|
||||
upstream.on("close", () => socket.close());
|
||||
});
|
||||
await once(proxy, "listening");
|
||||
const address = proxy.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const sibling = path.join(context.workDir, "x");
|
||||
const caller = path.join(context.workDir, "caller");
|
||||
await mkdir(caller);
|
||||
await mkdir(path.join(sibling, "sub"), { recursive: true });
|
||||
for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) {
|
||||
await writeFile(
|
||||
path.join(target, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "relative-cli",
|
||||
requirements: { paseo: `>=${resolveCliVersion()}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(target, "index.server.ts"), pluginSource);
|
||||
}
|
||||
assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd.
|
||||
try {
|
||||
for (const [source, expectedSource, expectedDirectory, extraArgs] of [
|
||||
[".", caller, caller, []],
|
||||
["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []],
|
||||
["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]],
|
||||
["~", context.paseoHome, context.paseoHome, []],
|
||||
["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []],
|
||||
] as const) {
|
||||
sources.length = 0;
|
||||
const result = await context.paseo(
|
||||
["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"],
|
||||
{ cwd: caller },
|
||||
);
|
||||
assert.deepEqual(sources, [expectedSource]);
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).path, expectedDirectory);
|
||||
const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]);
|
||||
assert.equal(removed.exitCode, 0, removed.stderr);
|
||||
}
|
||||
// An absolute path belongs to the daemon's platform, which can differ from the CLI's.
|
||||
const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`;
|
||||
sources.length = 0;
|
||||
const missing = await context.paseo(
|
||||
["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"],
|
||||
{ cwd: caller },
|
||||
);
|
||||
assert.deepEqual(sources, [windowsSource]);
|
||||
assert.equal(missing.exitCode, 1);
|
||||
assert.match(missing.stderr, /Plugin directory does not exist/);
|
||||
} finally {
|
||||
for (const socket of proxy.clients) socket.close();
|
||||
await promisify(proxy.close.bind(proxy))();
|
||||
}
|
||||
|
||||
const install = await context.paseo(["plugin", "install", directory, "--json"]);
|
||||
assert.equal(install.exitCode, 0, install.stderr);
|
||||
assert.equal(JSON.parse(install.stdout).id, "cli-e2e");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@getpaseo/relay": "0.11.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3100,6 +3100,162 @@ test("sends worktree target and autoArchive in create_agent_request", async () =
|
||||
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
|
||||
});
|
||||
|
||||
test("sends internal in create_agent_request when the host advertises internal agents", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ features: { backgroundWorkspaces: true } });
|
||||
await connectPromise;
|
||||
|
||||
const createPromise = client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
background: true,
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "create_agent_request",
|
||||
config: { provider: "codex", cwd: "/tmp/project" },
|
||||
background: true,
|
||||
}),
|
||||
);
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_create_failed",
|
||||
requestId: request.requestId,
|
||||
error: "internal agent sentinel",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(createPromise).rejects.toThrow("internal agent sentinel");
|
||||
});
|
||||
|
||||
test("rejects an internal create before sending when the host predates internal agents", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ features: {} });
|
||||
await connectPromise;
|
||||
|
||||
await expect(
|
||||
client.createAgent({ provider: "codex", cwd: "/tmp/project", background: true }),
|
||||
).rejects.toThrow("Update the host to use background workspaces.");
|
||||
expect(mock.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sends internal workspace creation and internal listings only to hosts that support them", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
// workspaceMultiplicity keeps the fetch on the real workspace directory RPC.
|
||||
mock.triggerOpen({
|
||||
features: { workspaceMultiplicity: true, backgroundWorkspaces: true },
|
||||
});
|
||||
await connectPromise;
|
||||
|
||||
void client
|
||||
.createWorkspace({ source: { kind: "directory", path: "/tmp/project" }, background: true })
|
||||
.catch(() => undefined);
|
||||
void client.fetchWorkspaces({ filter: { includeBackground: true } }).catch(() => undefined);
|
||||
void client.fetchAgents({ filter: { includeBackground: true } }).catch(() => undefined);
|
||||
|
||||
expect(mock.sent.map((frame) => parseSentFrame(frame))).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "workspace.create.request", background: true }),
|
||||
expect.objectContaining({
|
||||
type: "fetch_workspaces_request",
|
||||
filter: { includeBackground: true },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "fetch_agents_request",
|
||||
filter: { includeBackground: true },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects internal workspace creation and internal listings before sending on older hosts", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ features: {} });
|
||||
await connectPromise;
|
||||
|
||||
await expect(
|
||||
client.createWorkspace({
|
||||
source: { kind: "directory", path: "/tmp/project" },
|
||||
background: true,
|
||||
}),
|
||||
).rejects.toThrow("Update the host to use background workspaces.");
|
||||
await expect(client.fetchWorkspaces({ filter: { includeBackground: true } })).rejects.toThrow(
|
||||
"Update the host to use background workspaces.",
|
||||
);
|
||||
expect(mock.sent).toHaveLength(0);
|
||||
|
||||
// Agent listings gate on the older internalAgents feature.
|
||||
const olderMock = createMockTransport();
|
||||
const olderClient = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => olderMock.transport,
|
||||
});
|
||||
clients.push(olderClient);
|
||||
const olderConnect = olderClient.connect();
|
||||
olderMock.triggerOpen({ features: {} });
|
||||
await olderConnect;
|
||||
await expect(olderClient.fetchAgents({ filter: { includeBackground: true } })).rejects.toThrow(
|
||||
"Update the host to use background workspaces.",
|
||||
);
|
||||
expect(olderMock.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sends structured attachments with create_agent_request", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -7325,3 +7481,49 @@ test("usage request timeout detaches its update listener", async () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"agent provenance requires host support=%s while human sends remain compatible",
|
||||
async (supported) => {
|
||||
const transport = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "provenance",
|
||||
transportFactory: () => transport.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
transport.triggerOpen({ features: { agentMessageProvenance: supported } });
|
||||
await connecting;
|
||||
if (!supported) {
|
||||
await expect(
|
||||
client.sendAgentMessage("recipient", "review", { sourceAgentId: "server::sender" }),
|
||||
).rejects.toThrow("Update the Paseo host");
|
||||
expect(transport.sent).toHaveLength(0);
|
||||
}
|
||||
const send = client.sendAgentMessage("recipient", "review", {
|
||||
messageId: "delivery",
|
||||
...(supported ? { sourceAgentId: "server::sender" } : {}),
|
||||
});
|
||||
const request = parseSentFrame(transport.sent[0]);
|
||||
expect(request).toMatchObject({
|
||||
type: "send_agent_message_request",
|
||||
text: "review",
|
||||
messageId: "delivery",
|
||||
});
|
||||
expect(request.sourceAgentId).toBe(supported ? "server::sender" : undefined);
|
||||
transport.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
agentId: "recipient",
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await send;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -424,6 +424,7 @@ export interface DaemonClientTrace {
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
sourceAgentId?: string;
|
||||
messageId?: string;
|
||||
/** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */
|
||||
activeTurnBehavior?: ActiveTurnBehavior;
|
||||
@@ -459,6 +460,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
||||
git?: GitSetupOptions;
|
||||
worktree?: CreateAgentRequestMessage["worktree"];
|
||||
autoArchive?: CreateAgentRequestMessage["autoArchive"];
|
||||
background?: CreateAgentRequestMessage["background"];
|
||||
// COMPAT(createAgentWorktree): low-level old callers may still send the
|
||||
// create-agent worktree field. Added in v0.2.0; remove after 2027-01-17.
|
||||
worktreeName?: string;
|
||||
@@ -469,6 +471,9 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
||||
export interface CreateWorkspaceRequestOptions {
|
||||
source: WorkspaceCreateRequest["source"];
|
||||
title?: string;
|
||||
/** Workspace discovery visibility; contents retain normal durability. */
|
||||
background?: boolean;
|
||||
callerAgentId?: string;
|
||||
idempotencyKey?: string;
|
||||
workspaceId?: string;
|
||||
agent?: Omit<
|
||||
@@ -1195,6 +1200,25 @@ export function supportsUsageReports(features: ServerInfoStatusPayload["features
|
||||
return features?.usageSources === true || features?.providerUsageList === true;
|
||||
}
|
||||
|
||||
function buildFetchAgentsRequest(
|
||||
options: FetchAgentsOptions | undefined,
|
||||
requestId: string,
|
||||
): SessionInboundMessage {
|
||||
return SessionInboundMessageSchema.parse({
|
||||
type: "fetch_agents_request",
|
||||
requestId,
|
||||
...(options?.scope ? { scope: options.scope } : {}),
|
||||
...(options?.filter ? { filter: options.filter } : {}),
|
||||
...(options?.sort ? { sort: options.sort } : {}),
|
||||
...(options?.page ? { page: options.page } : {}),
|
||||
...(options?.subscribe ? { subscribe: options.subscribe } : {}),
|
||||
...(options?.sync ? { sync: options.sync } : {}),
|
||||
...(options?.includeBackground !== undefined
|
||||
? { includeBackground: options.includeBackground }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export class DaemonClient {
|
||||
private readonly providerSnapshotUpdates = new ProviderSnapshotUpdates({
|
||||
active: (message) => this.owned.owns(message),
|
||||
@@ -2341,6 +2365,8 @@ export class DaemonClient {
|
||||
observeAgents(
|
||||
options: Omit<FetchAgentsOptions, "subscribe"> = {},
|
||||
): OwnedSubscription<FetchAgentsPayload> {
|
||||
if (options.includeBackground || options.filter?.includeBackground)
|
||||
this.requireBackgroundWorkspaces();
|
||||
const { signal, requestId, timeout, ...query } = options;
|
||||
return this.observe(
|
||||
"fetch_agents_response",
|
||||
@@ -2352,6 +2378,8 @@ export class DaemonClient {
|
||||
observeWorkspaces(
|
||||
options: Omit<FetchWorkspacesOptions, "subscribe"> = {},
|
||||
): OwnedSubscription<FetchWorkspacesPayload> {
|
||||
if (options.includeBackground || options.filter?.includeBackground)
|
||||
this.requireBackgroundWorkspaces();
|
||||
const { signal, requestId, ...query } = options;
|
||||
return this.observe(
|
||||
"fetch_workspaces_response",
|
||||
@@ -2367,6 +2395,8 @@ export class DaemonClient {
|
||||
async fetchAgents(
|
||||
options?: FetchAgentsOptions,
|
||||
): Promise<FetchAgentsPayload & { subscription?: OwnedSubscription<FetchAgentsPayload> }> {
|
||||
if (options?.includeBackground || options?.filter?.includeBackground)
|
||||
this.requireBackgroundWorkspaces();
|
||||
if (options?.subscribe) {
|
||||
if (options.subscribe.subscriptionId !== undefined)
|
||||
throw new Error("Subscription IDs are assigned by the host");
|
||||
@@ -2374,16 +2404,7 @@ export class DaemonClient {
|
||||
return { ...(await subscription.ready), subscription };
|
||||
}
|
||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_agents_request",
|
||||
requestId: resolvedRequestId,
|
||||
...(options?.scope ? { scope: options.scope } : {}),
|
||||
...(options?.filter ? { filter: options.filter } : {}),
|
||||
...(options?.sort ? { sort: options.sort } : {}),
|
||||
...(options?.page ? { page: options.page } : {}),
|
||||
...(options?.subscribe ? { subscribe: options.subscribe } : {}),
|
||||
...(options?.sync ? { sync: options.sync } : {}),
|
||||
});
|
||||
const message = buildFetchAgentsRequest(options, resolvedRequestId);
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
@@ -2402,6 +2423,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async fetchAgentHistory(options?: FetchAgentHistoryOptions): Promise<FetchAgentHistoryPayload> {
|
||||
if (options?.filter?.includeBackground) this.requireBackgroundWorkspaces();
|
||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_agent_history_request",
|
||||
@@ -2465,6 +2487,8 @@ export class DaemonClient {
|
||||
): Promise<
|
||||
FetchWorkspacesPayload & { subscription?: OwnedSubscription<FetchWorkspacesPayload> }
|
||||
> {
|
||||
if (options?.includeBackground || options?.filter?.includeBackground)
|
||||
this.requireBackgroundWorkspaces();
|
||||
if (options?.subscribe) {
|
||||
if (options.subscribe.subscriptionId !== undefined)
|
||||
throw new Error("Subscription IDs are assigned by the host");
|
||||
@@ -2474,12 +2498,8 @@ export class DaemonClient {
|
||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_workspaces_request",
|
||||
...options,
|
||||
requestId: resolvedRequestId,
|
||||
...(options?.filter ? { filter: options.filter } : {}),
|
||||
...(options?.sort ? { sort: options.sort } : {}),
|
||||
...(options?.page ? { page: options.page } : {}),
|
||||
...(options?.subscribe ? { subscribe: options.subscribe } : {}),
|
||||
...(options?.sync ? { sync: options.sync } : {}),
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
@@ -2852,7 +2872,19 @@ export class DaemonClient {
|
||||
legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
|
||||
});
|
||||
|
||||
// Background workspaces are gated once at the transport boundary.
|
||||
private requireBackgroundWorkspaces(): void {
|
||||
if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) {
|
||||
throw new Error("Update the host to use background workspaces.");
|
||||
}
|
||||
}
|
||||
|
||||
supportsBackgroundWorkspaces(): boolean {
|
||||
return this.lastServerInfoMessage?.features?.backgroundWorkspaces === true;
|
||||
}
|
||||
|
||||
async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> {
|
||||
if (options.background !== undefined) this.requireBackgroundWorkspaces();
|
||||
const result = await this.creations.createAgent({
|
||||
...options,
|
||||
config: resolveAgentConfig(options),
|
||||
@@ -2885,6 +2917,7 @@ export class DaemonClient {
|
||||
...(options.git ? { git: options.git } : {}),
|
||||
...(options.worktree ? { worktree: options.worktree } : {}),
|
||||
...(options.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}),
|
||||
...(options.background !== undefined ? { background: options.background } : {}),
|
||||
...(options.worktreeName ? { worktreeName: options.worktreeName } : {}),
|
||||
...(options.labels && Object.keys(options.labels).length > 0
|
||||
? { labels: options.labels }
|
||||
@@ -3447,6 +3480,12 @@ export class DaemonClient {
|
||||
text: string,
|
||||
options?: SendMessageOptions,
|
||||
): Promise<void> {
|
||||
if (
|
||||
options?.sourceAgentId &&
|
||||
this.lastServerInfoMessage?.features?.agentMessageProvenance !== true
|
||||
) {
|
||||
throw new Error("Update the Paseo host to send messages with agent provenance.");
|
||||
}
|
||||
const requestId = this.createRequestId();
|
||||
const messageId = options?.messageId ?? crypto.randomUUID();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -3454,6 +3493,7 @@ export class DaemonClient {
|
||||
requestId,
|
||||
agentId,
|
||||
text,
|
||||
sourceAgentId: options?.sourceAgentId,
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}),
|
||||
...(options?.images ? { images: options.images } : {}),
|
||||
@@ -4552,6 +4592,9 @@ export class DaemonClient {
|
||||
requestId?: string,
|
||||
): Promise<WorkspaceCreatePayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId ?? input.requestId);
|
||||
if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces();
|
||||
if (input.agent?.background !== undefined)
|
||||
throw new Error("Configure background on the workspace, not its initial agent.");
|
||||
const result = await this.creations.createWorkspace({
|
||||
...input,
|
||||
requestId: resolvedRequestId,
|
||||
@@ -4583,6 +4626,8 @@ export class DaemonClient {
|
||||
? { idempotencyKey: input.idempotencyKey }
|
||||
: {}),
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(input.background !== undefined ? { background: input.background } : {}),
|
||||
...(input.callerAgentId ? { callerAgentId: input.callerAgentId } : {}),
|
||||
...(input.firstAgentContext !== undefined
|
||||
? { firstAgentContext: input.firstAgentContext }
|
||||
: {}),
|
||||
@@ -6932,6 +6977,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
||||
attachments: _attachments,
|
||||
worktree: _worktree,
|
||||
autoArchive: _autoArchive,
|
||||
background: _background,
|
||||
env: _env,
|
||||
workspaceId: _workspaceId,
|
||||
initialPrompt: _initialPrompt,
|
||||
|
||||
@@ -691,6 +691,61 @@ test("plugin-shaped PR workspace create and agent create use the existing daemon
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("agents.create forwards internal on the request, outside the agent config", async () => {
|
||||
const { client, ws } = await connectClient({
|
||||
providerUsageList: true,
|
||||
providersSnapshotCwd: true,
|
||||
ownedSubscriptions: true,
|
||||
backgroundWorkspaces: true,
|
||||
});
|
||||
|
||||
const createPromise = client.agents.create({
|
||||
config: { provider: "codex/gpt-5.4" },
|
||||
cwd: "/repo/sdk",
|
||||
title: "Summary helper",
|
||||
background: true,
|
||||
autoArchive: true,
|
||||
});
|
||||
const request = parseSentSessionMessage(ws.sent.at(-1));
|
||||
expect(request).toMatchObject({
|
||||
type: "create_agent_request",
|
||||
config: { provider: "codex", model: "gpt-5.4", cwd: "/repo/sdk", title: "Summary helper" },
|
||||
background: true,
|
||||
autoArchive: true,
|
||||
});
|
||||
expect(request.config).not.toHaveProperty("internal");
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_created",
|
||||
requestId: request.requestId,
|
||||
agentId: "agent_helper",
|
||||
agent: createAgent({ id: "agent_helper" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const agent = await createPromise;
|
||||
expect(agent.id).toBe("agent_helper");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("agents.create with internal fails before sending on a host without internal agents", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
const sentBefore = ws.sent.length;
|
||||
|
||||
await expect(
|
||||
client.agents.create({
|
||||
config: { provider: "codex/gpt-5.4" },
|
||||
cwd: "/repo/sdk",
|
||||
background: true,
|
||||
}),
|
||||
).rejects.toThrow("Update the host to use background workspaces.");
|
||||
expect(ws.sent).toHaveLength(sentBefore);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
const createdAgent = createAgent();
|
||||
|
||||
@@ -257,6 +257,8 @@ export interface PaseoAgentCreateOptions {
|
||||
git?: CreateAgentRequestMessage["git"];
|
||||
worktree?: CreateAgentRequestMessage["worktree"];
|
||||
autoArchive?: CreateAgentRequestMessage["autoArchive"];
|
||||
/** Visibility intent for a NEW workspace. Existing targets reject this option. */
|
||||
background?: CreateAgentRequestMessage["background"];
|
||||
requestId?: string;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
@@ -780,9 +782,12 @@ function createWorkspaceHandleFactory(
|
||||
const refresh = async (options?: { requestId?: string }) => {
|
||||
let cursor: string | undefined;
|
||||
let requestId = options?.requestId;
|
||||
// A ref addresses one workspace by id, so a background one resolves too.
|
||||
const filter = daemonClient.supportsBackgroundWorkspaces() ? { includeBackground: true } : {};
|
||||
do {
|
||||
const result = await daemonClient.fetchWorkspaces({
|
||||
requestId,
|
||||
filter,
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
const match = result.entries.find((entry) => entry.id === id);
|
||||
|
||||
@@ -287,7 +287,6 @@ async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) {
|
||||
provider: "mock/ten-second-stream",
|
||||
settings: { modeId: "load-test" },
|
||||
initialPrompt: "Remain available while the browser bridge regression runs.",
|
||||
background: true,
|
||||
},
|
||||
});
|
||||
const result = response.structuredContent;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.2",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -70,8 +70,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -81,8 +81,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.2",
|
||||
"@getpaseo/protocol": "0.11.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user