mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3ba6a586c | ||
|
|
0c1447db91 | ||
|
|
1ec55a7e29 | ||
|
|
33eb36c021 | ||
|
|
0fc6bf0c94 | ||
|
|
2d55870490 | ||
|
|
ab10a6694c | ||
|
|
2a151a4494 | ||
|
|
3d857f90d2 | ||
|
|
9fcae41c98 | ||
|
|
b352dad2e3 | ||
|
|
1ae979d3d0 | ||
|
|
d46164ce0d | ||
|
|
a591320409 | ||
|
|
fed4d0cddd | ||
|
|
ee6deda079 | ||
|
|
0555c877d0 | ||
|
|
b6454d7ea4 | ||
|
|
1b88f16124 | ||
|
|
1fae8e89f7 | ||
|
|
22488d4502 | ||
|
|
2a5264c188 |
+48
-8
@@ -1,12 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 0.11.0-beta.5 - 2026-10-06
|
||||
## 0.11.1 - 2026-10-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308))
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316))
|
||||
- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301))
|
||||
|
||||
## 0.11.0 - 2026-10-07
|
||||
|
||||
Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry).
|
||||
|
||||
**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory.
|
||||
|
||||
### Added
|
||||
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
|
||||
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||
- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216))
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
||||
@@ -16,21 +32,26 @@
|
||||
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
|
||||
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
|
||||
- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
||||
- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1))
|
||||
- Added `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
|
||||
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
|
||||
- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224))
|
||||
- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-items) to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
||||
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
|
||||
- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151))
|
||||
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
|
||||
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
|
||||
- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263))
|
||||
- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226))
|
||||
|
||||
### Changed
|
||||
|
||||
- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps.
|
||||
|
||||
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
|
||||
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185))
|
||||
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot))
|
||||
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
|
||||
@@ -46,12 +67,18 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
|
||||
- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100))
|
||||
- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson))
|
||||
- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272))
|
||||
- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248))
|
||||
- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268))
|
||||
- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022))
|
||||
- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||
- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074))
|
||||
- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov))
|
||||
- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151))
|
||||
- Fixed ACP agents running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273))
|
||||
- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228))
|
||||
- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045))
|
||||
- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
|
||||
@@ -66,12 +93,14 @@
|
||||
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112))
|
||||
- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115))
|
||||
- Fixed reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215))
|
||||
- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061))
|
||||
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
|
||||
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
|
||||
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
|
||||
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
|
||||
- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul))
|
||||
- Fixed OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188))
|
||||
- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum))
|
||||
- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988))
|
||||
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
@@ -85,12 +114,18 @@
|
||||
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
|
||||
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
|
||||
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
|
||||
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172))
|
||||
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
|
||||
- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138))
|
||||
- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158))
|
||||
- Fixed copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195))
|
||||
- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968))
|
||||
- Fixed text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207))
|
||||
- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219))
|
||||
- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq))
|
||||
- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn))
|
||||
- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen))
|
||||
- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu))
|
||||
- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio))
|
||||
@@ -98,12 +133,16 @@
|
||||
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
|
||||
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
|
||||
- Fixed dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
|
||||
- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279))
|
||||
- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117))
|
||||
- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097))
|
||||
- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095))
|
||||
- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
|
||||
- Fixed a failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255))
|
||||
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
|
||||
- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart))
|
||||
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
|
||||
@@ -123,6 +162,7 @@
|
||||
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||
- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239))
|
||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
Paseo is a desktop, mobile, web, and CLI app for coding agents. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android.
|
||||
Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android.
|
||||
|
||||
- **Parallel agents:** Run many agents at once, each in its own worktree.
|
||||
- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers.
|
||||
- **Full IDE:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want.
|
||||
- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want.
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
|
||||
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
|
||||
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
|
||||
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
|
||||
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4=
|
||||
sha256-aoYgGgEkqeeZJR7OOcSHuZEhBELkpyOGqHo4oEx7W88=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36413,7 +36413,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37496,12 +37496,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/server": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.1",
|
||||
"@getpaseo/protocol": "0.11.1",
|
||||
"@getpaseo/server": "0.11.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37748,10 +37748,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.1",
|
||||
"@getpaseo/relay": "0.11.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37762,7 +37762,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38006,7 +38006,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38902,7 +38902,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38931,14 +38931,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.1",
|
||||
"@getpaseo/protocol": "0.11.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38948,8 +38948,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.1",
|
||||
"@getpaseo/protocol": "0.11.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38998,7 +38998,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39216,16 +39216,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/highlight": "0.11.0-beta.5",
|
||||
"@getpaseo/plugin": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.1",
|
||||
"@getpaseo/highlight": "0.11.1",
|
||||
"@getpaseo/plugin": "0.11.1",
|
||||
"@getpaseo/protocol": "0.11.1",
|
||||
"@getpaseo/relay": "0.11.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40146,7 +40146,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"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-beta.5",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
// A primary mouse click on a sidebar workspace row selects it (#3996).
|
||||
// Run with the app on a wide Android layout, sidebar open, against a real daemon
|
||||
// with at least two workspaces. The app must already run the checkout being verified.
|
||||
// The click comes from a virtual USB mouse, so Android sends the same DOWN,
|
||||
// BUTTON_PRESS, BUTTON_RELEASE, UP sequence as a physical touchpad click.
|
||||
const serial = process.env.ANDROID_SERIAL ?? "emulator-5554";
|
||||
const CLICK_HOLD_MS = 110;
|
||||
|
||||
function adb(...args) {
|
||||
return execFileSync("adb", ["-s", serial, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
function workspaceRows() {
|
||||
adb("shell", "uiautomator", "dump", "/sdcard/paseo-mouse-click.xml");
|
||||
const xml = adb("shell", "cat", "/sdcard/paseo-mouse-click.xml");
|
||||
return [...xml.matchAll(/<node [^>]*resource-id="sidebar-workspace-row-[^"]*"[^>]*>/g)].map(
|
||||
([node]) => {
|
||||
const [, left, top, right, bottom] = node.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
|
||||
return {
|
||||
id: node.match(/resource-id="([^"]*)"/)[1],
|
||||
selected: node.includes('selected="true"'),
|
||||
// The title line, clear of the PR and check badges on the second line.
|
||||
title: { x: +left + (right - left) / 4, y: +top + (bottom - top) / 3 },
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function cursorPosition() {
|
||||
const events = [
|
||||
...adb("shell", "dumpsys", "input").matchAll(
|
||||
/source=MOUSE.*xCursorPosition=([\d.]+), yCursorPosition=([\d.]+).*age=(\d+)ms/g,
|
||||
),
|
||||
];
|
||||
assert.ok(events.length > 0, "Android reported no mouse events");
|
||||
const [, x, y] = events.reduce((latest, event) => (+event[3] < +latest[3] ? event : latest));
|
||||
return { x: +x, y: +y };
|
||||
}
|
||||
|
||||
const MOUSE_NAME = "Paseo E2E Mouse";
|
||||
|
||||
async function openVirtualMouse() {
|
||||
const uinput = spawn("adb", ["-s", serial, "shell", "uinput", "-"], {
|
||||
stdio: ["pipe", "ignore", "inherit"],
|
||||
});
|
||||
// A mouse that never registered must fail as setup, not as a missed click.
|
||||
let failure = null;
|
||||
uinput.on("exit", (code, signal) => {
|
||||
failure ??= `uinput exited (code ${code}, signal ${signal})`;
|
||||
});
|
||||
uinput.on("error", (error) => {
|
||||
failure ??= `uinput failed to start: ${error.message}`;
|
||||
});
|
||||
uinput.stdin.on("error", (error) => {
|
||||
failure ??= `uinput stdin closed: ${error.message}`;
|
||||
});
|
||||
const send = (command) => {
|
||||
if (failure) throw new Error(failure);
|
||||
uinput.stdin.write(`${JSON.stringify({ id: 1, ...command })}\n`);
|
||||
};
|
||||
const inject = (...events) =>
|
||||
send({ command: "inject", events: [...events, "EV_SYN", "SYN_REPORT", 0] });
|
||||
send({
|
||||
command: "register",
|
||||
name: MOUSE_NAME,
|
||||
vid: 0x18d1,
|
||||
pid: 0x0001,
|
||||
bus: "usb",
|
||||
configuration: [
|
||||
{ type: "UI_SET_EVBIT", data: ["EV_KEY", "EV_REL"] },
|
||||
{ type: "UI_SET_KEYBIT", data: ["BTN_LEFT", "BTN_RIGHT", "BTN_MIDDLE"] },
|
||||
{ type: "UI_SET_RELBIT", data: ["REL_X", "REL_Y"] },
|
||||
],
|
||||
});
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!adb("shell", "dumpsys", "input").includes(MOUSE_NAME)) {
|
||||
if (failure) throw new Error(failure);
|
||||
if (Date.now() > deadline) throw new Error(`Android did not add ${MOUSE_NAME}`);
|
||||
await setTimeout(250);
|
||||
}
|
||||
return {
|
||||
// Pointer speed and acceleration scale relative motion, so steer by the
|
||||
// cursor position Android reports until it lands on the target.
|
||||
async moveTo(target) {
|
||||
inject("EV_REL", "REL_X", 1, "EV_REL", "REL_Y", 1);
|
||||
await setTimeout(150);
|
||||
for (let step = 0; step < 20; step += 1) {
|
||||
const cursor = cursorPosition();
|
||||
const dx = target.x - cursor.x;
|
||||
const dy = target.y - cursor.y;
|
||||
if (Math.abs(dx) <= 4 && Math.abs(dy) <= 4) return;
|
||||
const clamp = (delta) => Math.max(-60, Math.min(60, Math.round(delta / 3)));
|
||||
inject("EV_REL", "REL_X", clamp(dx), "EV_REL", "REL_Y", clamp(dy));
|
||||
await setTimeout(150);
|
||||
}
|
||||
throw new Error(`Cursor did not reach ${JSON.stringify(target)}`);
|
||||
},
|
||||
async click() {
|
||||
inject("EV_KEY", "BTN_LEFT", 1);
|
||||
await setTimeout(CLICK_HOLD_MS);
|
||||
inject("EV_KEY", "BTN_LEFT", 0);
|
||||
},
|
||||
close() {
|
||||
uinput.stdin.end();
|
||||
uinput.kill();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForSelected(id) {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (workspaceRows().find((row) => row.id === id)?.selected) return true;
|
||||
await setTimeout(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = workspaceRows().find((row) => !row.selected);
|
||||
assert.ok(target, "Open the sidebar with at least one unselected workspace row first");
|
||||
|
||||
const mouse = await openVirtualMouse();
|
||||
try {
|
||||
await mouse.moveTo(target.title);
|
||||
await mouse.click();
|
||||
assert.ok(await waitForSelected(target.id), `Primary mouse click did not select ${target.id}`);
|
||||
console.log(`PASS: primary mouse click selected ${target.id}`);
|
||||
} finally {
|
||||
mouse.close();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { Pressable, StyleSheet, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { Gesture, GestureDetector, PointerType } from "react-native-gesture-handler";
|
||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { resolveHighlightCorners } from "./press-highlight.shape";
|
||||
import type { PressHighlightProps } from "./press-highlight.types";
|
||||
@@ -20,6 +20,15 @@ export const PressHighlight = forwardRef<View, PressHighlightProps>(function Pre
|
||||
.enabled(disabled !== true && highlightStyle != null)
|
||||
.maxDistance(8)
|
||||
.shouldCancelWhenOutside(true)
|
||||
// A mouse never gets the touch glow. Left unchecked, this Tap activates on the primary
|
||||
// button release and cancels the Pressable's press, so the click never reaches onPress.
|
||||
// Android delivers the first touch-down before the Tap begins, and on the UI thread the
|
||||
// failure lands synchronously, before ACTION_BUTTON_PRESS or ACTION_BUTTON_RELEASE.
|
||||
.onTouchesDown((event, stateManager) => {
|
||||
if (event.pointerType === PointerType.MOUSE) {
|
||||
stateManager.fail();
|
||||
}
|
||||
})
|
||||
.onBegin(() => {
|
||||
highlighted.value = 1;
|
||||
})
|
||||
|
||||
@@ -50,7 +50,7 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
|
||||
import { useProviderSubagentStore } from "@/subagents/provider-store";
|
||||
import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -532,9 +532,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
});
|
||||
|
||||
const unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => {
|
||||
if (message.type !== "agent.provider_subagents.update") return;
|
||||
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
|
||||
const unsubProviderSubagentUpdate = feeds.subscribe({
|
||||
snapshot: () => {
|
||||
void resyncProviderSubagents(client, serverId);
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "agent.provider_subagents.update") return;
|
||||
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
|
||||
},
|
||||
});
|
||||
|
||||
const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
observeProviderSubagentTimeline,
|
||||
providerSubagentKey,
|
||||
refreshProviderSubagents,
|
||||
resyncProviderSubagents,
|
||||
useProviderSubagentStore,
|
||||
} from "./provider-store";
|
||||
|
||||
@@ -15,6 +18,7 @@ afterEach(() => {
|
||||
descriptors: new Map(),
|
||||
timelines: new Map(),
|
||||
hiddenFromTrack: new Set(),
|
||||
trackedParents: new Set(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -727,3 +731,96 @@ describe("projected child history", () => {
|
||||
expect(current().lastSeq).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resyncing after the update feed resubscribes", () => {
|
||||
function child(
|
||||
id: string,
|
||||
parentAgentId: string,
|
||||
status: ProviderSubagentDescriptorPayload["status"],
|
||||
): ProviderSubagentDescriptorPayload {
|
||||
return {
|
||||
id,
|
||||
parentAgentId,
|
||||
provider: "claude",
|
||||
title: "Explore",
|
||||
description: null,
|
||||
status,
|
||||
createdAt: "2026-10-07T10:00:00.000Z",
|
||||
updatedAt: "2026-10-07T10:00:00.000Z",
|
||||
toolCallId: id,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeDaemon(lists: Record<string, ProviderSubagentDescriptorPayload[]>) {
|
||||
const requested: string[] = [];
|
||||
return {
|
||||
requested,
|
||||
lists,
|
||||
client: {
|
||||
listProviderSubagents: async (parentAgentId: string) => {
|
||||
requested.push(parentAgentId);
|
||||
return {
|
||||
requestId: "list",
|
||||
parentAgentId,
|
||||
subagents: lists[parentAgentId] ?? [],
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const status = (parentAgentId: string, subagentId: string) =>
|
||||
useProviderSubagentStore
|
||||
.getState()
|
||||
.descriptors.get(providerSubagentKey(SERVER_ID, parentAgentId, subagentId))?.status;
|
||||
|
||||
test("catches up on children that finished or started while updates were not delivered", async () => {
|
||||
const daemon = fakeDaemon({ [PARENT_ID]: [child("child-1", PARENT_ID, "running")] });
|
||||
await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
|
||||
expect(status(PARENT_ID, "child-1")).toBe("running");
|
||||
|
||||
// The connection dropped: the daemon finished child-1 and ran child-2 without telling us.
|
||||
daemon.lists[PARENT_ID] = [
|
||||
child("child-1", PARENT_ID, "completed"),
|
||||
child("child-2", PARENT_ID, "completed"),
|
||||
];
|
||||
await resyncProviderSubagents(daemon.client, SERVER_ID);
|
||||
|
||||
expect(status(PARENT_ID, "child-1")).toBe("completed");
|
||||
expect(status(PARENT_ID, "child-2")).toBe("completed");
|
||||
});
|
||||
|
||||
test("catches up on a parent whose first list failed while disconnected", async () => {
|
||||
const daemon = fakeDaemon({});
|
||||
const disconnected = {
|
||||
listProviderSubagents: async () => {
|
||||
throw new DaemonConnectionError("Transport not connected");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
refreshProviderSubagents(disconnected, SERVER_ID, PARENT_ID),
|
||||
).rejects.toBeInstanceOf(DaemonConnectionError);
|
||||
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: child("child-1", PARENT_ID, "running"),
|
||||
});
|
||||
|
||||
// The connection dropped again: the daemon finished child-1 without telling us.
|
||||
daemon.lists[PARENT_ID] = [child("child-1", PARENT_ID, "completed")];
|
||||
await resyncProviderSubagents(daemon.client, SERVER_ID);
|
||||
|
||||
expect(status(PARENT_ID, "child-1")).toBe("completed");
|
||||
});
|
||||
|
||||
test("lists again only the parents this server was showing", async () => {
|
||||
const daemon = fakeDaemon({});
|
||||
await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
|
||||
await refreshProviderSubagents(daemon.client, "server-2", "other-parent");
|
||||
daemon.requested.length = 0;
|
||||
|
||||
await resyncProviderSubagents(daemon.client, SERVER_ID);
|
||||
|
||||
expect(daemon.requested).toEqual([PARENT_ID]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,9 @@ interface ProviderSubagentState {
|
||||
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
|
||||
timelines: Map<string, ProviderSubagentTimelineState>;
|
||||
hiddenFromTrack: Set<string>;
|
||||
/** Parents whose list this client asked for, so a resubscribed update feed can catch them up. */
|
||||
trackedParents: Set<string>;
|
||||
trackParent(serverId: string, parentAgentId: string): void;
|
||||
hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void;
|
||||
replaceList(
|
||||
serverId: string,
|
||||
@@ -84,6 +87,7 @@ export function refreshProviderSubagents(
|
||||
const pending = clientRequests.get(requestKey);
|
||||
if (pending) return pending;
|
||||
|
||||
useProviderSubagentStore.getState().trackParent(serverId, parentAgentId);
|
||||
const request = client
|
||||
.listProviderSubagents(parentAgentId)
|
||||
.then((payload) => {
|
||||
@@ -101,6 +105,27 @@ function parentPrefix(serverId: string, parentAgentId: string): string {
|
||||
return `${serverId}\0${parentAgentId}\0`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates are not replayed across a reconnect, so a child that finished or started while the
|
||||
* update feed was down stays stale until its parent is listed again. Call this once the feed has
|
||||
* resubscribed, so every update after the list is delivered.
|
||||
*/
|
||||
export async function resyncProviderSubagents(
|
||||
client: ProviderSubagentListClient,
|
||||
serverId: string,
|
||||
): Promise<void> {
|
||||
const serverPrefix = `${serverId}\0`;
|
||||
const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents]
|
||||
.filter((key) => key.startsWith(serverPrefix))
|
||||
.map((key) => key.slice(serverPrefix.length, -1));
|
||||
await Promise.all(
|
||||
parentAgentIds.map((parentAgentId) =>
|
||||
// A parent that was archived meanwhile has nothing left to catch up.
|
||||
refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_TIMELINE: ProviderSubagentTimelineState = {
|
||||
tail: [],
|
||||
head: [],
|
||||
@@ -147,6 +172,14 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
descriptors: new Map(),
|
||||
timelines: new Map(),
|
||||
hiddenFromTrack: new Set(),
|
||||
trackedParents: new Set(),
|
||||
trackParent(serverId, parentAgentId) {
|
||||
set((state) => {
|
||||
const prefix = parentPrefix(serverId, parentAgentId);
|
||||
if (state.trackedParents.has(prefix)) return state;
|
||||
return { trackedParents: new Set(state.trackedParents).add(prefix) };
|
||||
});
|
||||
},
|
||||
hideFromTrack(serverId, parentAgentId, subagentIds) {
|
||||
set((state) => {
|
||||
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/server": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.1",
|
||||
"@getpaseo/protocol": "0.11.1",
|
||||
"@getpaseo/server": "0.11.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -122,10 +122,15 @@ const EXPECTED_CLAUDE_MODELS = [
|
||||
model: "Opus 4.6",
|
||||
descriptionFragment: "Most capable",
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-5-5",
|
||||
model: "Haiku 5.5",
|
||||
descriptionFragment: "Fastest",
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-4-5",
|
||||
model: "Haiku 4.5",
|
||||
descriptionFragment: "Fastest",
|
||||
descriptionFragment: "Previous release",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -435,8 +440,8 @@ try {
|
||||
"--quiet should print the same ordered model IDs returned by --json",
|
||||
);
|
||||
assert(
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"),
|
||||
"captured --json output should include the current Claude everyday model id",
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-haiku-5-5"),
|
||||
"captured --json output should include the current Claude quick-answer model id",
|
||||
);
|
||||
console.log("✓ provider models --quiet outputs model IDs only\n");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.1",
|
||||
"@getpaseo/relay": "0.11.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -70,8 +70,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.1",
|
||||
"@getpaseo/protocol": "0.11.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -81,8 +81,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.1",
|
||||
"@getpaseo/protocol": "0.11.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"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-beta.5",
|
||||
"@getpaseo/highlight": "0.11.0-beta.5",
|
||||
"@getpaseo/plugin": "0.11.0-beta.5",
|
||||
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||
"@getpaseo/relay": "0.11.0-beta.5",
|
||||
"@getpaseo/client": "0.11.1",
|
||||
"@getpaseo/highlight": "0.11.1",
|
||||
"@getpaseo/plugin": "0.11.1",
|
||||
"@getpaseo/protocol": "0.11.1",
|
||||
"@getpaseo/relay": "0.11.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import pino from "pino";
|
||||
@@ -90,4 +90,29 @@ 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") }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
-454
@@ -1,10 +1,3 @@
|
||||
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";
|
||||
@@ -283,453 +276,6 @@ 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,6 +143,10 @@ 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;
|
||||
@@ -312,7 +316,7 @@ describe("ClaudeAgentSession integration", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("interrupts a running Bash turn and continues the same conversation", async () => {
|
||||
test("interrupts a running Bash turn and continues on the same query", async () => {
|
||||
const handle = await createSession({
|
||||
cwdPrefix: "claude-agent-interrupt-continue-",
|
||||
});
|
||||
@@ -336,9 +340,9 @@ describe("ClaudeAgentSession integration", () => {
|
||||
event.item.name.toLowerCase() === "bash",
|
||||
45_000,
|
||||
);
|
||||
const firstSessionId = (await handle.session.getRuntimeInfo()).sessionId;
|
||||
const firstQuery = getInternalQuery(handle.session);
|
||||
|
||||
expect(firstSessionId).toEqual(expect.any(String));
|
||||
expect(firstQuery).toBeTruthy();
|
||||
|
||||
await handle.session.interrupt();
|
||||
|
||||
@@ -356,9 +360,9 @@ describe("ClaudeAgentSession integration", () => {
|
||||
const followUpEvents = await collectUntilTerminal(
|
||||
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
|
||||
);
|
||||
const secondSessionId = (await handle.session.getRuntimeInfo()).sessionId;
|
||||
const secondQuery = getInternalQuery(handle.session);
|
||||
|
||||
expect(secondSessionId).toBe(firstSessionId);
|
||||
expect(secondQuery).toBe(firstQuery);
|
||||
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
|
||||
expect(followUpEvents.at(-1)).toMatchObject({
|
||||
type: "turn_completed",
|
||||
|
||||
@@ -478,23 +478,31 @@ 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);
|
||||
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;
|
||||
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;
|
||||
|
||||
try {
|
||||
await session.interrupt();
|
||||
await internal.interruptActiveTurn();
|
||||
|
||||
const interruptInfoMessages = extractStringLogArgs(spy.info.mock.calls).filter((message) =>
|
||||
message.includes("interruptActiveTurn"),
|
||||
@@ -505,11 +513,12 @@ test("interruptActiveTurn only interrupts the active query without info logs", a
|
||||
|
||||
expect(interruptInfoMessages).toEqual([]);
|
||||
expect(interruptDebugMessages).toEqual([]);
|
||||
expect(activeQuery.interrupt).toHaveBeenCalledTimes(1);
|
||||
expect(activeQuery.return).not.toHaveBeenCalled();
|
||||
expect(activeQuery.close).not.toHaveBeenCalled();
|
||||
await session.listCommands!();
|
||||
expect(sdkQueryFactory).toHaveBeenCalledTimes(1);
|
||||
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);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
@@ -587,4 +587,75 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -496,7 +496,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
resolveVersion: async () => "2.1.284",
|
||||
resolveVersion: async () => "2.1.293",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
@@ -515,6 +515,15 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
|
||||
expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-haiku-5-5")).toEqual([
|
||||
"off",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"ultracode",
|
||||
]);
|
||||
expect(getThinkingIds("claude-sonnet-5-5")).toContain("xhigh");
|
||||
expect(getThinkingIds("claude-sonnet-5-5")).not.toContain("off");
|
||||
expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode");
|
||||
@@ -1223,6 +1232,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
|
||||
test.each([
|
||||
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
|
||||
["Haiku 5.5", "claude-haiku-5-5", { type: "disabled" }, undefined],
|
||||
["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"],
|
||||
["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined],
|
||||
["provider default", null, undefined, undefined],
|
||||
|
||||
@@ -84,7 +84,7 @@ import {
|
||||
type ClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js";
|
||||
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
|
||||
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||
import {
|
||||
isProviderImageMarkdown,
|
||||
@@ -1916,6 +1916,11 @@ 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;
|
||||
}
|
||||
@@ -2057,14 +2062,6 @@ 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;
|
||||
@@ -2078,12 +2075,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private readonly logger: Logger;
|
||||
private readonly queryFactory?: ClaudeQueryFactory;
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
private runtime: ClaudeRuntime | null = null;
|
||||
private runtimeStarting: Promise<ClaudeRuntime> | null = null;
|
||||
private query: Query | null = null;
|
||||
private readonly harnessEnvironment: Record<string, string>;
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
/** The runtime that owns the foreground or autonomous turn. */
|
||||
private activeTurnRuntime: ClaudeRuntime | null = null;
|
||||
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;
|
||||
/**
|
||||
* 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
|
||||
@@ -2139,6 +2138,7 @@ 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,15 +2324,19 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.notifySubscribers({ type: "turn_started", provider: "claude" });
|
||||
|
||||
try {
|
||||
const runtime = await this.ensureRuntime();
|
||||
if (this.closed || cancelIssued || this.activeForegroundTurnId !== turnId) {
|
||||
// Stopped during startup: withdraw this turn's message by never sending it.
|
||||
await this.ensureQuery();
|
||||
if (cancelIssued) {
|
||||
// Stopped while Claude was still starting up: withdrawn by never sending it.
|
||||
if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId);
|
||||
return { turnId };
|
||||
}
|
||||
this.activeTurnRuntime = runtime;
|
||||
runtime.input.push(sdkMessage);
|
||||
this.startQueryPump(runtime);
|
||||
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);
|
||||
setTimeout(() => {
|
||||
if (this.activeForegroundTurnId === turnId) {
|
||||
this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId);
|
||||
@@ -2340,13 +2344,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId);
|
||||
if (this.activeForegroundTurnId === turnId) {
|
||||
this.finishForegroundTurn(
|
||||
this.buildTurnFailedEvent(
|
||||
error instanceof Error ? error.message : "Claude stream failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
this.finishForegroundTurn(
|
||||
this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"),
|
||||
);
|
||||
}
|
||||
|
||||
return { turnId };
|
||||
@@ -2366,20 +2366,23 @@ 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 runtime = this.activeTurnRuntime;
|
||||
if (!runtime || this.runtime !== runtime) {
|
||||
const query = this.activeForegroundQuery;
|
||||
const input = this.activeForegroundInput;
|
||||
if (!query || !input || this.query !== query || this.input !== input) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const message = this.toSdkUserMessage(prompt);
|
||||
message.priority = "next";
|
||||
if (
|
||||
(this.activeForegroundTurnId ?? this.autonomousTurn?.id) !== options.expectedTurnId ||
|
||||
this.activeTurnRuntime !== runtime ||
|
||||
this.runtime !== runtime
|
||||
this.activeForegroundQuery !== query ||
|
||||
this.activeForegroundInput !== input ||
|
||||
this.query !== query ||
|
||||
this.input !== input
|
||||
) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
this.enqueueSteer(runtime.input, message, options.clearPendingPermissions === true);
|
||||
this.enqueueSteer(input, message, options.clearPendingPermissions === true);
|
||||
return { status: "accepted" };
|
||||
}
|
||||
|
||||
@@ -2471,7 +2474,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
||||
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
|
||||
const previousMode = this.currentMode;
|
||||
const launchesQuery = !this.runtime || this.queryRestartNeeded;
|
||||
const launchesQuery = !this.query || this.queryRestartNeeded;
|
||||
const activeQuery = await this.ensureQuery(normalized);
|
||||
try {
|
||||
await activeQuery.setPermissionMode(normalized);
|
||||
@@ -2570,7 +2573,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
...this.config.featureValues,
|
||||
fast_mode: enabled,
|
||||
};
|
||||
const activeQuery = query ?? this.runtime?.query;
|
||||
const activeQuery = query ?? this.query;
|
||||
if (activeQuery) {
|
||||
await activeQuery.applyFlagSettings({ fastMode: enabled });
|
||||
}
|
||||
@@ -2733,8 +2736,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
sessionId: this.claudeSessionId,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
|
||||
turnState: this.turnState,
|
||||
hasQuery: Boolean(this.runtime),
|
||||
hasInput: Boolean(this.runtime),
|
||||
hasQuery: Boolean(this.query),
|
||||
hasInput: Boolean(this.input),
|
||||
hasActiveForegroundTurnId: Boolean(this.activeForegroundTurnId),
|
||||
},
|
||||
"provider.claude.session_close.start",
|
||||
@@ -2744,17 +2747,35 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.cancelCurrentTurn?.();
|
||||
this.subscribers.clear();
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeTurnRuntime = null;
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.autonomousTurn = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.turnState = "idle";
|
||||
this.sidechainTracker.clear();
|
||||
this.taskProtocolSource.reset();
|
||||
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.
|
||||
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;
|
||||
}
|
||||
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
|
||||
@@ -2968,7 +2989,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private async ensureFreshQuery(): Promise<Query> {
|
||||
if (this.runtime) {
|
||||
if (this.query) {
|
||||
this.queryRestartNeeded = true;
|
||||
}
|
||||
return this.ensureQuery();
|
||||
@@ -3148,99 +3169,75 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<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.query && !this.queryRestartNeeded) {
|
||||
return this.query;
|
||||
}
|
||||
|
||||
const starting = this.createRuntime(launchMode).finally(() => {
|
||||
if (this.runtimeStarting === starting) this.runtimeStarting = null;
|
||||
});
|
||||
this.runtimeStarting = starting;
|
||||
return starting;
|
||||
}
|
||||
|
||||
private async createRuntime(launchMode: PermissionMode): Promise<ClaudeRuntime> {
|
||||
const retired = this.runtime;
|
||||
this.runtime = null;
|
||||
if (retired) {
|
||||
this.failRunningRuntimeTasks();
|
||||
await this.disposeRuntime(retired);
|
||||
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 */
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
// Preserve claudeSessionId across query recreation so buildOptions() passes
|
||||
// resume: sessionId and the new query continues the existing conversation.
|
||||
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;
|
||||
const input = createAsyncMessageInput<SDKUserMessage>();
|
||||
const process: ClaudeRuntime["process"] = { child: null };
|
||||
const query = claudeQuery(
|
||||
this.query = claudeQuery(
|
||||
{ prompt: input.iterable, options },
|
||||
{
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
launchEnv: this.launchEnv,
|
||||
queryFactory: this.queryFactory,
|
||||
onChildProcess: (child) => {
|
||||
process.child = child;
|
||||
this.childProcess = child;
|
||||
child.once("exit", (code, signal) => this.handleRuntimeExit(child, code, signal));
|
||||
},
|
||||
},
|
||||
);
|
||||
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",
|
||||
);
|
||||
}
|
||||
const fastMode = this.resolveFastModeSetting();
|
||||
if (fastMode !== null) {
|
||||
await this.query.applyFlagSettings({ fastMode });
|
||||
}
|
||||
// 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(
|
||||
@@ -3359,6 +3356,17 @@ 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;
|
||||
@@ -3376,6 +3384,7 @@ 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: {
|
||||
@@ -3425,18 +3434,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
...this.runtimeSettings.disallowedTools,
|
||||
];
|
||||
}
|
||||
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 };
|
||||
return base;
|
||||
}
|
||||
|
||||
private buildSettingsOptions(
|
||||
@@ -3692,7 +3690,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.notifySubscribers(event);
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeTurnRuntime = null;
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.compactionMarkerOpen = false;
|
||||
@@ -3710,13 +3709,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.compactionMarkerOpen = false;
|
||||
if (this.activeForegroundTurnId) {
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeTurnRuntime = null;
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
} else if (this.autonomousTurn) {
|
||||
this.autonomousTurn = null;
|
||||
this.activeTurnRuntime = null;
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.syncTurnState("autonomous turn terminal");
|
||||
}
|
||||
@@ -3730,7 +3731,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.autonomousTurn = {
|
||||
id: this.createTurnId("autonomous"),
|
||||
};
|
||||
this.activeTurnRuntime = this.runtime;
|
||||
this.activeForegroundQuery = this.query;
|
||||
this.activeForegroundInput = this.input;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.contextUsage.beginTurn();
|
||||
this.notifySubscribers({ type: "turn_started", provider: "claude" });
|
||||
@@ -3743,7 +3745,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
|
||||
this.autonomousTurn = null;
|
||||
this.activeTurnRuntime = null;
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.compactionMarkerOpen = false;
|
||||
this.syncTurnState("autonomous turn completed");
|
||||
@@ -3771,11 +3774,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): void {
|
||||
const runtime = this.runtime;
|
||||
if (this.closed || !runtime || runtime.process.child !== child) {
|
||||
if (this.closed || this.childProcess !== child) {
|
||||
return;
|
||||
}
|
||||
runtime.process.child = null;
|
||||
this.childProcess = null;
|
||||
this.logger.warn(
|
||||
{ agentId: this.agentId, pid: child.pid, code, signal },
|
||||
"Claude runtime exited unexpectedly",
|
||||
@@ -3784,19 +3786,13 @@ 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 the runtime, which is how the pump recognizes its own stream.
|
||||
// and detach this.query, 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.
|
||||
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.query = null;
|
||||
this.input = null;
|
||||
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.`,
|
||||
@@ -3812,12 +3808,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private startQueryPump(runtime: ClaudeRuntime): void {
|
||||
if (this.closed || runtime.pump) {
|
||||
private startQueryPump(): void {
|
||||
if (this.closed || this.queryPumpPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pump = this.runQueryPump(runtime).catch((error) => {
|
||||
const pump = this.runQueryPump().catch((error) => {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
@@ -3830,16 +3826,33 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
});
|
||||
|
||||
runtime.pump = pump;
|
||||
this.queryPumpPromise = pump;
|
||||
void pump.finally(() => {
|
||||
if (runtime.pump === pump) {
|
||||
runtime.pump = null;
|
||||
if (this.queryPumpPromise === pump) {
|
||||
this.queryPumpPromise = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async runQueryPump(runtime: ClaudeRuntime): Promise<void> {
|
||||
const activeQuery = runtime.query;
|
||||
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;
|
||||
}
|
||||
|
||||
let consecutiveInterruptAbortRecoveries = 0;
|
||||
const logRawMessage = (message: SDKMessage): void => {
|
||||
this.logger.trace(
|
||||
@@ -3857,10 +3870,9 @@ 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, runtime)) {
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) {
|
||||
return true;
|
||||
}
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
@@ -3875,19 +3887,19 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return false;
|
||||
};
|
||||
try {
|
||||
while (!this.closed && this.runtime === runtime) {
|
||||
while (!this.closed && this.query === activeQuery) {
|
||||
try {
|
||||
if (await drainActiveQuery()) {
|
||||
return;
|
||||
}
|
||||
if (!this.closed && this.runtime === runtime) {
|
||||
if (!this.closed && this.query === activeQuery) {
|
||||
this.failActiveTurns("Claude stream ended before terminal result");
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
!this.closed &&
|
||||
this.runtime === runtime &&
|
||||
this.query === activeQuery &&
|
||||
this.shouldRecoverInterruptedQueryAbort(error, consecutiveInterruptAbortRecoveries)
|
||||
) {
|
||||
consecutiveInterruptAbortRecoveries += 1;
|
||||
@@ -3897,7 +3909,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!this.closed && this.runtime === runtime) {
|
||||
if (!this.closed && this.query === activeQuery) {
|
||||
await this.awaitRecentStderrAfterProcessExit(error);
|
||||
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
|
||||
}
|
||||
@@ -3905,8 +3917,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await this.disposeRuntime(runtime);
|
||||
if (this.runtime === runtime) this.runtime = null;
|
||||
if (this.query === activeQuery) {
|
||||
this.query = null;
|
||||
this.input = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4078,7 +4092,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private async handleMissingResumedConversation(
|
||||
message: SDKMessage,
|
||||
runtime: ClaudeRuntime,
|
||||
activeQuery: Query,
|
||||
): Promise<boolean> {
|
||||
const staleResumeError = this.readMissingResumedConversationError(message);
|
||||
if (!staleResumeError) {
|
||||
@@ -4093,8 +4107,29 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
|
||||
this.failActiveTurns(staleResumeError);
|
||||
// Clear the conversation before yielding to teardown, so a later request
|
||||
// cannot resume a session that has already been rejected.
|
||||
// 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;
|
||||
}
|
||||
this.persistence = null;
|
||||
this.persistedHistory = [];
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
@@ -4103,10 +4138,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.queryRestartNeeded = false;
|
||||
this.autonomousTurn = null;
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeTurnRuntime = null;
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.syncTurnState("missing resumed conversation");
|
||||
await this.disposeRuntime(runtime);
|
||||
if (this.runtime === runtime) this.runtime = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4117,7 +4151,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.runtime?.query;
|
||||
const queryToInterrupt = this.query;
|
||||
if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") {
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -5187,26 +5221,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private resolveHistoryPath(sessionId: string): string | null {
|
||||
const cwd = this.config.cwd;
|
||||
if (!cwd) return null;
|
||||
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`);
|
||||
return claudeTranscriptPathSync({
|
||||
cwd,
|
||||
sessionId,
|
||||
configDir: claudeConfigDir(this.harnessEnvironment),
|
||||
});
|
||||
}
|
||||
|
||||
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
|
||||
|
||||
@@ -170,10 +170,20 @@ export const CLAUDE_MODEL_MANIFEST = [
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.standard,
|
||||
supportsThinkingDisabled: true,
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-5-5",
|
||||
label: "Haiku 5.5",
|
||||
description: "Haiku 5.5 · Fastest for quick answers",
|
||||
minimumClaudeCodeVersion: "2.1.293",
|
||||
defaultThinkingOptionId: "medium",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
supportsThinkingDisabled: true,
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
description: "Haiku 4.5 · Previous release",
|
||||
contextWindowMaxTokens: 200_000,
|
||||
},
|
||||
] as const satisfies readonly ClaudeModelManifestEntry[];
|
||||
|
||||
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
|
||||
return configDir;
|
||||
}
|
||||
|
||||
function createCatalogClient(claudeCodeVersion = "2.1.284"): ClaudeAgentClient {
|
||||
function createCatalogClient(claudeCodeVersion = "2.1.293"): ClaudeAgentClient {
|
||||
return new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => claudeCodeVersion,
|
||||
@@ -66,6 +66,7 @@ describe("getClaudeModels", () => {
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6[1m]",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-5-5",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
});
|
||||
@@ -100,6 +101,7 @@ describe("getClaudeModels", () => {
|
||||
["claude-opus-4-6", 200_000],
|
||||
["claude-sonnet-4-6[1m]", 1_000_000],
|
||||
["claude-sonnet-4-6", 200_000],
|
||||
["claude-haiku-5-5", 1_000_000],
|
||||
["claude-haiku-4-5", 200_000],
|
||||
]),
|
||||
);
|
||||
@@ -123,6 +125,12 @@ describe("getClaudeModels", () => {
|
||||
expect(getClaudeModels("2.1.284").map((model) => model.id)).toContain("claude-sonnet-5-5");
|
||||
});
|
||||
|
||||
it("gates Haiku 5.5 on Claude Code 2.1.293 without changing the default", () => {
|
||||
expect(getClaudeModels("2.1.292").map((model) => model.id)).not.toContain("claude-haiku-5-5");
|
||||
expect(getClaudeModels("2.1.293").map((model) => model.id)).toContain("claude-haiku-5-5");
|
||||
expect(getClaudeModels("2.1.293").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
|
||||
});
|
||||
|
||||
it("derives thinking options from model effort capabilities", () => {
|
||||
const models = new Map(getClaudeModels().map((model) => [model.id, model]));
|
||||
|
||||
@@ -192,6 +200,8 @@ describe("getClaudeModels", () => {
|
||||
["claude-sonnet-5-20260101", true, "high"],
|
||||
["claude-fable-5", false, "high"],
|
||||
["claude-fable-5-1", false, "high"],
|
||||
["claude-haiku-5-5", true, "medium"],
|
||||
["claude-haiku-5-5-20261007", true, "medium"],
|
||||
["claude-haiku-4-5", false, undefined],
|
||||
["openrouter/anthropic/claude-opus-4-8", false, undefined],
|
||||
[null, false, undefined],
|
||||
@@ -653,6 +663,51 @@ describe("Claude Sonnet 5.5 catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Haiku 5.5 catalog", () => {
|
||||
it("offers one Haiku 5.5 entry with a 1M context window", () => {
|
||||
const haiku55Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-haiku-5-5"))
|
||||
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||
|
||||
expect(haiku55Models).toEqual([
|
||||
{ id: "claude-haiku-5-5", label: "Haiku 5.5", contextWindowMaxTokens: 1_000_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers Off and every effort level with medium as the default", () => {
|
||||
const haiku55 = getClaudeModels().find((model) => model.id === "claude-haiku-5-5");
|
||||
|
||||
expect(haiku55?.thinkingOptions?.map((option) => option.id)).toEqual([
|
||||
CLAUDE_DISABLED_THINKING_OPTION_ID,
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
|
||||
]);
|
||||
expect(haiku55?.defaultThinkingOptionId).toBe("medium");
|
||||
expect(
|
||||
haiku55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
|
||||
).toEqual(["medium"]);
|
||||
});
|
||||
|
||||
it("normalizes provider-prefixed runtime IDs", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("anthropic/claude-haiku-5-5")).toBe("claude-haiku-5-5");
|
||||
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-haiku-5-5-v1:0")).toBe(
|
||||
"claude-haiku-5-5",
|
||||
);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-haiku-5-5")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves suffixed and dated Haiku 5.5 IDs to the single catalog entry", () => {
|
||||
expect(findClaudeModel("claude-haiku-5-5[1m]")?.id).toBe("claude-haiku-5-5");
|
||||
expect(findClaudeModel("claude-haiku-5-5-20261007")?.id).toBe("claude-haiku-5-5");
|
||||
expect(findClaudeModel("claude-haiku-5-5-20261007[1m]")?.id).toBe("claude-haiku-5-5");
|
||||
expect(findClaudeModel("claude-haiku-5-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeManifestModelSupportsFastMode", () => {
|
||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -30,6 +30,46 @@ 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));
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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("concurrent command discovery and thinking changes preserve replies and conversation", async () => {
|
||||
test("changing thinking effort preserves the previous conversation", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
@@ -88,13 +88,10 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
|
||||
model: model.id,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
client.listCommands(agent.id),
|
||||
client.sendMessage(
|
||||
agent.id,
|
||||
"Remember the code phrase PASEO_MEMORY_56. Reply exactly: ACK_56",
|
||||
),
|
||||
]);
|
||||
await 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();
|
||||
@@ -102,25 +99,16 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
|
||||
|
||||
await client.setAgentThinkingOption(agent.id, "low");
|
||||
|
||||
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.",
|
||||
),
|
||||
]);
|
||||
await 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,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -2867,6 +2867,44 @@ describe("ScheduleService", () => {
|
||||
expect(inspected.runs[0]?.error).toBe("network blip");
|
||||
});
|
||||
|
||||
// chmod cannot make a directory unwritable on Windows or for root.
|
||||
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"fires again after a tick whose run could not be recorded",
|
||||
async () => {
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
logger: createTestLogger(),
|
||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ran" }),
|
||||
});
|
||||
|
||||
const created = await service.create({
|
||||
prompt: "ping target",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: { type: "agent", agentId: "88888888-8888-4888-8888-888888888888" },
|
||||
});
|
||||
|
||||
const schedulesDir = join(tempDir, "schedules");
|
||||
now = new Date("2026-01-01T00:01:00.000Z");
|
||||
await chmod(schedulesDir, 0o555);
|
||||
try {
|
||||
await expect(service.tick()).rejects.toThrow();
|
||||
} finally {
|
||||
await chmod(schedulesDir, 0o755);
|
||||
}
|
||||
|
||||
await service.tick();
|
||||
|
||||
const inspected = await service.inspect(created.id);
|
||||
expect(inspected.runs).toHaveLength(1);
|
||||
expect(inspected.runs[0]?.status).toBe("succeeded");
|
||||
expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z");
|
||||
},
|
||||
);
|
||||
|
||||
test("completes the schedule when a scheduled run targets an archived agent", async () => {
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
|
||||
@@ -694,42 +694,44 @@ export class ScheduleService {
|
||||
): Promise<void> {
|
||||
const manual = options?.manual === true;
|
||||
this.runningScheduleIds.add(schedule.id);
|
||||
const runId = randomUUID();
|
||||
const runningRun: ScheduleRun = {
|
||||
id: runId,
|
||||
scheduledFor: manual ? now.toISOString() : (schedule.nextRunAt ?? now.toISOString()),
|
||||
startedAt: now.toISOString(),
|
||||
endedAt: null,
|
||||
status: "running",
|
||||
agentId: null,
|
||||
output: null,
|
||||
error: null,
|
||||
};
|
||||
const scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun);
|
||||
|
||||
try {
|
||||
const result = await this.runner(scheduleWithRun, runId);
|
||||
await this.finishRun({
|
||||
scheduleId: schedule.id,
|
||||
runId,
|
||||
status: "succeeded",
|
||||
agentId: result.agentId,
|
||||
output: result.output,
|
||||
error: null,
|
||||
targetGone: false,
|
||||
manual,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.finishRun({
|
||||
scheduleId: schedule.id,
|
||||
runId,
|
||||
status: "failed",
|
||||
const runId = randomUUID();
|
||||
const runningRun: ScheduleRun = {
|
||||
id: runId,
|
||||
scheduledFor: manual ? now.toISOString() : (schedule.nextRunAt ?? now.toISOString()),
|
||||
startedAt: now.toISOString(),
|
||||
endedAt: null,
|
||||
status: "running",
|
||||
agentId: null,
|
||||
output: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
targetGone: error instanceof ScheduleTargetGoneError,
|
||||
manual,
|
||||
});
|
||||
error: null,
|
||||
};
|
||||
const scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun);
|
||||
|
||||
try {
|
||||
const result = await this.runner(scheduleWithRun, runId);
|
||||
await this.finishRun({
|
||||
scheduleId: schedule.id,
|
||||
runId,
|
||||
status: "succeeded",
|
||||
agentId: result.agentId,
|
||||
output: result.output,
|
||||
error: null,
|
||||
targetGone: false,
|
||||
manual,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.finishRun({
|
||||
scheduleId: schedule.id,
|
||||
runId,
|
||||
status: "failed",
|
||||
agentId: null,
|
||||
output: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
targetGone: error instanceof ScheduleTargetGoneError,
|
||||
manual,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.runningScheduleIds.delete(schedule.id);
|
||||
}
|
||||
|
||||
@@ -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 install omercnet/fresh-worktrees")).toHaveCount(1);
|
||||
await expect(page.getByText("paseo plugin add 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 install omercnet/fresh-worktrees");
|
||||
.toBe("paseo plugin add 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 install omercnet/fresh-worktrees")).toBeVisible();
|
||||
await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -388,19 +388,18 @@ async function expectPageMetadata(page: Page, title: string, path: string) {
|
||||
);
|
||||
}
|
||||
|
||||
test("keeps the directory unlinked until the coordinated announcement", async ({ page }) => {
|
||||
test("links the directory from the site navigation", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.getByRole("banner").getByRole("link", { name: "Plugins", exact: true }),
|
||||
).toHaveCount(0);
|
||||
page.getByRole("navigation").getByRole("link", { name: "Plugins", exact: true }),
|
||||
).toHaveAttribute("href", "/plugins");
|
||||
await expect(
|
||||
page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }),
|
||||
).toHaveCount(0);
|
||||
).toHaveAttribute("href", "/plugins");
|
||||
await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://paseo.cafe",
|
||||
"/plugins",
|
||||
);
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"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-white/60 font-medium">Product</p>
|
||||
<p className="text-extra-muted-foreground">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-white/60 font-medium">Agents</p>
|
||||
<p className="text-extra-muted-foreground">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-white/60 font-medium">Alternatives</p>
|
||||
<p className="text-extra-muted-foreground">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-white/60 font-medium">Community</p>
|
||||
<p className="text-extra-muted-foreground">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-white/60 font-medium">Download</p>
|
||||
<p className="text-extra-muted-foreground">Download</p>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href={appStoreUrl}
|
||||
|
||||
@@ -12,6 +12,14 @@ export function SiteHeader() {
|
||||
<span className="text-lg font-medium">Paseo</span>
|
||||
</a>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
{PLUGINS_LINKED && (
|
||||
<a
|
||||
href="/plugins"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Plugins
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="/blog"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -24,14 +32,6 @@ export function SiteHeader() {
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
{PLUGINS_LINKED && (
|
||||
<a
|
||||
href="/plugins"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Plugins
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="/changelog"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 55
|
||||
|
||||
Claude Desktop is Anthropic's app for Claude Chat, Cowork, and Claude Code on macOS, Windows, and Linux.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 54
|
||||
|
||||
OpenAI provides Codex through the ChatGPT desktop app on macOS, Windows, and Linux, with local, worktree, cloud, and remote workflows.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 50
|
||||
|
||||
Conductor is a proprietary macOS app for running Claude Code, Codex, Cursor, and OpenCode in parallel Git worktrees and managed cloud workspaces.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 53
|
||||
|
||||
Happy Coder connects Claude Code and Codex sessions across its macOS, web, iOS, and Android apps. It wraps the agent CLI on your laptop and syncs sessions over an end-to-end encrypted relay. Open source under MIT.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ order: 56
|
||||
|
||||
OpenCode Desktop is the desktop app for OpenCode. It is available in beta for macOS, Windows, and Linux.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Orca ADE Alternative With Agent Chat, a No-Account Relay, and Plugins
|
||||
description: Paseo is an Orca alternative for developers who want a chat-first interface, a daemon they can reach from any client, and plugins that run on the server and every client.
|
||||
title: Orca Alternative for Agentic Development
|
||||
description: Paseo is an open source agentic development environment with parallel worktrees, an editor, terminals, diffs, pull requests, and a built-in browser.
|
||||
nav: Orca
|
||||
order: 57
|
||||
---
|
||||
@@ -9,7 +9,7 @@ order: 57
|
||||
|
||||
Orca is an MIT-licensed desktop app for running terminal coding agents in parallel Git worktrees. It includes a mobile companion app, SSH worktrees, remote Orca servers, an embedded browser, and a CLI.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
Paseo is an open source agentic development environment. Run parallel agents in separate worktrees, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. Available on desktop, mobile, web, and CLI under Apache-2.0.
|
||||
|
||||

|
||||
|
||||
@@ -23,7 +23,7 @@ Orca is made by Stably AI, a venture-funded company (Y Combinator). Paseo is ind
|
||||
|
||||
## Architecture and remote machines
|
||||
|
||||
The Paseo daemon runs as its own process. Desktop, web, mobile, and CLI clients connect to it directly, over SSH, or through the optional end-to-end encrypted relay. Run the daemon on your laptop, a VM, a home server, or in Docker, and connect to any of them from any client. See [connectivity](/docs/connectivity).
|
||||
The Paseo desktop app starts and manages a local daemon automatically. That daemon owns the agents and workspaces, so other clients can connect to the same development environment. Desktop, web, mobile, and CLI clients connect to it directly, over SSH, or through the optional end-to-end encrypted relay. Run the daemon on your laptop, a VM, a home server, or in Docker, and connect to any of them from any client. See [connectivity](/docs/connectivity).
|
||||
|
||||
Orca runs inside its desktop app by default and reaches other machines in two ways. SSH worktrees keep the Orca runtime on your laptop and run selected worktrees and terminals on a remote host. Remote Orca Servers, in beta, run Orca desktop or `orca serve` on another machine and let clients pair to it. Orca's documentation recommends Tailscale or another private network for Remote Orca Servers.
|
||||
|
||||
|
||||
@@ -6,13 +6,15 @@ const SITE_URL = "https://paseo.sh";
|
||||
|
||||
const PRODUCT_PREAMBLE = `# Paseo
|
||||
|
||||
> Mobile and desktop app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket.
|
||||
> Open source agentic development environment for desktop, mobile, web, and CLI.
|
||||
|
||||
Paseo is an open source application that lets you run AI coding agents on your own machine and drive them from your phone, desktop, browser, or terminal. Your code stays local — Paseo connects directly to your real development environment instead of running agents in someone else's cloud.
|
||||
Paseo is a full agentic development environment. For agent-driven development, it replaces your IDE: run coding agents, edit files, review diffs and pull requests, run tests in terminals, and check your app in the built-in desktop browser. Arrange agents, files, terminals, and browser tabs in split panes within a workspace.
|
||||
|
||||
A self-hosted daemon manages agent lifecycle, exposes a WebSocket API, and ships with an MCP server so other agents can talk to it. Native apps for iOS, Android, macOS, Windows, Linux, and the web let you launch sessions, watch them work, review diffs, and ship from anywhere. A Docker-style CLI ("paseo run", "paseo ls", "paseo logs", "paseo wait") gives you scripting access. An end-to-end encrypted relay lets the mobile app reach your daemon over the public internet without exposing it.
|
||||
Run many agents in parallel, each in its own git worktree and branch. Choose a provider and model for each task, inspect the results, and decide what to merge. Agents can also create worktrees, launch other agents, and communicate with them through Paseo's orchestration tools.
|
||||
|
||||
Paseo supports every major coding agent: Claude Code, Codex, GitHub Copilot, OpenCode, Antigravity, Muse Code, Cursor, Gemini, Cline, Goose, Amp, Aider, and 30+ others. Each agent runs as its own process; Paseo handles I/O, persistence, git worktree isolation, schedules, and skills.
|
||||
Agents run on your own machines with your existing tools, configuration, and credentials. Work from desktop, the full native iOS and Android app, a web browser, or the CLI. The desktop app starts its local daemon automatically. For remote work, run the daemon on another machine and connect directly or through the optional end-to-end encrypted relay.
|
||||
|
||||
Paseo supports Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Muse Code, and additional ACP-compatible agents. The CLI, TypeScript SDK, and MCP tools expose agent and workspace operations for automation. Plugins extend providers, workflows, and the app's interface.
|
||||
|
||||
Distribution: native apps for Mac, Windows, Linux, iOS, and Android; web app; Homebrew; npm. Source: Apache-2.0 at https://github.com/getpaseo/paseo. Marketing site: https://paseo.sh.
|
||||
`;
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
// Keep navigation on paseo.cafe until the coordinated directory announcement.
|
||||
export const PLUGINS_LINKED = false;
|
||||
export const PLUGINS_LINKED = true;
|
||||
|
||||
@@ -263,7 +263,7 @@ describe("plugin registry", () => {
|
||||
);
|
||||
});
|
||||
it("offers a registry install command", () => {
|
||||
expect(installCommand(plugin)).toBe("paseo plugin install acme/example");
|
||||
expect(installCommand(plugin)).toBe("paseo plugin add acme/example");
|
||||
expect(formatInstalls(1250)).toBe("1.3k");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 install ${plugin.id}`;
|
||||
return `paseo plugin add ${plugin.id}`;
|
||||
}
|
||||
export function pluginVersion(plugin: Plugin): string {
|
||||
return plugin.artifact.kind === "npm"
|
||||
|
||||
@@ -17,16 +17,19 @@ function Home() {
|
||||
<LandingPage
|
||||
title={
|
||||
<>
|
||||
The control plane
|
||||
The agentic development
|
||||
<br />
|
||||
for coding agents
|
||||
environment
|
||||
</>
|
||||
}
|
||||
subtitle={
|
||||
<>
|
||||
Run many coding agents at once, on your machines.
|
||||
Run many coding agents at once,
|
||||
<br className="sm:hidden" />
|
||||
<span className="hidden sm:inline"> </span>
|
||||
on any machine.
|
||||
<br />
|
||||
Desktop and mobile. Open source.
|
||||
From your desk and from your phone.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.11.0-beta.5",
|
||||
"version": "0.11.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -8,7 +8,7 @@ category: Getting started
|
||||
|
||||
# Getting started
|
||||
|
||||
Paseo runs your coding agents on your machine and gives you a mobile, desktop, web, and CLI client to drive them from anywhere. Three common ways to install.
|
||||
Paseo is an agentic development environment for running coding agents, editing files, reviewing changes, and testing your work. Agents run on your machines; desktop, mobile, web, and CLI clients let you work from anywhere. Choose how to install it below.
|
||||
|
||||
## Desktop app (recommended)
|
||||
|
||||
|
||||
+22
-24
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Why Paseo?
|
||||
description: What Paseo is, what it isn't, and how it fits into your workflow.
|
||||
description: A full agentic development environment for running coding agents, editing files, reviewing changes, and testing your work.
|
||||
nav: Why Paseo?
|
||||
order: 2
|
||||
category: Getting started
|
||||
@@ -8,38 +8,36 @@ category: Getting started
|
||||
|
||||
# Why Paseo?
|
||||
|
||||
Paseo is a self-hostable platform for running and orchestrating coding agents. It runs the agent CLIs you already use, on the hardware you already have, and gives you a UI, CLI, and API to drive them from anywhere.
|
||||
Paseo is a full agentic development environment. Run coding agents, edit files, review diffs and pull requests, and test your app with terminals and a built-in desktop browser. For agent-driven development, Paseo replaces your IDE: take a task from the first prompt through review and testing in one app.
|
||||
|
||||
## Architecture
|
||||
## Develop in one workspace
|
||||
|
||||
- Daemon-client architecture. The daemon manages agents; clients (mobile, desktop, web, CLI) connect locally or over a relay. Remote access isn't an add-on.
|
||||
- macOS, Windows, and Linux are all primary targets. None of them are a port or an afterthought.
|
||||
- Mobile, desktop, and web are separate native clients. The mobile app is built in React Native, not a webview.
|
||||
Open the desktop app, choose a project and an agent, and start working. Keep the conversation, editor, terminal, diffs, and browser together in split panes. Run your app, inspect the result, and ask the agent for changes without leaving the workspace.
|
||||
|
||||
## Providers
|
||||
The desktop app is available on macOS, Windows, and Linux. It starts and manages its local daemon automatically.
|
||||
|
||||
- Bring your own. Use your Claude subscription, your OpenAI account, your own API keys, a self-hosted endpoint. Paseo doesn't proxy model calls.
|
||||
- Local voice stack. Speech-to-text and text-to-speech run on-device by default. OpenAI providers are configurable if you want cloud quality.
|
||||
- Open source. No telemetry on your code.
|
||||
## Run tasks in parallel
|
||||
|
||||
## Where agents run
|
||||
Give independent tasks their own worktrees and branches. Run several agents at once, inspect each diff, test each implementation, and choose what to merge. Use the same provider for every task or choose a different provider and model for each.
|
||||
|
||||
- Your laptop, a homelab, a company server. Same daemon, same client surface.
|
||||
- Any directory, git or not. Launch agents, merge locally, review the diff in the app.
|
||||
- GitHub PRs, checks, and reviews surface in the app when you want them. Not required.
|
||||
Configure [workspace scripts](/docs/worktrees#scripts-and-services) to start development servers with separate ports for each worktree. [Try the parallel development workflow](/docs/parallel-development).
|
||||
|
||||
## Parallel work
|
||||
## Use your agents and tools
|
||||
|
||||
- Splits and panes. Agents, terminals, and browsers side by side in one workspace.
|
||||
- Per-worktree services. Each worktree gets allocated ports for dev servers and databases, reachable through proxy URLs like `web.fix-auth.my-app.localhost` so they don't collide.
|
||||
- Multiple agents on the same repo via worktrees.
|
||||
Paseo runs the coding agents you already use, with your subscriptions, credentials, configuration, skills, and MCP servers. Your agents work with the files and tools on your machine. See [supported providers](/docs/supported-providers).
|
||||
|
||||
## Automation
|
||||
Dictate tasks or use [voice mode](/docs/voice). Speech-to-text and text-to-speech run locally by default, with cloud providers available in settings.
|
||||
|
||||
- The CLI exposes the same surface as the app. Anything in the UI is scriptable.
|
||||
- [Paseo tools](/docs/orchestration). Agents can drive Paseo themselves: create isolated workspaces, spawn subagents, open terminals, and send prompts.
|
||||
- [Hub](/docs/hub). A service you host that starts agents on your daemon when someone mentions you on GitHub, Slack, or Discord.
|
||||
## Continue from another device
|
||||
|
||||
## What it isn't
|
||||
The daemon owns the running agents and workspaces. The desktop app connects to it locally; mobile, web, and CLI clients connect to the same environment. That separation lets you continue working from another device or run your environment on a server.
|
||||
|
||||
Not a hosted agent, not an IDE, not a model provider. Paseo runs the CLIs you already use and stays out of the way.
|
||||
Use Paseo entirely on your laptop, or connect to another machine when you need it. The native iOS and Android app gives you agents, files, terminals, and diffs on your phone. The built-in browser runs in the desktop app. [Connect your devices](/docs/connectivity) directly or through the optional end-to-end encrypted relay.
|
||||
|
||||
## Automate and extend your workflow
|
||||
|
||||
Give agents [Paseo tools](/docs/orchestration) to create worktrees, launch other agents, send prompts, and collect results. Use the [CLI](/docs/cli) or [TypeScript SDK](/docs/sdk) to automate agent and workspace operations.
|
||||
|
||||
[Plugins](/docs/plugins) add providers, workspace panels, commands, and workflows. [Hub](/docs/hub) connects external events to agents running on your machines.
|
||||
|
||||
Paseo is open source under Apache-2.0, with no telemetry, tracking, or required Paseo account.
|
||||
|
||||
Reference in New Issue
Block a user