Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e3c82d5fe6 fix(claude): preserve replies across concurrent session startup
Share initialization and bind prompt input, output consumption, and cleanup to one runtime so overlapping command discovery cannot leave a conversation writing to a process whose replies are never consumed.
2026-10-07 14:41:43 +02:00
38 changed files with 776 additions and 917 deletions
+8 -36
View File
@@ -1,16 +1,12 @@
# Changelog
## 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.
## 0.11.0-beta.5 - 2026-10-06
### Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216))
- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
@@ -20,26 +16,21 @@ Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Bro
- 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))
### 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 screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
- Added `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), [#6185](https://github.com/getpaseo/paseo/pull/6185))
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop 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))
@@ -55,18 +46,12 @@ Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Bro
### Fixed
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson))
- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272))
- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248))
- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268))
- Fixed 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 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))
@@ -81,14 +66,12 @@ Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Bro
- 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))
@@ -102,18 +85,12 @@ Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Bro
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172))
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970))
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
- Fixed 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))
@@ -121,16 +98,12 @@ Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Bro
- 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))
@@ -150,7 +123,6 @@ Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Bro
- 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))
@@ -1,7 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,7 +0,0 @@
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
+1 -1
View File
@@ -1 +1 @@
sha256-AKY6OMZgHeyn/To/2zsmtfb5C4fKniUp8gvpEYaR7/o=
sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36413,7 +36413,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37496,12 +37496,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.0",
"@getpaseo/protocol": "0.11.0",
"@getpaseo/server": "0.11.0",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/server": "0.11.0-beta.5",
"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",
"version": "0.11.0-beta.5",
"dependencies": {
"@getpaseo/protocol": "0.11.0",
"@getpaseo/relay": "0.11.0",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37762,7 +37762,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"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",
"version": "0.11.0-beta.5",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38902,7 +38902,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"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",
"version": "0.11.0-beta.5",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.0",
"@getpaseo/protocol": "0.11.0",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@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",
"@getpaseo/protocol": "0.11.0",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"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",
"version": "0.11.0-beta.5",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38998,7 +38998,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39216,16 +39216,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"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",
"@getpaseo/highlight": "0.11.0",
"@getpaseo/plugin": "0.11.0",
"@getpaseo/protocol": "0.11.0",
"@getpaseo/relay": "0.11.0",
"@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",
"@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",
"version": "0.11.0-beta.5",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41754,7 +41754,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"private": true,
"main": "index.ts",
"scripts": {
@@ -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 { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store";
import { 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,14 +532,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
});
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 unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => {
if (message.type !== "agent.provider_subagents.update") return;
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
});
const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => {
@@ -1,11 +1,8 @@
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";
@@ -18,7 +15,6 @@ afterEach(() => {
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
trackedParents: new Set(),
});
});
@@ -731,96 +727,3 @@ 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,9 +28,6 @@ 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,
@@ -87,7 +84,6 @@ export function refreshProviderSubagents(
const pending = clientRequests.get(requestKey);
if (pending) return pending;
useProviderSubagentStore.getState().trackParent(serverId, parentAgentId);
const request = client
.listProviderSubagents(parentAgentId)
.then((payload) => {
@@ -105,27 +101,6 @@ 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: [],
@@ -172,14 +147,6 @@ 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);
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"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",
"@getpaseo/protocol": "0.11.0",
"@getpaseo/server": "0.11.0",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/server": "0.11.0-beta.5",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.11.0",
"@getpaseo/relay": "0.11.0",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -70,8 +70,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.0",
"@getpaseo/protocol": "0.11.0",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@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",
"@getpaseo/protocol": "0.11.0",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.11.0",
"@getpaseo/highlight": "0.11.0",
"@getpaseo/plugin": "0.11.0",
"@getpaseo/protocol": "0.11.0",
"@getpaseo/relay": "0.11.0",
"@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",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1,4 +1,4 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
@@ -90,29 +90,4 @@ describe("ClaudeAgentSession persisted history load", () => {
}),
);
});
// chmod does not remove read access on Windows, or for root.
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"logs a warning with the error when Claude's project folders cannot be listed",
async () => {
// The transcript is not under cwd's folder, so the lookup lists the other project folders.
mkdirSync(path.join(configDir, "projects"), { recursive: true });
chmodSync(path.join(configDir, "projects"), 0o000);
try {
await resume();
} finally {
chmodSync(path.join(configDir, "projects"), 0o755);
}
expect(records).toContainEqual(
expect.objectContaining({
level: pino.levels.values.warn,
msg: "Failed to load Claude history from transcript",
sessionId: SESSION_ID,
err: expect.objectContaining({ message: expect.stringContaining("EACCES") }),
}),
);
},
);
});
@@ -1,3 +1,10 @@
import type {
NonNullableUsage,
Options,
Query,
SDKMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { afterEach, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
@@ -276,6 +283,453 @@ afterEach(() => {
queryFactory.mockReset();
});
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
function unsupportedQueryOperation(): never {
throw new Error("Unexpected query operation in runtime lifecycle test");
}
interface RuntimeQueryHooks {
initialize?: (flags: Parameters<Query["applyFlagSettings"]>[0]) => Promise<void>;
retire?: () => Promise<void>;
beforeReply?: () => Promise<void>;
}
function createRuntimeQuery(prompt: AsyncIterable<SDKUserMessage>, hooks: RuntimeQueryHooks = {}) {
const usage: NonNullableUsage = {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 },
server_tool_use: { web_fetch_requests: 0, web_search_requests: 0 },
service_tier: "standard",
inference_geo: "",
speed: "standard",
iterations: [],
output_tokens_details: { thinking_tokens: 0 },
};
const output = createAsyncQueue<SDKMessage>();
const received: string[] = [];
let closed = false;
const stream = (async function* () {
for (;;) {
const next = await output.next();
if (next.done) return;
yield next.value;
}
})();
const returnStream = stream.return.bind(stream);
const query: Query = Object.assign(stream, {
interrupt: async () => undefined,
setPermissionMode: async () => undefined,
setModel: async () => undefined,
applyFlagSettings: async (flags: Parameters<Query["applyFlagSettings"]>[0]) => {
await hooks.initialize?.(flags);
},
return: async () => {
output.end();
await hooks.retire?.();
return returnStream();
},
supportedCommands: async () => [],
supportedModels: async () => [],
close: () => {
closed = true;
output.end();
},
setMcpPermissionModeOverride: unsupportedQueryOperation,
setMaxThinkingTokens: unsupportedQueryOperation,
initializationResult: unsupportedQueryOperation,
reinitialize: unsupportedQueryOperation,
supportedAgents: unsupportedQueryOperation,
mcpServerStatus: unsupportedQueryOperation,
getContextUsage: unsupportedQueryOperation,
usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: unsupportedQueryOperation,
readFile: unsupportedQueryOperation,
reloadPlugins: unsupportedQueryOperation,
reloadSkills: unsupportedQueryOperation,
accountInfo: unsupportedQueryOperation,
rewindFiles: unsupportedQueryOperation,
seedReadState: unsupportedQueryOperation,
reconnectMcpServer: unsupportedQueryOperation,
toggleMcpServer: unsupportedQueryOperation,
setMcpServers: unsupportedQueryOperation,
streamInput: unsupportedQueryOperation,
stopTask: unsupportedQueryOperation,
backgroundTasks: unsupportedQueryOperation,
});
void (async () => {
for await (const message of prompt) {
const text = extractPromptText(message);
received.push(text);
await hooks.beforeReply?.();
output.push({
type: "assistant",
uuid: crypto.randomUUID(),
session_id: "runtime-lifecycle",
parent_tool_use_id: null,
message: {
id: crypto.randomUUID(),
type: "message",
role: "assistant",
model: "opus",
content: [{ type: "text", text: `reply to ${text}`, citations: null }],
stop_reason: "end_turn",
stop_sequence: null,
usage,
container: null,
context_management: null,
diagnostics: null,
stop_details: null,
},
});
output.push({
type: "result",
subtype: "success",
uuid: crypto.randomUUID(),
session_id: "runtime-lifecycle",
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
num_turns: 1,
result: `reply to ${text}`,
stop_reason: "end_turn",
total_cost_usd: 0,
usage,
modelUsage: {},
permission_denials: [],
});
}
})();
return { query, received, isClosed: () => closed };
}
test("command discovery overlapping startup preserves replies to subsequent prompts", async () => {
const lookup = deferred();
let lookups = 0;
const queries: ReturnType<typeof createRuntimeQuery>[] = [];
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => {
if (++lookups === 1) await lookup.promise;
return "/test/claude/bin";
},
queryFactory: ({ prompt }) => {
if (typeof prompt === "string") throw new Error("Expected streaming input");
const runtime = createRuntimeQuery(prompt);
queries.push(runtime);
return runtime.query;
},
}).createSession({ provider: "claude", cwd: process.cwd() });
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
const commands = session.listCommands!();
const first = session.startTurn("first");
// Finish all work not blocked by executable discovery before releasing it.
await new Promise<void>((resolve) => setImmediate(resolve));
lookup.resolve();
await Promise.all([commands, first]);
await new Promise<void>((resolve) => setImmediate(resolve));
await session.startTurn("hello");
await new Promise<void>((resolve) => setImmediate(resolve));
expect(collectAssistantText(events)).toBe("reply to firstreply to hello");
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(2);
expect(queries).toHaveLength(1);
expect(queries[0].received).toEqual(["first", "hello"]);
} finally {
lookup.resolve();
for (const runtime of queries) runtime.query.close();
await session.close();
}
});
test("closing during query initialization disposes the process before initialization settles", async () => {
const initializing = deferred();
const release = deferred();
const queries: ReturnType<typeof createRuntimeQuery>[] = [];
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude/bin",
queryFactory: ({ prompt }) => {
if (typeof prompt === "string") throw new Error("Expected streaming input");
const runtime = createRuntimeQuery(prompt, {
initialize: async () => {
initializing.resolve();
await release.promise;
},
});
queries.push(runtime);
return runtime.query;
},
}).createSession({ provider: "claude", model: "claude-opus-5-5", cwd: process.cwd() });
const discovery = session.listCommands!();
const rejected = expect(discovery).rejects.toThrow("Claude session is closed");
try {
await initializing.promise;
await session.close();
expect(queries[0].isClosed()).toBe(true);
} finally {
release.resolve();
await rejected;
await session.close();
}
});
async function createRuntimeHarness(
options: {
resolveBinary?: () => Promise<string>;
hooks?: RuntimeQueryHooks;
} = {},
) {
const launches: Options[] = [];
const queries: ReturnType<typeof createRuntimeQuery>[] = [];
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: options.resolveBinary ?? (async () => "/test/claude/bin"),
queryFactory: ({ prompt, options: launch }) => {
if (typeof prompt === "string") throw new Error("Expected streaming input");
launches.push(launch);
const runtime = createRuntimeQuery(prompt, options.hooks);
queries.push(runtime);
return runtime.query;
},
}).createSession({
provider: "claude",
model: "claude-opus-5-5",
thinkingOptionId: "low",
cwd: process.cwd(),
});
return { session, queries, launches };
}
test.each([true, false])(
"Fast mode changed to %s during startup reaches the launched query",
async (enabled) => {
const lookup = deferred();
const entered = deferred();
const applied: unknown[] = [];
const { session, queries } = await createRuntimeHarness({
resolveBinary: async () => {
entered.resolve();
await lookup.promise;
return "/test/claude/bin";
},
hooks: {
initialize: async (flags) => {
applied.push(flags.fastMode);
},
},
});
await session.setFeature!("fast_mode", !enabled);
const discovery = session.listCommands!();
try {
await entered.promise;
await session.setFeature!("fast_mode", enabled);
lookup.resolve();
await discovery;
await session.listCommands!();
expect(applied).toEqual([enabled]);
expect(queries).toHaveLength(1);
} finally {
lookup.resolve();
await discovery;
await session.close();
}
},
);
test("command discovery leaves a thinking change pending until the active turn finishes", async () => {
const reply = deferred();
const { session, queries, launches } = await createRuntimeHarness({
hooks: { beforeReply: () => reply.promise },
});
try {
const turn = collectUntilTerminal(streamSession(session, "first"));
await new Promise<void>((resolve) => setImmediate(resolve));
await session.setThinkingOption!("high");
await session.listCommands!();
expect(queries).toHaveLength(1);
reply.resolve();
expect(collectAssistantText(await turn)).toBe("reply to first");
const next = await collectUntilTerminal(streamSession(session, "hello"));
expect(collectAssistantText(next)).toBe("reply to hello");
expect(launches.map((launch) => launch.effort)).toEqual(["low", "high"]);
} finally {
reply.resolve();
await session.close();
}
});
test("command discovery and a prompt share replacement until the old runtime retires", async () => {
const retiring = deferred();
const release = deferred();
const { session, queries } = await createRuntimeHarness({
hooks: {
retire: async () => {
retiring.resolve();
await release.promise;
},
},
});
try {
await collectUntilTerminal(streamSession(session, "first"));
await session.setThinkingOption!("high");
const commands = session.listCommands!();
await retiring.promise;
const next = collectUntilTerminal(streamSession(session, "second"));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(queries).toHaveLength(1);
release.resolve();
await commands;
expect(collectAssistantText(await next)).toBe("reply to second");
expect(collectAssistantText(await collectUntilTerminal(streamSession(session, "third")))).toBe(
"reply to third",
);
expect(queries).toHaveLength(2);
expect(queries[0].isClosed()).toBe(true);
expect(queries[1].received).toEqual(["second", "third"]);
} finally {
release.resolve();
await session.close();
}
});
test("a thinking change during startup remains pending for the following turn", async () => {
const lookup = deferred();
const entered = deferred();
const { session, launches } = await createRuntimeHarness({
resolveBinary: async () => {
entered.resolve();
await lookup.promise;
return "/test/claude/bin";
},
});
try {
await session.setThinkingOption!("medium");
const first = collectUntilTerminal(streamSession(session, "first"));
await entered.promise;
await session.setThinkingOption!("high");
lookup.resolve();
expect(collectAssistantText(await first)).toBe("reply to first");
expect(collectAssistantText(await collectUntilTerminal(streamSession(session, "second")))).toBe(
"reply to second",
);
expect(launches.map((launch) => launch.effort)).toEqual(["medium", "high"]);
} finally {
lookup.resolve();
await session.close();
}
});
test("closing during executable discovery never launches a late query", async () => {
const lookup = deferred();
const entered = deferred();
const { session, queries } = await createRuntimeHarness({
resolveBinary: async () => {
entered.resolve();
await lookup.promise;
return "/test/claude/bin";
},
});
const discovery = session.listCommands!();
const rejected = expect(discovery).rejects.toThrow("Claude session is closed");
await entered.promise;
await session.close();
lookup.resolve();
await rejected;
expect(queries).toHaveLength(0);
await expect(session.listCommands!()).rejects.toThrow("Claude session is closed");
});
test("a failed shared initialization releases the runtime for a subsequent prompt", async () => {
let attempts = 0;
const { session, queries } = await createRuntimeHarness({
hooks: {
initialize: async () => {
if (++attempts === 1) throw new Error("initialization rejected");
},
},
});
try {
const discovery = session.listCommands!();
const rejected = expect(discovery).rejects.toThrow("initialization rejected");
const failed = await collectUntilTerminal(streamSession(session, "first"));
await rejected;
expect(failed.at(-1)).toMatchObject({ type: "turn_failed", error: "initialization rejected" });
expect(queries[0].isClosed()).toBe(true);
expect(queries[0].received).toEqual([]);
const recovered = await collectUntilTerminal(streamSession(session, "retry"));
expect(collectAssistantText(recovered)).toBe("reply to retry");
expect(queries).toHaveLength(2);
} finally {
await session.close();
}
});
test("a new turn waits for a naturally ended runtime to finish cleanup", async () => {
const retiring = deferred();
const release = deferred();
const { session, queries } = await createRuntimeHarness({
hooks: {
retire: async () => {
retiring.resolve();
await release.promise;
},
},
});
try {
await collectUntilTerminal(streamSession(session, "first"));
queries[0].query.close();
await retiring.promise;
const next = collectUntilTerminal(streamSession(session, "second"));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(queries).toHaveLength(1);
release.resolve();
expect(collectAssistantText(await next)).toBe("reply to second");
expect(queries).toHaveLength(2);
} finally {
release.resolve();
await session.close();
}
});
test("an interrupted startup failure does not finish its replacement turn twice", async () => {
const entered = deferred();
const release = deferred();
const { session } = await createRuntimeHarness({
hooks: {
initialize: async () => {
entered.resolve();
await release.promise;
throw new Error("initialization rejected");
},
},
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
const first = session.startTurn("first");
await entered.promise;
await session.interrupt();
const replacement = session.startTurn("second");
release.resolve();
await Promise.all([first, replacement]);
expect(events.filter((event) => event.type === "turn_canceled")).toHaveLength(1);
expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(1);
} finally {
release.resolve();
await session.close();
}
});
test("interrupt only calls query.interrupt and leaves the query open", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
@@ -143,10 +143,6 @@ function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelin
.find((item) => item.status === "completed" && item.name.toLowerCase() === "bash");
}
function getInternalQuery(session: AgentSession): unknown {
return (session as AgentSession & { query?: unknown }).query ?? null;
}
async function createSession(params?: {
cwdPrefix?: string;
modeId?: string;
@@ -316,7 +312,7 @@ describe("ClaudeAgentSession integration", () => {
}
}, 60_000);
test("interrupts a running Bash turn and continues on the same query", async () => {
test("interrupts a running Bash turn and continues the same conversation", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-interrupt-continue-",
});
@@ -340,9 +336,9 @@ describe("ClaudeAgentSession integration", () => {
event.item.name.toLowerCase() === "bash",
45_000,
);
const firstQuery = getInternalQuery(handle.session);
const firstSessionId = (await handle.session.getRuntimeInfo()).sessionId;
expect(firstQuery).toBeTruthy();
expect(firstSessionId).toEqual(expect.any(String));
await handle.session.interrupt();
@@ -360,9 +356,9 @@ describe("ClaudeAgentSession integration", () => {
const followUpEvents = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
);
const secondQuery = getInternalQuery(handle.session);
const secondSessionId = (await handle.session.getRuntimeInfo()).sessionId;
expect(secondQuery).toBe(firstQuery);
expect(secondSessionId).toBe(firstSessionId);
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
expect(followUpEvents.at(-1)).toMatchObject({
type: "turn_completed",
@@ -478,31 +478,23 @@ test("logs redacted query summary and never leaks sentinel secrets", async () =>
test("interruptActiveTurn only interrupts the active query without info logs", async () => {
const spy = createSpyLogger();
const session = await createSessionWithLogger(spy.logger);
const internal: {
query: {
interrupt: () => Promise<void>;
return?: () => Promise<void>;
close?: () => void;
} | null;
input: { end: () => void } | null;
queryRestartNeeded: boolean;
mainTurnInFlight: boolean;
interruptActiveTurn: () => Promise<void>;
} = asInternals(session);
const interrupt = vi.fn(async () => undefined);
const queryReturn = vi.fn(async () => undefined);
const end = vi.fn(() => undefined);
internal.query = {
interrupt,
return: queryReturn,
close: vi.fn(() => undefined),
};
internal.input = { end };
internal.queryRestartNeeded = false;
internal.mainTurnInFlight = true;
let acknowledgeInit!: () => void;
const initialized = new Promise<void>((resolve) => {
acknowledgeInit = resolve;
});
const activeQuery = createBaseQueryMock(
vi.fn(() => {
acknowledgeInit();
return new Promise<never>(() => {});
}),
);
activeQuery.next.mockResolvedValueOnce({ done: false, value: claudeTurnInit() });
sdkQueryFactory.mockImplementation(() => activeQuery);
await session.startTurn("hello");
await initialized;
try {
await internal.interruptActiveTurn();
await session.interrupt();
const interruptInfoMessages = extractStringLogArgs(spy.info.mock.calls).filter((message) =>
message.includes("interruptActiveTurn"),
@@ -513,12 +505,11 @@ test("interruptActiveTurn only interrupts the active query without info logs", a
expect(interruptInfoMessages).toEqual([]);
expect(interruptDebugMessages).toEqual([]);
expect(interrupt).toHaveBeenCalledTimes(1);
expect(queryReturn).not.toHaveBeenCalled();
expect(end).not.toHaveBeenCalled();
expect(internal.query).not.toBeNull();
expect(internal.input).not.toBeNull();
expect(internal.queryRestartNeeded).toBe(false);
expect(activeQuery.interrupt).toHaveBeenCalledTimes(1);
expect(activeQuery.return).not.toHaveBeenCalled();
expect(activeQuery.close).not.toHaveBeenCalled();
await session.listCommands!();
expect(sdkQueryFactory).toHaveBeenCalledTimes(1);
} finally {
await session.close();
}
@@ -587,75 +587,4 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
expect(latest.detail.log).toContain("[Read] file-6.md");
expect(latest.detail.log).toContain("[Read] file-205.md");
});
test("keeps a long main-thread tool call out of the subagents track", async () => {
// Claude Code sends a heartbeat every 30 s while a tool runs, with the tool's own id as
// parent_tool_use_id. Frames captured from Claude Code 2.1.291 for a 40 s MCP call.
queryFactory.mockImplementation(() =>
buildQueryMock([
{
type: "system",
subtype: "init",
session_id: "heartbeat-session",
permissionMode: "bypassPermissions",
model: "opus",
},
{
type: "assistant",
parent_tool_use_id: null,
message: {
id: "msg-heartbeat-1",
role: "assistant",
content: [
{ type: "tool_use", id: "toolu_slow", name: "mcp__slow__slow_check", input: {} },
],
},
},
{
type: "tool_progress",
tool_use_id: "toolu_slow-heartbeat-0",
tool_name: "mcp__slow__slow_check",
parent_tool_use_id: "toolu_slow",
elapsed_time_seconds: 30,
heartbeat: true,
},
{
type: "user",
parent_tool_use_id: null,
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_slow",
content: [{ type: "text", text: "SLOW_CHECK_OK" }],
is_error: false,
},
],
},
},
{
type: "result",
subtype: "success",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
total_cost_usd: 0,
},
]),
);
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const events = await collectUntilTerminal(streamSession(session, "run the slow check"));
await session.close();
expect(events.filter((event) => event.type === "provider_subagent")).toEqual([]);
const toolCallNames = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call" ? [event.item.name] : [],
);
expect(toolCallNames).toContain("mcp__slow__slow_check");
expect(toolCallNames).not.toContain("Task");
});
});
@@ -84,7 +84,7 @@ import {
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
@@ -1916,11 +1916,6 @@ function readClaudeParentToolUseId(message: SDKMessage): string | null {
if (!("parent_tool_use_id" in message)) {
return null;
}
// Claude Code's liveness tick for a tool still running after 30 s names that tool as its
// parent, so a long main-thread call would otherwise read as a subagent.
if (message.type === "tool_progress" && message.heartbeat === true) {
return null;
}
const parentToolUseId = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id;
return typeof parentToolUseId === "string" && parentToolUseId.length > 0 ? parentToolUseId : null;
}
@@ -2062,6 +2057,14 @@ class ClaudeContextUsageState {
}
}
interface ClaudeRuntime {
query: Query;
input: AsyncMessageInput<SDKUserMessage>;
process: { child: ChildProcess | null };
pump: Promise<void> | null;
closing: Promise<void> | null;
}
class ClaudeAgentSession implements AgentSession {
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -2075,14 +2078,12 @@ class ClaudeAgentSession implements AgentSession {
private readonly logger: Logger;
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
private query: Query | null = null;
private runtime: ClaudeRuntime | null = null;
private runtimeStarting: Promise<ClaudeRuntime> | null = null;
private readonly harnessEnvironment: Record<string, string>;
private readonly usageSessionKey = randomUUID();
private childProcess: ChildProcess | null = null;
private input: AsyncMessageInput<SDKUserMessage> | null = null;
/** The exact SDK query/input pair that owns the current foreground turn. */
private activeForegroundQuery: Query | null = null;
private activeForegroundInput: AsyncMessageInput<SDKUserMessage> | null = null;
/** The runtime that owns the foreground or autonomous turn. */
private activeTurnRuntime: ClaudeRuntime | null = null;
/**
* Messages pushed into the live SDK input (steers, and a foreground turn's own prompt) whose
* command_lifecycle has not reached "started". Interrupting has to withdraw them, or the SDK
@@ -2138,7 +2139,6 @@ class ClaudeAgentSession implements AgentSession {
private lastRuntimeModel: string | null = null;
private compacting = false;
private compactionMarkerOpen = false;
private queryPumpPromise: Promise<void> | null = null;
private queryRestartNeeded = false;
private pendingInterruptAbort = false;
/**
@@ -2324,19 +2324,15 @@ class ClaudeAgentSession implements AgentSession {
this.notifySubscribers({ type: "turn_started", provider: "claude" });
try {
await this.ensureQuery();
if (cancelIssued) {
// Stopped while Claude was still starting up: withdrawn by never sending it.
const runtime = await this.ensureRuntime();
if (this.closed || cancelIssued || this.activeForegroundTurnId !== turnId) {
// Stopped during startup: withdraw this turn's message by never sending it.
if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId);
return { turnId };
}
if (!this.input) {
throw new Error("Claude session input stream not initialized");
}
this.activeForegroundQuery = this.query;
this.activeForegroundInput = this.input;
this.startQueryPump();
this.input.push(sdkMessage);
this.activeTurnRuntime = runtime;
runtime.input.push(sdkMessage);
this.startQueryPump(runtime);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId);
@@ -2344,9 +2340,13 @@ class ClaudeAgentSession implements AgentSession {
}, 0);
} catch (error) {
if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId);
this.finishForegroundTurn(
this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"),
);
if (this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn(
this.buildTurnFailedEvent(
error instanceof Error ? error.message : "Claude stream failed",
),
);
}
}
return { turnId };
@@ -2366,23 +2366,20 @@ class ClaudeAgentSession implements AgentSession {
// Capture both ends of the live SDK stream before creating or delivering the message. There
// is deliberately no await below: a finished A cannot make this input point at a later B.
const query = this.activeForegroundQuery;
const input = this.activeForegroundInput;
if (!query || !input || this.query !== query || this.input !== input) {
const runtime = this.activeTurnRuntime;
if (!runtime || this.runtime !== runtime) {
return { status: "unavailable" };
}
const message = this.toSdkUserMessage(prompt);
message.priority = "next";
if (
(this.activeForegroundTurnId ?? this.autonomousTurn?.id) !== options.expectedTurnId ||
this.activeForegroundQuery !== query ||
this.activeForegroundInput !== input ||
this.query !== query ||
this.input !== input
this.activeTurnRuntime !== runtime ||
this.runtime !== runtime
) {
return { status: "unavailable" };
}
this.enqueueSteer(input, message, options.clearPendingPermissions === true);
this.enqueueSteer(runtime.input, message, options.clearPendingPermissions === true);
return { status: "accepted" };
}
@@ -2474,7 +2471,7 @@ class ClaudeAgentSession implements AgentSession {
const normalized = isPermissionMode(modeId) ? modeId : "default";
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
const previousMode = this.currentMode;
const launchesQuery = !this.query || this.queryRestartNeeded;
const launchesQuery = !this.runtime || this.queryRestartNeeded;
const activeQuery = await this.ensureQuery(normalized);
try {
await activeQuery.setPermissionMode(normalized);
@@ -2573,7 +2570,7 @@ class ClaudeAgentSession implements AgentSession {
...this.config.featureValues,
fast_mode: enabled,
};
const activeQuery = query ?? this.query;
const activeQuery = query ?? this.runtime?.query;
if (activeQuery) {
await activeQuery.applyFlagSettings({ fastMode: enabled });
}
@@ -2736,8 +2733,8 @@ class ClaudeAgentSession implements AgentSession {
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
turnState: this.turnState,
hasQuery: Boolean(this.query),
hasInput: Boolean(this.input),
hasQuery: Boolean(this.runtime),
hasInput: Boolean(this.runtime),
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
},
"provider.claude.session_close.start",
@@ -2747,35 +2744,17 @@ class ClaudeAgentSession implements AgentSession {
this.cancelCurrentTurn?.();
this.subscribers.clear();
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.activeTurnRuntime = null;
this.autonomousTurn = null;
this.cancelCurrentTurn = null;
this.turnState = "idle";
this.sidechainTracker.clear();
this.taskProtocolSource.reset();
this.input?.end();
this.query?.close?.();
await this.awaitWithTimeout(this.query?.interrupt?.(), "close query interrupt");
await this.awaitWithTimeout(this.query?.return?.(), "close query return");
this.query = null;
this.input = null;
// Terminate the entire process tree (claude + MCP children) to prevent
// orphan accumulation. The SDK's internal cleanup may only kill the
// direct child process.
if (this.childProcess) {
const result = await terminateWithTreeKill(this.childProcess, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
});
if (result === "kill-timeout") {
this.logger.warn(
{ pid: this.childProcess.pid, agentId: this.agentId },
"Claude process tree did not report exit after SIGKILL",
);
}
this.childProcess = null;
}
const runtime = this.runtime;
this.runtime = null;
if (runtime) await this.disposeRuntime(runtime);
// A pending launch checks closed before spawning and after initialization. Do not
// wait on executable discovery here: closing must work even when discovery stalls.
if (this.persistSession === false && this.claudeSessionId) {
// Claude Code currently ignores --no-session-persistence outside --print mode
// (see `claude --help`), so the SDK's persistSession=false is silently dropped
@@ -2989,7 +2968,7 @@ class ClaudeAgentSession implements AgentSession {
}
private async ensureFreshQuery(): Promise<Query> {
if (this.query) {
if (this.runtime) {
this.queryRestartNeeded = true;
}
return this.ensureQuery();
@@ -3169,75 +3148,99 @@ class ClaudeAgentSession implements AgentSession {
}
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
if (this.query && !this.queryRestartNeeded) {
return this.query;
return (await this.ensureRuntime(launchMode)).query;
}
private ensureRuntime(launchMode: PermissionMode = this.currentMode): Promise<ClaudeRuntime> {
if (this.closed) return Promise.reject(new Error("Claude session is closed"));
if (this.runtimeStarting) return this.runtimeStarting;
if (
this.runtime &&
!this.runtime.closing &&
(!this.queryRestartNeeded || this.activeTurnRuntime === this.runtime)
) {
return Promise.resolve(this.runtime);
}
if (this.queryRestartNeeded && this.query) {
const oldQuery = this.query;
const oldInput = this.input;
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Ending the input retires the process on purpose. Detach first so its
// exit is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
if (retiredChild) this.failRunningRuntimeTasks();
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
// Tree-kill the old process tree now that the SDK has cleaned up.
// If we skip this, MCP children of the previous claude process can
// survive as orphans when the session spawns a replacement query.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
}
const starting = this.createRuntime(launchMode).finally(() => {
if (this.runtimeStarting === starting) this.runtimeStarting = null;
});
this.runtimeStarting = starting;
return starting;
}
// Preserve claudeSessionId across query recreation so buildOptions() passes
// resume: sessionId and the new query continues the existing conversation.
private async createRuntime(launchMode: PermissionMode): Promise<ClaudeRuntime> {
const retired = this.runtime;
this.runtime = null;
if (retired) {
this.failRunningRuntimeTasks();
await this.disposeRuntime(retired);
}
if (this.closed) throw new Error("Claude session is closed");
// Consume only the settings captured by this launch. A change during an await
// remains pending for the next turn, and the pump never initiates a restart.
this.queryRestartNeeded = false;
this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>();
const options = await this.buildOptions(launchMode);
if (this.closed) throw new Error("Claude session is closed");
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
// A fresh Claude process has no turn of its own in flight.
this.mainTurnInFlight = false;
this.query = claudeQuery(
const input = createAsyncMessageInput<SDKUserMessage>();
const process: ClaudeRuntime["process"] = { child: null };
const query = claudeQuery(
{ prompt: input.iterable, options },
{
runtimeSettings: this.runtimeSettings,
launchEnv: this.launchEnv,
queryFactory: this.queryFactory,
onChildProcess: (child) => {
this.childProcess = child;
process.child = child;
child.once("exit", (code, signal) => this.handleRuntimeExit(child, code, signal));
},
},
);
const fastMode = this.resolveFastModeSetting();
if (fastMode !== null) {
await this.query.applyFlagSettings({ fastMode });
const runtime: ClaudeRuntime = { query, input, process, pump: null, closing: null };
this.runtime = runtime;
try {
const fastMode = this.resolveFastModeSetting();
if (fastMode !== null) await query.applyFlagSettings({ fastMode });
if (this.closed) throw new Error("Claude session is closed");
if (this.runtime !== runtime) throw new Error("Claude runtime exited during initialization");
return runtime;
} catch (error) {
if (this.runtime === runtime) this.runtime = null;
await this.disposeRuntime(runtime);
throw error;
}
}
private disposeRuntime(runtime: ClaudeRuntime): Promise<void> {
runtime.closing ??= this.closeRuntime(runtime);
return runtime.closing;
}
private async closeRuntime(runtime: ClaudeRuntime): Promise<void> {
const child = runtime.process.child;
runtime.process.child = null;
runtime.input.end();
runtime.query.close?.();
await this.awaitWithTimeout(runtime.query.interrupt?.(), "close query interrupt");
await this.awaitWithTimeout(runtime.query.return?.(), "close query return");
// Claude's SDK cleanup does not own MCP children; retire the entire tree.
if (child) {
const result = await terminateWithTreeKill(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
});
if (result === "kill-timeout") {
this.logger.warn(
{ pid: child.pid, agentId: this.agentId },
"Claude process tree did not report exit after SIGKILL",
);
}
}
// Do not kick off background control-plane queries here. Methods like
// supportedCommands()/setPermissionMode() may execute immediately after
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
// control plane can cause those calls to wait behind supportedModels().
return this.query;
}
private async awaitWithTimeout(
@@ -3356,17 +3359,6 @@ class ClaudeAgentSession implements AgentSession {
const sdkEnv = this.harnessEnvironment;
assertClaudeModeCanRun(permissionMode, sdkEnv);
const claudeBinary = await this.resolveBinary();
this.logger.debug(
{
claudeBinary,
pathEnvKey: resolvePathEnvKey(),
pathIncludesClaudeLocalBin: (process.env["Path"] ?? process.env["PATH"] ?? "")
.toLowerCase()
.includes("\\.local\\bin"),
},
"Resolved Claude executable",
);
const sessionBinding: Pick<ClaudeOptions, "resume" | "sessionId"> = {};
if (this.pendingFreshSessionId) {
sessionBinding.sessionId = this.pendingFreshSessionId;
@@ -3384,7 +3376,6 @@ class ClaudeAgentSession implements AgentSession {
allowDangerouslySkipPermissions: true,
agents: this.defaults?.agents,
canUseTool: this.handlePermissionRequest,
pathToClaudeCodeExecutable: claudeBinary,
// Use Claude Code preset system prompt and load CLAUDE.md files
// Append provider-agnostic system prompts for agents.
systemPrompt: {
@@ -3434,7 +3425,18 @@ class ClaudeAgentSession implements AgentSession {
...this.runtimeSettings.disallowedTools,
];
}
return base;
const claudeBinary = await this.resolveBinary();
this.logger.debug(
{
claudeBinary,
pathEnvKey: resolvePathEnvKey(),
pathIncludesClaudeLocalBin: (process.env["Path"] ?? process.env["PATH"] ?? "")
.toLowerCase()
.includes("\\.local\\bin"),
},
"Resolved Claude executable",
);
return { pathToClaudeCodeExecutable: claudeBinary, ...base };
}
private buildSettingsOptions(
@@ -3690,8 +3692,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.notifySubscribers(event);
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.activeTurnRuntime = null;
this.cancelCurrentTurn = null;
this.activeTurnHasAssistantText = false;
this.compactionMarkerOpen = false;
@@ -3709,15 +3710,13 @@ class ClaudeAgentSession implements AgentSession {
this.compactionMarkerOpen = false;
if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.activeTurnRuntime = null;
this.cancelCurrentTurn = null;
this.activeTurnHasAssistantText = false;
this.syncTurnState("foreground turn terminal");
} else if (this.autonomousTurn) {
this.autonomousTurn = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.activeTurnRuntime = null;
this.activeTurnHasAssistantText = false;
this.syncTurnState("autonomous turn terminal");
}
@@ -3731,8 +3730,7 @@ class ClaudeAgentSession implements AgentSession {
this.autonomousTurn = {
id: this.createTurnId("autonomous"),
};
this.activeForegroundQuery = this.query;
this.activeForegroundInput = this.input;
this.activeTurnRuntime = this.runtime;
this.activeTurnHasAssistantText = false;
this.contextUsage.beginTurn();
this.notifySubscribers({ type: "turn_started", provider: "claude" });
@@ -3745,8 +3743,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
this.autonomousTurn = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.activeTurnRuntime = null;
this.activeTurnHasAssistantText = false;
this.compactionMarkerOpen = false;
this.syncTurnState("autonomous turn completed");
@@ -3774,10 +3771,11 @@ class ClaudeAgentSession implements AgentSession {
code: number | null,
signal: NodeJS.Signals | null,
): void {
if (this.closed || this.childProcess !== child) {
const runtime = this.runtime;
if (this.closed || !runtime || runtime.process.child !== child) {
return;
}
this.childProcess = null;
runtime.process.child = null;
this.logger.warn(
{ agentId: this.agentId, pid: child.pid, code, signal },
"Claude runtime exited unexpectedly",
@@ -3786,13 +3784,19 @@ class ClaudeAgentSession implements AgentSession {
if (this.activeForegroundTurnId || this.autonomousTurn) {
// The pump is about to throw. It waits for stderr to flush and reports the
// real cause; reporting here first would replace that with a bare exit code
// and detach this.query, which is how the pump recognizes its own stream.
// and detach the runtime, which is how the pump recognizes its own stream.
return;
}
// Drop the dead handles so the next write spawns a fresh process instead of
// failing against a dead transport forever.
this.query = null;
this.input = null;
void this.disposeRuntime(runtime)
.then(() => {
if (this.runtime === runtime) this.runtime = null;
return undefined;
})
.catch((error) => {
this.logger.warn({ err: error }, "Failed to retire exited Claude runtime");
});
this.dispatchEvents([
this.buildTurnFailedEvent(
`Claude stopped unexpectedly (${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}). Any background shells, monitors or other work it had running were terminated with it.`,
@@ -3808,12 +3812,12 @@ class ClaudeAgentSession implements AgentSession {
);
}
private startQueryPump(): void {
if (this.closed || this.queryPumpPromise) {
private startQueryPump(runtime: ClaudeRuntime): void {
if (this.closed || runtime.pump) {
return;
}
const pump = this.runQueryPump().catch((error) => {
const pump = this.runQueryPump(runtime).catch((error) => {
this.logger.trace(
{
agentId: this.agentId,
@@ -3826,33 +3830,16 @@ class ClaudeAgentSession implements AgentSession {
);
});
this.queryPumpPromise = pump;
runtime.pump = pump;
void pump.finally(() => {
if (this.queryPumpPromise === pump) {
this.queryPumpPromise = null;
if (runtime.pump === pump) {
runtime.pump = null;
}
});
}
private async runQueryPump(): Promise<void> {
let activeQuery: Query;
try {
activeQuery = await this.ensureQuery();
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.init_failed",
);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return;
}
private async runQueryPump(runtime: ClaudeRuntime): Promise<void> {
const activeQuery = runtime.query;
let consecutiveInterruptAbortRecoveries = 0;
const logRawMessage = (message: SDKMessage): void => {
this.logger.trace(
@@ -3870,9 +3857,10 @@ class ClaudeAgentSession implements AgentSession {
);
};
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
if (this.runtime !== runtime) return true;
logRawMessage(message);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) {
if (await this.handleMissingResumedConversation(message, runtime)) {
return true;
}
await this.routeSdkMessageFromPump(message);
@@ -3887,19 +3875,19 @@ class ClaudeAgentSession implements AgentSession {
return false;
};
try {
while (!this.closed && this.query === activeQuery) {
while (!this.closed && this.runtime === runtime) {
try {
if (await drainActiveQuery()) {
return;
}
if (!this.closed && this.query === activeQuery) {
if (!this.closed && this.runtime === runtime) {
this.failActiveTurns("Claude stream ended before terminal result");
}
return;
} catch (error) {
if (
!this.closed &&
this.query === activeQuery &&
this.runtime === runtime &&
this.shouldRecoverInterruptedQueryAbort(error, consecutiveInterruptAbortRecoveries)
) {
consecutiveInterruptAbortRecoveries += 1;
@@ -3909,7 +3897,7 @@ class ClaudeAgentSession implements AgentSession {
);
continue;
}
if (!this.closed && this.query === activeQuery) {
if (!this.closed && this.runtime === runtime) {
await this.awaitRecentStderrAfterProcessExit(error);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
}
@@ -3917,10 +3905,8 @@ class ClaudeAgentSession implements AgentSession {
}
}
} finally {
if (this.query === activeQuery) {
this.query = null;
this.input = null;
}
await this.disposeRuntime(runtime);
if (this.runtime === runtime) this.runtime = null;
}
}
@@ -4092,7 +4078,7 @@ class ClaudeAgentSession implements AgentSession {
private async handleMissingResumedConversation(
message: SDKMessage,
activeQuery: Query,
runtime: ClaudeRuntime,
): Promise<boolean> {
const staleResumeError = this.readMissingResumedConversationError(message);
if (!staleResumeError) {
@@ -4107,29 +4093,8 @@ class ClaudeAgentSession implements AgentSession {
);
this.failActiveTurns(staleResumeError);
// Ending the input retires the process on purpose. Detach first so its exit
// is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
this.input?.end();
await this.awaitWithTimeout(
activeQuery.return?.(),
"query pump return on missing resumed conversation",
);
// Tree-kill for the same reason the restart path does: MCP children of the
// retired claude process outlive it otherwise.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
if (this.query === activeQuery) {
this.query = null;
this.input = null;
}
// Clear the conversation before yielding to teardown, so a later request
// cannot resume a session that has already been rejected.
this.persistence = null;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
@@ -4138,9 +4103,10 @@ class ClaudeAgentSession implements AgentSession {
this.queryRestartNeeded = false;
this.autonomousTurn = null;
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.activeTurnRuntime = null;
this.syncTurnState("missing resumed conversation");
await this.disposeRuntime(runtime);
if (this.runtime === runtime) this.runtime = null;
return true;
}
@@ -4151,7 +4117,7 @@ class ClaudeAgentSession implements AgentSession {
* perTaskStopAffordance), and no result would follow to consume pendingInterruptAbort.
*/
private async interruptActiveTurn(claudeStartedTurn = this.mainTurnInFlight): Promise<void> {
const queryToInterrupt = this.query;
const queryToInterrupt = this.runtime?.query;
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
this.logger.trace(
{
@@ -5221,11 +5187,26 @@ class ClaudeAgentSession implements AgentSession {
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
return claudeTranscriptPathSync({
cwd,
sessionId,
configDir: claudeConfigDir(this.harnessEnvironment),
});
const configDir = claudeConfigDir(this.harnessEnvironment);
const candidates = [cwd];
try {
const realCwd = fs.realpathSync(cwd);
if (realCwd !== cwd) {
candidates.push(realCwd);
}
} catch {
// Fall back to the configured cwd when the path has already disappeared.
}
for (const candidate of candidates) {
const historyPath = path.join(
claudeProjectDirSync(candidate, { configDir }),
`${sessionId}.jsonl`,
);
if (fs.existsSync(historyPath)) {
return historyPath;
}
}
return path.join(claudeProjectDirSync(cwd, { configDir }), `${sessionId}.jsonl`);
}
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { realpathSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -30,46 +30,6 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti
return join(projectsRoot, encode(canonical));
}
// Claude Code resumes a session by id from any working directory and keeps appending to the
// transcript in the project folder where the session started. That folder differs from the
// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the
// cwd under another name (a Windows binary under WSL). Returns the expected path when no
// transcript exists anywhere.
export function claudeTranscriptPathSync(input: {
cwd: string;
sessionId: string;
configDir?: string;
}): string {
const options = { configDir: input.configDir };
const fileName = `${input.sessionId}.jsonl`;
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
if (existsSync(expected)) {
return expected;
}
const projectsRoot = join(resolveConfigDir(options), "projects");
let projectDirs: string[];
try {
projectDirs = readdirSync(projectsRoot);
} catch (error) {
if (isMissingDirectoryError(error)) {
return expected;
}
throw error;
}
for (const projectDir of projectDirs) {
const candidate = join(projectsRoot, projectDir, fileName);
if (existsSync(candidate)) {
return candidate;
}
}
return expected;
}
function isMissingDirectoryError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === "ENOENT" || code === "ENOTDIR";
}
async function canonicalize(input: string): Promise<string> {
try {
return normalizeProjectPath(await realpath(input));
@@ -1,130 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { claudeProjectDirSync } from "../agent/providers/claude/project-dir.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
// Claude Code resumes a session by id from any working directory and keeps writing to the
// transcript under the project folder it started in. The agent's cwd can name a different
// folder: a project that moved, a daemon migrated between machines or containers, or Claude
// Code running under WSL interop and seeing the cwd as a Windows path.
// The test daemon fakes the built-in providers; a provider that extends `claude` runs the real one.
const PROVIDER_ID = "claude-real";
const SESSION_ID = "6f0c5d8e-4c3a-4f7b-9a51-2d8e1b7c9f10";
function timelineText(entries: ReadonlyArray<{ item: { type: string; text?: string } }>): string {
return entries
.filter(
(
entry,
): entry is {
item: { type: "user_message" | "assistant_message"; text: string };
} => entry.item.type === "user_message" || entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
}
describe("daemon E2E - Claude history stored under another project folder", () => {
let tempRoot: string;
let paseoHomeRoot: string;
let configDir: string;
let transcriptCwd: string;
let agentCwd: string;
let daemon: TestPaseoDaemon | undefined;
let client: DaemonClient | undefined;
beforeEach(() => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-history-other-project-"));
paseoHomeRoot = path.join(tempRoot, "paseo-home");
configDir = path.join(tempRoot, "claude-config");
transcriptCwd = path.join(tempRoot, "where-claude-started");
agentCwd = path.join(tempRoot, "agent-cwd");
mkdirSync(paseoHomeRoot, { recursive: true });
mkdirSync(agentCwd, { recursive: true });
const projectDir = claudeProjectDirSync(transcriptCwd, { configDir });
mkdirSync(projectDir, { recursive: true });
writeFileSync(
path.join(projectDir, `${SESSION_ID}.jsonl`),
[
{
type: "user",
uuid: "user-uuid-1",
sessionId: SESSION_ID,
cwd: transcriptCwd,
message: { role: "user", content: "hello from the original folder" },
},
{
type: "assistant",
sessionId: SESSION_ID,
cwd: transcriptCwd,
message: {
role: "assistant",
content: "reply from the original folder",
},
},
]
.map((entry) => `${JSON.stringify(entry)}\n`)
.join(""),
"utf8",
);
});
afterEach(async () => {
await client?.close().catch(() => undefined);
await daemon?.close().catch(() => undefined);
client = undefined;
daemon = undefined;
rmSync(tempRoot, { recursive: true, force: true });
}, 60_000);
async function startDaemon(): Promise<DaemonClient> {
daemon = await createTestPaseoDaemon({
paseoHomeRoot,
cleanup: false,
providerOverrides: {
[PROVIDER_ID]: {
extends: "claude",
label: "Claude",
env: { CLAUDE_CONFIG_DIR: configDir },
},
},
});
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: {} });
return client;
}
async function stopDaemon(): Promise<void> {
await client?.close();
await daemon?.close();
client = undefined;
daemon = undefined;
}
test("an agent's timeline survives a daemon restart", async () => {
const firstClient = await startDaemon();
const agent = await firstClient.importAgent({
provider: PROVIDER_ID,
sessionId: SESSION_ID,
cwd: agentCwd,
});
await stopDaemon();
const restartedClient = await startDaemon();
const timeline = await restartedClient.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const text = timelineText(timeline.entries);
expect(text).toContain("hello from the original folder");
expect(text).toContain("reply from the original folder");
}, 60_000);
});
@@ -60,7 +60,7 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
}
});
test("changing thinking effort preserves the previous conversation", async () => {
test("concurrent command discovery and thinking changes preserve replies and conversation", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
@@ -88,10 +88,13 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
model: model.id,
});
await client.sendMessage(
agent.id,
"Remember the code phrase PASEO_MEMORY_56. Reply exactly: ACK_56",
);
await Promise.all([
client.listCommands(agent.id),
client.sendMessage(
agent.id,
"Remember the code phrase PASEO_MEMORY_56. Reply exactly: ACK_56",
),
]);
const firstFinish = await client.waitForFinish(agent.id, 180_000);
expect(firstFinish.status).toBe("idle");
expect(firstFinish.final?.lastError).toBeUndefined();
@@ -99,16 +102,25 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
await client.setAgentThinkingOption(agent.id, "low");
await client.sendMessage(
agent.id,
"What code phrase did I ask you to remember? Reply exactly with that code phrase and nothing else.",
);
await Promise.all([
client.listCommands(agent.id),
client.sendMessage(
agent.id,
"What code phrase did I ask you to remember? Reply exactly with that code phrase and nothing else.",
),
]);
const secondFinish = await client.waitForFinish(agent.id, 180_000);
expect(secondFinish.status).toBe("idle");
expect(secondFinish.final?.lastError).toBeUndefined();
const assistantText = await getAssistantText(client, agent.id);
expect(compactText(assistantText)).toContain("paseo_memory_56");
await client.sendMessage(agent.id, "Reply exactly: FOLLOWUP_56");
const followup = await client.waitForFinish(agent.id, 180_000);
expect(followup.status).toBe("idle");
expect(followup.final?.lastError).toBeUndefined();
expect(compactText(await getAssistantText(client, agent.id))).toContain("followup_56");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
@@ -1,120 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import { afterAll, beforeAll, expect, test } from "vitest";
import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js";
import { fetchWorkspaceGitRemote } from "./workspace-git-fetch.js";
// Repro for #3335: a background fetch that outlives its timeout is SIGKILLed,
// and every killed attempt leaves a partial `tmp_pack_*` behind. The daemon
// retries every 180s, so a fetch that needs longer than the timeout grows
// `.git/objects/pack` without bound.
const FETCH_TIMEOUT_MS = 300;
const cleanupPaths: string[] = [];
let checkout: string;
function git(cwd: string, args: string[]): string {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0" },
}).trim();
}
beforeAll(() => {
const root = mkdtempSync(join(tmpdir(), "paseo-fetch-timeout-"));
cleanupPaths.push(root);
const upstream = join(root, "upstream.git");
const seed = join(root, "seed");
checkout = join(root, "checkout");
git(root, ["init", "-q", "--bare", "-b", "main", upstream]);
git(root, ["init", "-q", "-b", "main", seed]);
git(seed, [
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init",
]);
git(seed, ["push", "-q", upstream, "main"]);
git(root, ["clone", "-q", "--no-local", upstream, checkout]);
// Advance upstream with incompressible content so the next fetch takes
// longer than FETCH_TIMEOUT_MS to transfer. More than git's default
// `fetch.unpackLimit` (100 objects) so the fetch keeps a pack, like any
// large fetch does, instead of exploding into loose objects.
for (let i = 0; i < 200; i += 1) {
writeFileSync(join(seed, `blob-${i}.bin`), randomBytes(1024 * 1024));
}
git(seed, ["add", "."]);
git(seed, [
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"-m",
"large",
]);
git(seed, ["push", "-q", upstream, "main"]);
}, 120_000);
afterAll(() => {
for (const path of cleanupPaths.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
});
function packDirectory(): { tmpPacks: string[] } {
const entries = readdirSync(join(checkout, ".git", "objects", "pack"));
return { tmpPacks: entries.filter((entry) => entry.startsWith("tmp_pack_")) };
}
// The daemon's runner, with the fetch budget shortened from 120s so a local
// fetch outlives it the way a large repository's fetch outlives 120s.
const runWithShortFetchTimeout: RunGitCommand = (args, options) =>
runGitCommand(
args,
args[0] === "fetch" ? { ...options, timeout: FETCH_TIMEOUT_MS } : options
);
test("a background fetch that times out leaves no partial pack behind", async () => {
const before = packDirectory();
const observations: { error: string | null; tmpPacks: number }[] = [];
for (let attempt = 0; attempt < 3; attempt += 1) {
const result = await fetchWorkspaceGitRemote(
checkout,
{ onRefSnapshot() {} },
runWithShortFetchTimeout
);
const after = packDirectory();
observations.push({
error: result.error instanceof Error ? result.error.message : null,
tmpPacks: after.tmpPacks.length,
});
}
expect(observations).toEqual([
{
error: expect.stringContaining("timed out"),
tmpPacks: before.tmpPacks.length,
},
{
error: expect.stringContaining("timed out"),
tmpPacks: before.tmpPacks.length,
},
{
error: expect.stringContaining("timed out"),
tmpPacks: before.tmpPacks.length,
},
]);
}, 60_000);
+9 -8
View File
@@ -41,14 +41,14 @@ test("browses from the directory into a category, a plugin, and its author", asy
await page.getByRole("link", { name: /Fresh Worktrees/ }).click();
await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/);
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toHaveCount(1);
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1);
await expect(
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Copy to clipboard" }).click();
await expect
.poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied))
.toBe("paseo plugin add omercnet/fresh-worktrees");
.toBe("paseo plugin install omercnet/fresh-worktrees");
await expect(page.getByRole("link", { name: "Git", exact: true })).toHaveAttribute(
"href",
"/plugins/category/git",
@@ -242,7 +242,7 @@ test.describe("search engine visits without JavaScript", () => {
expect(response?.status()).toBe(200);
expect(response?.headers()["cache-control"]).toBe("private, no-store");
expect(response?.headers()["x-robots-tag"]).toBeUndefined();
await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toBeVisible();
await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible();
await expect(
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
).toBeVisible();
@@ -388,18 +388,19 @@ async function expectPageMetadata(page: Page, title: string, path: string) {
);
}
test("links the directory from the site navigation", async ({ page }) => {
test("keeps the directory unlinked until the coordinated announcement", async ({ page }) => {
await page.goto("/");
await expect(
page.getByRole("navigation").getByRole("link", { name: "Plugins", exact: true }),
).toHaveAttribute("href", "/plugins");
page.getByRole("banner").getByRole("link", { name: "Plugins", exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }),
).toHaveAttribute("href", "/plugins");
).toHaveCount(0);
await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute(
"href",
"/plugins",
"https://paseo.cafe",
);
await expect(page.locator('a[href="/plugins"]')).toHaveCount(0);
const response = await page.goto("/plugins");
expect(response?.status()).toBe(200);
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"private": true,
"type": "module",
"scripts": {
@@ -19,7 +19,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
<footer className={`${widthClasses} mx-auto mt-12 md:mt-16`}>
<div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-5 gap-8 text-sm">
<div className="space-y-3">
<p className="text-extra-muted-foreground">Product</p>
<p className="text-white/60 font-medium">Product</p>
<div className="space-y-2">
<a
href="/blog"
@@ -80,7 +80,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
</div>
</div>
<div className="space-y-3">
<p className="text-extra-muted-foreground">Agents</p>
<p className="text-white/60 font-medium">Agents</p>
<div className="space-y-2">
<a
href="/claude-code"
@@ -121,7 +121,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
</div>
</div>
<div className="space-y-3">
<p className="text-extra-muted-foreground">Alternatives</p>
<p className="text-white/60 font-medium">Alternatives</p>
<div className="space-y-2">
{alternatives.map((page) => (
<a
@@ -135,7 +135,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
</div>
</div>
<div className="space-y-3">
<p className="text-extra-muted-foreground">Community</p>
<p className="text-white/60 font-medium">Community</p>
<div className="space-y-2">
<a
href="https://discord.gg/jz8T2uahpH"
@@ -164,7 +164,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
</div>
</div>
<div className="space-y-3">
<p className="text-extra-muted-foreground">Download</p>
<p className="text-white/60 font-medium">Download</p>
<div className="space-y-2">
<a
href={appStoreUrl}
+2 -1
View File
@@ -1 +1,2 @@
export const PLUGINS_LINKED = true;
// Keep navigation on paseo.cafe until the coordinated directory announcement.
export const PLUGINS_LINKED = false;
@@ -263,7 +263,7 @@ describe("plugin registry", () => {
);
});
it("offers a registry install command", () => {
expect(installCommand(plugin)).toBe("paseo plugin add acme/example");
expect(installCommand(plugin)).toBe("paseo plugin install acme/example");
expect(formatInstalls(1250)).toBe("1.3k");
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ export function getPluginsByAuthor(plugins: Plugin[], owner: string): Plugin[] {
return plugins.filter((plugin) => pluginOwner(plugin) === owner);
}
export function installCommand(plugin: Plugin): string {
return `paseo plugin add ${plugin.id}`;
return `paseo plugin install ${plugin.id}`;
}
export function pluginVersion(plugin: Plugin): string {
return plugin.artifact.kind === "npm"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.11.0",
"version": "0.11.0-beta.5",
"private": true,
"type": "module",
"scripts": {