Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 306b9bfb7b test(server): compare profiled and plain config reads on Windows 2026-10-30 12:39:56 +00:00
paseo-bot[bot] 14329d1b98 test(server): diagnose recurring config responsiveness failure 2026-10-30 12:08:00 +00:00
paseo-ai[bot] fbe24fcdb6 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-30 11:06:08 +00:00
Mohamed Boudra 787edb1bf9 Respect proxy variables throughout the daemon (#8177)
* fix(server): respect environment proxies across daemon traffic

Install proxy routing at daemon and plugin process startup, with a separate HTTP agent for outbound WebSockets. Always exempt loopback so local provider bridges remain reachable.

* test(server): run usage source regressions in CI

* fix(server): validate proxy settings without blocking startup

Normalize scheme-less proxies and share HTTP-only validation across fetch and WebSockets. Record ignored variables without credentials in the daemon log. Separate built-in and directory plugin regressions through a typed fixture.

* refactor(server): treat ignored proxy variables as unset

* fix(server): read Windows proxy environment entries once

* fix(server): apply proxy policy to connected host links

* test(server): isolate proxy fixture credential directories

* fix(server): keep proxy routing and authentication consistent

Normalize Windows environment names and wildcard bypass rules at the network boundary. Compare bypass ports numerically and keep Basic proxy credentials on the CONNECT pool, including empty credential fields.
2026-10-30 17:57:57 +07:00
Mohamed Boudra 385c29b655 Keep failed chat history available for retry (#8278)
* fix(app): keep failed chat history until retry

Automatic retries repeatedly reopen provider sessions after persistent failures. Keep the error visible until the user retries, while retaining reconnect recovery.

* fix(app): catch up chats after subscription failure

A failed replacement subscription leaves chats unsubscribed, so discard its old catch-up state and fetch missed history after retry.

* fix(app): recover gaps during history retry

Keep gap recovery active while a user-initiated retry is running so the chat is not marked current before its follow-up fetch.

* fix(app): show history errors in archived chats

Cached archived chats need the same manual retry affordance when history synchronization fails.
2026-10-30 17:57:52 +07:00
Mohamed Boudra 9477fe0c00 fix(app): refresh Android layout after background fold changes (#8285)
Backport the Unistyles 3.5.3 Activity configuration fix while keeping 3.2.4 and its existing fixes. Application resources could leave the responsive shell on the previous fold size after resume. Cover the real device-state sequence with a native UI regression.
2026-10-30 17:22:53 +07:00
531370c4ce fix(pi): show tool-result images live and after reload (#8280)
* pi: render tool-result images as inline image rows (paseo #1717 parity)

* fix(pi): preserve turn ownership while rendering images

* fix(pi): preserve metadata when stripping nested image payloads

---------

Co-authored-by: be-my-bot <bot@be-my-code.local>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: duyiliu <duyiliu@users.noreply.github.com>
2026-10-30 10:10:39 +00:00
paseo-bot[bot] e299b21e94 test(server): measure Git filter work within its callback (#8270)
Exclude unrelated callbacks and fixture garbage from the cold event-filter budget. Keep the 20ms limit and IO assertions, with unrelated CPU work as a regression control.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-30 09:17:50 +00:00
PeanutandPeanut-HZM 1f79efcd62 fix(providers): name the MiniMax Code binary so npx can resolve it (#8201)
The MiniMax Code catalog entry ran `npx -y @minimax-ai/code@0.6.13 acp`.
npx derives the executable from the unscoped package name, which is `code`,
but the package publishes `mcode` and `mcode-tools`. npm therefore exits
with "could not determine executable to run" before the agent starts.

Paseo reports this as a provider that timed out during `initialize`, so the
entry looks like a broken agent rather than a malformed command.

Name the package and the binary separately. The ACP handshake then completes
and the provider reports its models and modes.

Co-authored-by: Peanut-HZM <peanut@hzm.me>
2026-10-30 08:08:14 +00:00
Mohamed Boudra e3682ba18c Usage fetches only when shown, and can be turned off (#8181)
* fix(usage): fetch only when shown and add a host-wide off switch

* fix(usage): restore policy defaults and isolate usage test state

* refactor(usage): give the sidebar footer sole ownership of its summary
2026-10-30 14:32:19 +07:00
a8e0effb32 Follow system rotation on mobile devices (#8256)
The app no longer locks to portrait. Phones and tablets on Android and iOS follow the device rotation and respect the system rotation lock, so Android tablets fill the display in landscape instead of letterboxing.

Modals pass all orientations explicitly because React Native presents iPhone modals in portrait by default.

Closes #1669

Co-authored-by: yz <43207690+yzim@users.noreply.github.com>
Co-authored-by: magiclaw <930198090@qq.com>
2026-10-30 13:42:38 +07:00
c663630bbd fix(server): read Codex history in pages instead of one whole-thread reply (#8171)
Opening a Codex agent whose timeline the daemon doesn't hold asked Codex
for the whole thread in one thread/read reply. Past the 128 MiB provider
line cap that reply killed the session; before the cap it crashed the
daemon. Codex 0.145 and newer reject that read on paginated threads, so
importing them failed.

Paginated threads now load through thread/items/list pages. A legacy
thread keeps its single read. A reply over the line cap fails only its own
request: for a legacy read, a warning row names the archive,
`codex migrate-rollouts`, unarchive steps; for a page, a warning row ends
the history there. Items are capped as they are projected, and rewind's
fork no longer returns the thread's turns.

Co-authored-by: hunterricks <hr@savagestrategic.io>
Co-authored-by: dundunHa <lixinpengde@gmail.com>
2026-10-30 12:57:21 +07:00
paseo-bot[bot] deb1bdc266 test(plugins): wait for global recovery without an extra restart (#8250)
* test(plugins): observe global recovery without reloading again

* test(plugins): leave time for recovery failure cleanup

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-30 03:38:37 +00:00
Matěj Pešl 9c7925de58 fix(skills): make invocation input portable across harnesses (#8252)
Read invocation input and conversation context without Claude argument substitution. Recognize Pi skill references when forwarding advisor requests.
2026-10-30 03:01:41 +00:00
4351445467 Keep other windows in sync when an agent is deleted (#8248)
* fix(server): broadcast agent removals across logical sessions

* test(server): make subscription fixtures portable on Windows

The newly enabled integration suite assumed /bin/cat and a one-second reply deadline, so Windows CI failed before completing its subscription coverage. Use the running Node executable for echo terminals and let diagnostics finish their bounded external tool probes.

Co-authored-by: loganmeyer <50949542+loganmeyer@users.noreply.github.com>

---------

Co-authored-by: loganmeyer <50949542+loganmeyer@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-30 00:05:34 +00:00
paseo-bot[bot] 9503537041 test(server): exclude loader activity from config responsiveness (#8218)
* test(server): trace recurring config responsiveness failures

* test(server): keep loader messages out of config responsiveness probes

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 21:02:58 +00:00
paseo-bot[bot] 1aef1ec662 test(server): isolate timeline fetch responsiveness from runner work (#8197)
* test(server): isolate timeline fetch responsiveness from runner work

Measure the existing cold tail workload in a separate process after fixture collection. Keep the 50ms gate and row assertions, and reproduce unrelated callback CPU contaminating the old measurement.

* test(server): keep timeline probe private and exercise overlapping CPU

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 12:21:29 +00:00
946b6e58fd Keep transcript colors in sync after a hidden theme change (#8199)
* fix(app): preserve adaptive theme events while hidden

Read the current scheme through fresh matchMedia lists so hidden runtime
reads do not consume the pending events on listener-owned lists.

Carry Dan Sabin's fix from #7172 into the current combined Unistyles patch,
and cover both transition directions with a real Electron regression in
the existing desktop browser suite.

Co-authored-by: Dan Sabin <dan@freethinking.it>

* test(desktop): run adaptive theme regression on Linux

Electron retains --no-sandbox in argv, so the positional lookup used the script filename as the artifact directory. Read the final argument so the same real Electron regression exercises Linux as well as macOS.

Co-authored-by: Nurşat Bülbül <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: Dan Sabin <dan@freethinking.it>

---------

Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: Dan Sabin <dan@freethinking.it>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 11:16:48 +00:00
Mohamed Boudra ac6e4e9f96 Fix flaky 64 MiB image materialization test on Windows CI (#8198)
The responsiveness test timed wall-clock gaps between 1 ms ticks, which reached 50.35 ms on the shared Windows runner for the same code. It now measures main-thread CPU time between ticks and still fails when the payload decodes in one chunk.
2026-10-29 16:56:12 +07:00
paseo-bot[bot] c4b4516890 Keep split dividers under the pointer across mouse and gesture input (#6011)
* fix(app): keep split dividers under the mouse before Explorer is first opened

The main row only left the Explorer pane out once the Explorer had been
shown, because the split container read the registered pane id directly.
Before that, the hidden Explorer stayed in the row with its share of the
sizes, and every divider drag moved about 1.28 times the mouse distance.
Resolve the pane the same way the workspace screen does, so the default
Explorer pane is left out from the start.

Closes #5998

* fix(app): keep split dividers under gesture input

Measure the gesture from its initial press so activation does not discard the movement before its threshold. Cover coarse-pointer mouse and touch input alongside app-created workspaces, Explorer changes and reload.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 09:10:25 +00:00
Mohamed Boudra 75f58c4fe7 fix(release): upload only en-US screenshots to Google Play (#8195)
supply uploads every locale folder under fastlane/metadata/android in one
edit. Play rejects the F-Droid-only ja locale, which failed the whole edit in
the 0.14.0 release ("ja - Invalid request"), so no screenshots were replaced.
Stage only the en-US images, add a standalone workflow to rerun the upload for
the current release, and document the App Store associatedErrors gotcha.
2026-10-29 15:57:08 +07:00
paseo-ai[bot] fb69bf3314 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-29 08:49:01 +00:00
Mohamed Boudra f7eef9dd0c chore(release): reconcile main with v0.14.0 (#8193)
Bring in what v0.14.0 shipped: the 0.14.0 entry in CHANGELOG.md and
CHANGELOG.zh-CN.md exactly as tagged (replacing the 0.14.0-beta.2 series
entry), the 0.14.0 version in every package.json and package-lock.json, and
the generated F-Droid changelogs. The unreleased notes on main stay above the
entry and now say they are not in 0.14.0.

Release commit: 8c6de842a3 (tag v0.14.0)
2026-10-29 15:40:29 +07:00
Mohamed Boudra 7d74ae5d6c docs(release): release from tags with selected cherry-picks while main stays open (#8188)
Replace the next-branch discipline with the main-first workflow: normal PRs
always merge into main, each release builds in a short-lived release workspace
from origin/main or a release tag plus approved cherry-picks, promotions take
an inventory that defaults to defer, and a reconcile PR brings the shipped
changelog, version and F-Droid files back into main.
2026-10-29 15:08:36 +07:00
babo-jeonguk 61841d1ed5 fix(app): stop web menus snapping back to their entering-animation rect (#8180)
Reanimated's web path saves a custom keyframe's element rect when the
animation ends, and writes it back as inline top/left/width/height when
it cleans the keyframe up 750ms after the start. A menu whose content
resized in between, such as a plugin popover whose data had just
arrived, was left clipped to its loading-state size or hanging over its
anchor.

Play the same keyframes through the Web Animations API on web, which
leaves no inline style, and drop the height release that only undid
part of that write. Native keeps the Reanimated Keyframe.
2026-10-29 07:58:09 +00:00
162 changed files with 4158 additions and 1026 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: CI
on:
push:
branches: [main]
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
# Every base branch, so stacked PRs and PRs into any base get the same checks.
pull_request:
merge_group:
workflow_dispatch:
+4 -4
View File
@@ -1,6 +1,6 @@
# Changelog
Not released yet. These changes are on `main` but not in 0.14.0-beta.2; the next release entry includes them.
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
Added:
@@ -11,7 +11,7 @@ Improved:
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0-beta.2 - 2026-10-28
## 0.14.0 - 2026-10-29
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
@@ -43,7 +43,6 @@ Voice settings now let you configure speech providers and download local models
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### Fixed
@@ -72,7 +71,8 @@ Voice settings now let you configure speech providers and download local models
- Voice mode now defaults to off; enable it in Voice settings
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0
- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
## 0.13.2 - 2026-10-26
+4 -4
View File
@@ -1,6 +1,6 @@
# 更新日志
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0-beta.2 中;下一个版本的条目会包含它们。
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
新增:
@@ -11,7 +11,7 @@
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0-beta.2 - 2026-10-28
## 0.14.0 - 2026-10-29
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
@@ -43,7 +43,6 @@
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### 修复
@@ -72,7 +71,8 @@
- 语音模式默认关闭,可在语音设置中启用
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置
- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
## 0.13.2 - 2026-10-26
+3 -4
View File
@@ -117,10 +117,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
## Release branches
When the user says "this goes to next", create or
retarget the PR to `next` and preserve that destination through delivery. Follow
[release branch discipline](docs/release.md#release-branch-discipline) for creating
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each
release builds in a short-lived release workspace from `origin/main` or a release tag; see
[release base and source](docs/release.md#release-base-and-source).
## Critical rules
+10
View File
@@ -493,6 +493,16 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
## Outbound networking
The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`
(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets
share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct.
A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes
are treated as unset, with one warning naming the variable in `daemon.log`.
Agent and terminal environments are unchanged. System proxy settings and SOCKS-only
`ALL_PROXY` configurations are not supported.
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
+1
View File
@@ -38,6 +38,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
- **Check usage** — Host-wide switch in Usage Settings, on by default. Turning it off stops login discovery and vendor requests on that host for every device. Usage shows an off state with **Turn on**; the sidebar summary and agent usage sections disappear.
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
-9
View File
@@ -44,15 +44,6 @@ fall back to creating a fresh agent when continuation was requested.
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
`server_info.features.optionalMcpSupport` advertises optional MCP launch requirements.
Hub sends `toolPolicy.enforcement: "if-supported"` and
`mcpServerRequirements: { hub: "optional" }` only when this flag is true.
Without it, Hub preserves its existing strict tool-policy request. Available
providers remain selectable; providers lacking strict approval support need an
updated daemon to launch from Hub. Required external/plugin servers and explicitly
required tool policies keep their existing refusal semantics.
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
Provider controls remain provider-native; see [providers.md](providers.md).
+9 -7
View File
@@ -200,13 +200,15 @@ its own.
## Gotchas
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
identity changes — a pushed page taller than the one it replaced is clipped without that.
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
entering animation never ends at the off-screen measuring position; on a slow machine that
write moved the open menu back off-screen.
- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the
surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms
after the start. Whatever moved or resized the surface in between was undone: content that
grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved
again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which
leaves no inline style; native keeps the Reanimated `Keyframe`.
- **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves,
so the entering animation runs at the final position rather than ending unseen at the
off-screen measuring position.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it. A fill page is the one exception.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
+14
View File
@@ -35,6 +35,20 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
For Android background fold recovery, install the release APK on a foldable AVD, open a
connected workspace, close the keyboard, and run:
```bash
ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
```
The AVD must expose `CLOSED` and `OPENED` device states. The test backgrounds the app,
changes posture, resumes immediately, and asserts compact/wide workspace controls without
restarting the process. It writes snapshots and native failure state under
`.dev/agent-device-artifacts/android-fold`. Set `PASEO_MOBILE_APP_ID=sh.paseo` for a
production package. This needs a native rebuild when the Unistyles patch changes; a Metro
reload cannot update it.
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
with an empty prompt, hide its keyboard, and run:
+1 -1
View File
@@ -550,7 +550,7 @@ string is never inline SVG or a URL.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Contribute sidebar items
+3 -10
View File
@@ -30,16 +30,9 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
Exact MCP preapproval is a separate daemon-owned contract. A required tool policy
(the default) fails closed unless the provider can approve exact injected server/tool
identities without approving native tools. Native agent creation also accepts
`toolPolicy.enforcement: "if-supported"` for automatic approval convenience:
unsupported providers retain ordinary MCP configuration and native permissions.
Hub uses that optional request; MCP support and exact approval are not provider
eligibility requirements. Optional injection is declared separately with
`mcpServerRequirements: { [serverName]: "optional" }`; other injected servers
(including plugin-owned servers) keep their required semantics. The daemon
advertises this native launch contract as `features.optionalMcpSupport`. The legacy Hub create API remains strict-only.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
## Core adapter patterns
+135 -73
View File
@@ -6,10 +6,10 @@ All workspaces share one version and release together.
A release has exactly two steps. The agent does the first, the user authorizes the second.
**Preparation** (local, reversible — agent does this):
**Preparation** (reversible — agent does this):
- format, lint, typecheck all green
- resolve the release source to one commit and confirm that commit's existing CI is green
- resolve the [release base](#release-base-and-source), get the [release inventory](#release-inventory) approved when one applies, and confirm CI for the resulting release source is green
- classify the diff from the previous stable to the release source as patch or minor, then show the
target version and rationale to the user
- draft the changelog, show it to the user, wait for review
@@ -17,9 +17,10 @@ A release has exactly two steps. The agent does the first, the user authorizes t
**Go-ahead** (user says "go ahead"):
- commit the approved release inputs locally
- run the release, which publishes npm and pushes the prepared branch and tag
- commit the approved release inputs locally on the release branch
- run the release, which publishes npm and pushes the release branch and tag
- create the release heartbeat immediately and babysit it to completion
- [reconcile `main`](#reconcile-main) with what shipped
Rules that apply to both steps:
@@ -27,56 +28,128 @@ Rules that apply to both steps:
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- An approval the user already gave covers what it names, such as an explicit PR selection or authorization to release autonomously. Do not ask for it again.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Release source and CI
## Main stays open
The default release source is `origin/main`. Fetch `origin`, then record the
resolved commit. The default release checkout is a clean local `main` whose
`HEAD` equals `origin/main`.
Merge normal PRs into `main` at any time, including while a beta soaks or a release is being
finalized. Each release builds in a short-lived release workspace, and its published tag records
the shipped source.
An explicit user instruction can select another ref, such as a hotfix commit or
tag. Resolve that ref once and apply every source, diff, and CI check to that
commit instead of `origin/main`.
## Choosing a path
Before making release-preparation commits, confirm the existing CI run for the
resolved commit is green. Pending CI is watched to completion. Release
preparation then stays local through the changelog, any explicitly requested ACP
catalog update, lockfile preparation, and the version commit. After approval,
commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
The user decides when the changes on `main` warrant a release and how much risk it takes:
## Release branch discipline
1. **Direct stable release** for a low-risk batch of polish and bug fixes. Stable desktop updates
already ramp over 36 hours (see [Staged rollout](#staged-rollout-stable-channel)), and store
review often takes about a day, with no guaranteed timing.
2. **Beta flow**, the default for changes that need testing. Release candidates on the `beta`
channel. Each beta folds into the series' single changelog entry, publishes npm only on the
explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
release. This applies to both beta and stable releases.
A beta follows these defaults, which the user can explicitly override:
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
- "This goes to next" means create the PR against `next` or retarget an existing
PR, and keep that destination through delivery.
- Keep `next` current by merging `origin/main` into it as release fixes land.
Avoid rebasing this shared branch because agents and open PRs depend on its history.
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
Pass CI and merge without squashing away the individual PR commits needed for
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or
while feedback is open. Changes added in a later beta get their own soak; earlier soak time does
not count for them.
- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects
them.
- Promotion ships the tested beta's product content unchanged and does not pull from `main`.
Adding a product change needs the user's explicit selection and a new beta, which soaks again.
- During a soak, ship urgent stable regressions and compatibility or provider model updates as a
[stable hotfix](#release-base-and-source) from the latest stable tag. The beta keeps its own
pace.
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
push runs target only `main`, and GitHub permits only squash merges. Enable
required-check protection for `next`, CI on its pushes, and merge commits for the
integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
npm dist-tags are independent pointers:
### Hotfix from a release tag
- A beta release publishes with `--tag beta` and moves only `beta`; `latest` remains on the
newest stable.
- A stable release publishes to `latest`. Move `beta` to it as well when the stable version is
newer than the version `beta` points to, which covers promotions and direct stable releases, so
`@getpaseo/cli@beta` users never sit behind stable. A stable hotfix on an older line leaves
`beta` on the active newer beta.
If `main` contains changes you do not want to release, branch from the affected
release tag and cherry-pick only the required fixes. Run CI on that branch, then
use the normal release flow with it as the explicit source, choosing a new patch
or beta version. Ensure the fixes and changelog also reach `main` and any active
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track.
## Release base and source
The release base is the tag or commit a release workspace starts from. The release source is the
base plus any approved cherry-picks, and every source, diff, and CI check uses it.
| Release | Base | Changes on top |
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- |
| Starting release: first beta of a line, or direct stable | `origin/main`, captured once | None |
| Follow-up beta | The selected published beta tag, normally the series' latest | Approved commits from `main` |
| Promotion | The beta tag being promoted | None by default |
| Stable hotfix | The latest appropriate stable tag | Approved commits from `main` |
An explicit user instruction can select another base.
1. Fetch `origin` with tags and resolve the base to one commit.
2. Create the release workspace through Paseo MCP on a new branch from the base, named
`release/vX.Y.Z` or `release/vX.Y.Z-beta.N` for the version being cut. Run the release from
this workspace: `release:push` pushes `HEAD` to the current branch.
3. Cherry-pick the approved commits in their `main` order with `git cherry-pick -x`, so each
commit records the `main` commit it came from. Include the prerequisites the inventory listed.
Resolve conflicts within the approved changes and report how. A resolution that needs new
behavior is a scope addition: surface it for selection.
4. Confirm CI for the release source. When the source is a commit whose CI already passed (the
captured `origin/main`, or the beta tag being promoted), use that run and watch pending CI to
completion. Otherwise push the release branch and run `gh workflow run ci.yml --ref <branch>`,
because CI push runs target only `main`.
5. Keep the changelog, any explicitly requested ACP catalog update, lockfile preparation, and the
version commit local until the go-ahead. The release command pushes the release branch and tag
as one batch, and the tag push starts the release workflows. Then run
`gh workflow run ci.yml --ref <branch>` so CI covers the complete release commit.
6. [Reconcile `main`](#reconcile-main).
A stable hotfix can take the version a patch beta line would promote to, such as `0.13.3`
shipping while `0.13.3-beta.2` soaks. Never republish or reuse a shipped version. Continue the line
as the next unused patch beta, `0.13.4-beta.1`, from the tested beta tag. No `version:all:*` mode
computes that from a prerelease, so follow the [manual steps](#manual-step-by-step) with
`npm version 0.13.4-beta.1 --include-workspace-root --message "chore(release): cut %s"` and
`npm run release:publish:beta`. Record the tested beta tag as its source so its soak still counts
for the unchanged content.
## Release inventory
When the user asks for a follow-up beta or a promotion, list what `main` has that the base does
not, then get the batch approved. There is no release label or queue.
1. Find the commits on `origin/main` whose patches are not in the base tag:
```bash
git log --cherry-pick --right-only --no-merges --format='%h %s' <base-tag>...origin/main
```
Patch equivalence recognizes commits already cherry-picked into the tag, so trust the tag
content over PR state or branch names.
2. Present a short include/defer list. Link each PR and give one plain reason. List separately the
prerequisites an included PR depends on and anything that adds scope beyond what the beta
delivers. For each fix that shipped in a stable hotfix after the beta's base, confirm the beta
preserves its behavior, directly or through a superseding change. List the missing ones for
explicit selection; adding one to a promotion needs a new beta.
3. Wait for the user to approve the batch, unless an explicit selection or autonomous
authorization they already gave covers it.
For a promotion, every item defaults to defer.
## Reconcile main
After the release push, open a PR into `main` that brings in what shipped and keeps newer
development on `main`:
- The shipped `CHANGELOG.md` and `CHANGELOG.zh-CN.md` entries, exactly as tagged. A beta or
promotion entry replaces the series entry on `main`. A stable hotfix on an older line goes
below any newer entries.
- The shipped version in every `package.json` and `package-lock.json`, unless `main` already
carries a newer version, plus the F-Droid changelog files a stable release generates.
- No claims about unshipped work: a shipped entry describes only the tagged content, and notes
`main` holds for changes that have not shipped stay out of it.
Delete the release branch once the PR merges. The tag is the record of shipped source; Paseo
maintains no release track besides `main`.
## ACP catalog updates
@@ -91,21 +164,6 @@ ACP catalog work enters a release through an explicit user request:
The release authorization covers the requested ACP commit. It ships in the same
release push as the changelog and version commit.
## Two paths
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision
Every fresh release starts by classifying the full diff from the previous
@@ -133,7 +191,7 @@ as described in **Fixing a failed release build**.
Before running any stable release command:
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
@@ -145,9 +203,9 @@ npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable
is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only;
do not republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
@@ -156,8 +214,8 @@ for package in highlight relay protocol client plugin server cli; do
done
```
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
active newer beta, before considering the stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
@@ -175,7 +233,7 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version using the command above.
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
```
## Beta flow
@@ -322,7 +380,8 @@ There is no mobile-release workflow under `.github/workflows`. The EAS GitHub ap
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches.
- When `submit_review` fails with `appStoreVersions … is not in valid state`, the reason is in the API error's `meta.associatedErrors`, which fastlane does not print. Read it with a `POST /v1/reviewSubmissionItems` against the version. A required age-rating question that Apple added, such as `socialMedia`, fails this way; answer it under App Information > Age Rating in App Store Connect, then resubmit.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the `en-US` phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. The lane stages only `en-US`: supply uploads every locale folder in one edit, and Play rejects F-Droid locales such as `ja`, which fails the whole edit. `Upload Google Play screenshots` reruns the step for the current release without a rebuild.
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
@@ -488,7 +547,7 @@ git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
This ensures the checkout ref matches the code you want built, with the fix included.
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
@@ -631,7 +690,7 @@ Rules:
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
When listing PR numbers, `git log --cherry-pick --right-only --no-merges --format='%H %s' v<previous>...<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks.
## Changelog ordering
@@ -663,11 +722,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
### Beta release
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
@@ -678,22 +737,24 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
- [ ] A promotion ships the tested beta's product content after its soak, unless the user explicitly overrode either default
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
@@ -707,4 +768,5 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
+8
View File
@@ -358,6 +358,14 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
Electron running the old code.
The adaptive-theme patch keeps `colorScheme` reads off the media-query lists
owned by Unistyles' change listeners. Chromium defers their events while a window
is hidden; reading `matches` on those same lists can consume the pending change.
CSS variables then follow the OS theme while `withUnistyles` props retain the old
colors. Keep this patch until upstream reads the scheme through separate lists.
The desktop browser E2E checks both transition directions in an isolated Electron
process, because Playwright's focus emulation prevents real hidden-page behavior.
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
`patches/` is inert unless that script knows which installed package activates
it.
@@ -0,0 +1,10 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,10 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw=
sha256-8NOrrCAxraQ74lP3Vy59MRhloBfzStTc4rW+ltc42Fg=
+52 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37590,7 +37590,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39793,12 +39793,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -40045,10 +40045,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -40060,7 +40060,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40304,7 +40304,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40350,7 +40350,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40379,15 +40379,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40397,8 +40397,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40420,7 +40420,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40447,7 +40447,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40668,16 +40668,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40690,6 +40690,7 @@
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
@@ -40707,6 +40708,7 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.24.8",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -41280,6 +41282,15 @@
"url": "https://opencollective.com/vitest"
}
},
"packages/server/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"packages/server/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -41349,6 +41360,19 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"packages/server/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
@@ -41598,7 +41622,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43208,7 +43232,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -0,0 +1,27 @@
name: Upload Google Play screenshots
# Standalone re-trigger for the release workflow's Google Play screenshot step.
# Replaces the en-US phone and tablet screenshots with the committed renders,
# without rebuilding or resubmitting the app.
on:
workflow_dispatch:
inputs:
android_version_code:
type: string
required: false
description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release."
jobs:
upload_android_screenshots:
name: Upload Google Play screenshots
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Upload screenshots
run: |
export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}"
bundle exec fastlane android upload_screenshots
+1 -1
View File
@@ -102,7 +102,7 @@ export default {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
orientation: "portrait",
orientation: "default",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
+35
View File
@@ -59,3 +59,38 @@ describe("Android app config", () => {
30_000,
);
});
it("follows system rotation on Android", async () => {
const manifest = await resolveAndroidManifest();
const activity = manifest.application[0].activity.find(
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
);
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
}, 30_000);
it("allows all orientations on iPhone and iPad", async () => {
const { mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
try {
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
await compileModsAsync(exp, {
projectRoot: root,
introspect: true,
platforms: ["ios"],
assertMissingModProviders: false,
});
const plist = exp._internal.modResults.ios.infoPlist;
const orientations = [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
];
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -0,0 +1,134 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { createCreationScenario } from "../support/helpers/creation";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
const TOLERANCE_PX = 2;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX, y, { steps: 8 });
await page.mouse.up();
}
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const input = await page.context().newCDPSession(page);
try {
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 8; step++) {
await input.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
});
}
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
} finally {
await input.detach();
}
}
async function splitIntoFourPanes(page: Page): Promise<void> {
for (const expectedDividers of [1, 2, 3]) {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
}
async function expectDividersTrackPointer(
page: Page,
distance = DRAG_DISTANCE,
drag = dragDivider,
): Promise<void> {
for (const index of [0, 1, 2]) {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await drag(divider, distance);
await expect
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
message: `divider ${index + 1} should stay under the pointer`,
})
.toBeLessThanOrEqual(TOLERANCE_PX);
}
}
for (const hasTouch of [false, true]) {
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
test.use({ hasTouch });
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(
page,
DRAG_DISTANCE,
hasTouch ? dragDividerWithTouch : dragDivider,
);
} finally {
await workspace.cleanup();
}
});
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
page,
}) => {
await page.setViewportSize({ width: 1600, height: 900 });
const creation = await createCreationScenario(page);
try {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Reply briefly.", "Create");
await creation.expectAssistantReply();
await waitForWorkspaceTabsVisible(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(page);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
await page.reload();
await expect(splitDividers(page)).toHaveCount(3);
await expectDividersTrackPointer(page);
} finally {
await creation.cleanup();
}
});
});
}
@@ -0,0 +1,69 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
openUsageFromIcon,
openUsageOptions,
closeUsageOptions,
closeUsage,
expectNoUsageItem,
expectPinnedUsage,
} from "../support/helpers/usage-sidebar-item";
test("Check usage applies to the host and Turn on restores cards", async ({
page,
e2eWorkerClient,
}) => {
test.setTimeout(180_000);
const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true };
try {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
await openUsageOptions(page);
await page.getByRole("switch", { name: "Check usage", exact: true }).click();
await closeUsageOptions(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0);
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false });
await expectNoUsageItem(page);
const before = usage.listRequests().length;
await closeUsage(page);
await page.reload();
await openUsageFromIcon(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
expect(usage.listRequests()).toHaveLength(before);
await page.getByRole("button", { name: "Turn on", exact: true }).click();
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true });
// A separate client changes the policy, as another device would.
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } });
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } });
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
} finally {
await e2eWorkerClient.patchDaemonConfig({ usage: previous });
}
});
test("older hosts keep usage without a Check usage switch", async ({ page }) => {
test.setTimeout(180_000);
await installUsageReportsFixture(page, {
usageSettingSupported: false,
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await gotoAppShell(page);
await openUsageFromIcon(page);
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
await openUsageOptions(page);
await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0);
});
@@ -441,3 +441,31 @@ test("released hosts supply source logos through the client conversion", async (
).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-usage-modal");
});
test("hidden usage sends no requests until opened", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectNoUsageItem(page);
await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({
timeout: 90_000,
});
await openSidebarNavSettings(page);
await expectFooterItemSetting(page, "usage", false);
await leaveSettings(page);
expect(usage.listRequests()).toHaveLength(0);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
expect(usage.listRequests()).toHaveLength(1);
});
test("visible usage summary requests reports on load", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
});
+104
View File
@@ -0,0 +1,104 @@
// Run a release build with a connected workspace open and the keyboard hidden.
// ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const serial = process.env.ANDROID_SERIAL;
assert(serial, "Set ANDROID_SERIAL to a booted Android foldable emulator");
const appId = process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug";
const session = "android-fold";
const artifacts = resolve(".dev/agent-device-artifacts/android-fold");
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-android-fold"),
};
mkdirSync(artifacts, { recursive: true });
function run(binary, args) {
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 60_000 });
}
function adb(...args) {
return run("adb", ["-s", serial, ...args]);
}
function ad(...args) {
return run("agent-device", [...args, "--session", session]);
}
function pid() {
return adb("shell", "pidof", appId).trim();
}
function open() {
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
}
function stateId(name) {
const states = adb("shell", "cmd", "device_state", "print-states");
const match = states.match(new RegExp(`identifier=(\\d+), name='${name}'`));
assert(match, `Emulator must expose the ${name} posture: ${states}`);
return match[1];
}
async function expectLayout(posture) {
const compact = posture === "CLOSED";
const expectedId = compact ? "workspace-tab-switcher-trigger" : "workspace-tabs-scroll";
const unexpectedId = compact ? "workspace-tabs-scroll" : "workspace-tab-switcher-trigger";
const deadline = Date.now() + 15_000;
let nodes = [];
do {
let data;
try {
({ data } = JSON.parse(ad("snapshot", "-i", "--json")));
} catch (error) {
// During display handoff Android briefly has no capturable app window.
if (!error.stdout?.includes("insufficient foreground app content")) throw error;
await delay(200);
continue;
}
assert.equal(data.appBundleId, appId, "Snapshot must belong to Paseo");
nodes = data.nodes;
if (
nodes.some((node) => node.identifier === expectedId) &&
!nodes.some((node) => node.identifier === unexpectedId)
) {
writeFileSync(resolve(artifacts, `${posture}.json`), JSON.stringify(data, null, 2));
console.log(`PASS: ${posture} uses ${compact ? "compact" : "wide"} workspace controls`);
return;
}
await delay(200);
} while (Date.now() < deadline);
writeFileSync(resolve(artifacts, `${posture}-failure.json`), JSON.stringify(nodes, null, 2));
writeFileSync(
resolve(artifacts, "native-failure.txt"),
adb("shell", "dumpsys", "activity", "top"),
);
assert.fail(`${posture}: expected ${expectedId}, without ${unexpectedId}`);
}
const closed = stateId("CLOSED");
const opened = stateId("OPENED");
const initialState = adb("shell", "cmd", "device_state", "state").match(
/Committed state: DeviceState\{identifier=(\d+)/,
)?.[1];
assert(initialState, "Could not read the initial fold state");
try {
adb("shell", "cmd", "device_state", "state", opened);
open();
await expectLayout("OPENED");
const initialPid = pid();
for (const [posture, id] of [
["CLOSED", closed],
["OPENED", opened],
]) {
adb("shell", "input", "keyevent", "KEYCODE_HOME");
adb("shell", "cmd", "device_state", "state", id);
// Resume immediately: waiting for configuration delivery hides the regression.
open();
await expectLayout(posture);
assert.equal(pid(), initialPid, "Fold/resume must preserve the app process");
}
} finally {
adb("shell", "cmd", "device_state", "state", initialState);
ad("close");
}
@@ -28,6 +28,10 @@ interface SeedProjectDescriptor {
* prefer those wrappers over reaching for this client directly.
*/
export interface SeedDaemonClient {
patchDaemonConfig(
config: import("@getpaseo/protocol/messages").MutableDaemonConfigPatch,
): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
getDaemonConfig(): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
connect(): Promise<void>;
close(): Promise<void>;
addProject(cwd: string): Promise<{
@@ -25,6 +25,7 @@ interface UsageReportsFixtureOptions {
* app connects, so a reload can switch it.
*/
usageSupported?: boolean | (() => boolean);
usageSettingSupported?: boolean;
/** Released hosts expose provider.usage.list with no source icons. */
providerUsageListOnly?: boolean;
/** The host daemon's port; defaults to the E2E daemon. */
@@ -63,6 +64,7 @@ function withUsageSupportFeature(
message: WebSocketMessage,
enabled: boolean,
providerUsageListOnly: boolean,
usageSettingSupported: boolean,
): string | null {
const envelope = parseJson(message) as {
type?: unknown;
@@ -86,6 +88,7 @@ function withUsageSupportFeature(
...payload,
features: {
...features,
usageSetting: usageSettingSupported ? true : undefined,
usageSources: enabled && !providerUsageListOnly,
providerUsageList: enabled,
},
@@ -229,7 +232,12 @@ export async function installUsageReportsFixture(
server.onMessage((message) => {
const serverInfo =
typeof message === "string"
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
? withUsageSupportFeature(
message,
isUsageSupported(),
providerUsageListOnly,
options.usageSettingSupported ?? true,
)
: null;
ws.send(serverInfo ?? message);
});
+17 -1
View File
@@ -26,6 +26,22 @@ def stage_app_store_screenshots
root
end
# Google Play only has en-US screenshots, and some F-Droid locales in
# ANDROID_METADATA are not Play languages (Play rejects "ja"). supply uploads
# every locale folder in one edit, so give it only the en-US images.
def stage_google_play_screenshots
root = Dir.mktmpdir("google-play-screenshots")
images = File.join(ANDROID_METADATA, "en-US/images")
%w[phoneScreenshots tenInchScreenshots].each do |kind|
target = File.join(root, "en-US/images", kind)
FileUtils.mkdir_p(target)
Dir.glob(File.join(images, kind, "*.png")).sort.each do |file|
FileUtils.cp(File.realpath(file), File.join(target, File.basename(file)))
end
end
root
end
platform :ios do
desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do
@@ -132,7 +148,7 @@ platform :android do
supply(
package_name: APP_IDENTIFIER,
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
metadata_path: ANDROID_METADATA,
metadata_path: stage_google_play_screenshots,
version_code: version_code.empty? ? nil : version_code.to_i,
skip_upload_apk: true,
skip_upload_aab: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import equal from "fast-deep-equal";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import {
FlatList,
Modal,
Pressable,
Text,
View,
@@ -1,3 +1,4 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { router } from "expo-router";
@@ -22,14 +23,7 @@ import {
type ComponentType,
type RefObject,
} from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
+1 -1
View File
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import {
View,
Text,
Pressable,
Modal,
RefreshControl,
FlatList,
type ListRenderItem,
@@ -1,5 +1,6 @@
import { Modal } from "@/components/ui/modal";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
+4 -9
View File
@@ -64,12 +64,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useOpenSidebarUsage,
} from "@/usage";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -480,10 +475,10 @@ function SidebarUsageIcon({ label }: { label: string }) {
*/
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const { items } = useSidebarNavItems("footer");
const hasUsageSummary = useHasUsageSummary();
const usageSummary = useUsageSummary();
const rowsRef = useRef<View | null>(null);
const visibleItems = items.filter(
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
(item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0),
);
if (visibleItems.length === 0) return null;
return (
@@ -499,7 +494,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void
onBeforeNavigate={onBeforeNavigate}
/>
) : (
<UsageSidebarItem key={item.key} />
<UsageSidebarItem key={item.key} sources={usageSummary} />
),
)}
</View>
@@ -1,5 +1,6 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Modal, View } from "react-native";
import { View } from "react-native";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
@@ -1,6 +1,6 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
StyleSheet as RNStyleSheet,
ScrollView,
+21 -14
View File
@@ -49,7 +49,7 @@ export function ResizeHandle({
const { theme } = useUnistyles();
const finePointer = useHasFinePointer();
const pointerStatesRef = useRef(new Map<number, PointerState>());
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
const touchStateRef = useRef<PointerState | null>(null);
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
@@ -151,23 +151,30 @@ export function ResizeHandle({
const touchGesture = useMemo(() => {
const gesture = Gesture.Pan()
.runOnJS(true)
.onBegin(() => setDragging(true))
.onStart(() => {
touchDragRef.current = startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
});
.onBegin((event) => {
setDragging(true);
touchStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
drag: startResizeHandleDrag({
sizes,
index,
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
}),
};
})
.onUpdate((event) => {
if (containerSize <= 0) return;
const translation = direction === "horizontal" ? event.translationX : event.translationY;
touchDragRef.current?.move(translation / containerSize);
const state = touchStateRef.current;
if (!state || state.containerSize <= 0) return;
// Gesture Handler resets web translation when the pan activates. Measure from
// the initial press so the activation distance is included on every platform.
const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY;
state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize);
})
.onEnd(() => touchDragRef.current?.finish())
.onEnd(() => touchStateRef.current?.drag.finish())
.onFinalize(() => {
touchDragRef.current = null;
touchStateRef.current = null;
setDragging(false);
});
@@ -48,6 +48,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import {
collectAllPanes,
createDefaultLayout,
selectExplorerSidebarPaneId,
useWorkspaceLayoutStore,
type SplitNode,
type SplitPane,
@@ -183,8 +184,8 @@ export function SplitContainer({
} | null>(null);
const maximizedPaneId =
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
const explorerSidebarPaneId = useWorkspaceLayoutStore(
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
selectExplorerSidebarPaneId(state, workspaceKey),
);
const mainRoot = useMemo(
+1 -1
View File
@@ -1,3 +1,4 @@
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
@@ -12,7 +13,6 @@ import {
View,
Text,
Pressable,
Modal,
ScrollView,
Platform,
StatusBar,
@@ -0,0 +1,124 @@
import React, { act, useEffect, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { Text, View } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import { MenuRoot, MenuSurface, MenuTrigger, type Size } from "./index";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* A real browser, because the regression lives in Reanimated's web entering path: it snapshots
* the surface's rect when a custom keyframe ends and writes that rect back as inline
* top/left/width/height when it cleans the keyframe up, 750ms after the animation starts.
* Anything that moved or resized the surface in between is undone by that write.
*/
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
function mount(node: ReactNode): void {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
mounted.push({ root, container });
}
afterEach(() => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
});
const OFFSET = 12;
const PILL_POSITION = { position: "absolute", top: 700, left: 40 } as const;
const SHORT: Size = { width: 200, height: 40 };
const TALL: Size = { width: 200, height: 240 };
const WIDE: Size = { width: 480, height: 40 };
/** Inside the window between the entering animation ending and Reanimated's cleanup. */
const GROWS_BEFORE_CLEANUP_MS = 400;
/** Past Reanimated's cleanup, where the surface used to keep a fixed height. */
const GROWS_AFTER_CLEANUP_MS = 1_000;
/** Long enough after the content grew for anything that would write to the surface to land. */
const SETTLE_MS = 1_200;
/** Plain timers, not `act`: an async `act` holds every update until it resolves. */
function wait(ms: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
function element(testID: string): HTMLElement {
const found = document.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`${testID} did not render`);
return found;
}
/** Stands in for a popover whose data lands after it opened, like a plugin's first RPC. */
function GrowsAfterOpen({ afterMs, from, to }: { afterMs: number; from: Size; to: Size }) {
const [grown, setGrown] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setGrown(true), afterMs);
return () => clearTimeout(timer);
}, [afterMs]);
return <View testID="body" style={grown ? to : from} />;
}
function mountMenuAbovePill(body: ReactNode): void {
mount(
<View style={PILL_POSITION}>
<MenuRoot>
<MenuTrigger testID="pill" accessibilityLabel="Open panel">
<Text>Open panel</Text>
</MenuTrigger>
<MenuSurface
side="top"
align="start"
offset={OFFSET}
minWidth={280}
maxWidth={620}
testID="panel"
>
{body}
</MenuSurface>
</MenuRoot>
</View>,
);
act(() => element("pill").click());
}
describe("anchored menu surface on web", () => {
beforeEach(async () => {
await page.viewport(1280, 900);
});
it.each([GROWS_BEFORE_CLEANUP_MS, GROWS_AFTER_CLEANUP_MS])(
"stays on its anchor when its content grows %ims after opening",
async (afterMs) => {
mountMenuAbovePill(<GrowsAfterOpen afterMs={afterMs} from={SHORT} to={TALL} />);
await wait(afterMs + SETTLE_MS);
const pill = element("pill").getBoundingClientRect();
const panel = element("panel").getBoundingClientRect();
expect(panel.height).toBeGreaterThan(240);
expect(panel.bottom).toBeLessThanOrEqual(pill.top);
},
);
it("widens when its content widens after the entering animation", async () => {
mountMenuAbovePill(<GrowsAfterOpen afterMs={GROWS_BEFORE_CLEANUP_MS} from={SHORT} to={WIDE} />);
await wait(GROWS_BEFORE_CLEANUP_MS + SETTLE_MS);
const body = element("body").getBoundingClientRect();
const panel = element("panel").getBoundingClientRect();
expect(panel.width).toBeGreaterThanOrEqual(body.width);
});
});
@@ -1,7 +1,9 @@
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useState,
type ReactElement,
@@ -11,7 +13,6 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
Dimensions,
Modal,
Platform,
Pressable,
View,
@@ -47,56 +48,41 @@ const contentEntering = new Keyframe({
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(CONTENT_ENTERING_DURATION_MS);
/** `contentEntering` for the browser's own animation engine. Both default to linear easing. */
const WEB_CONTENT_ENTERING_KEYFRAMES: PropertyIndexedKeyframes = {
opacity: [0, 1],
transform: ["scale(0.97)", "scale(1)"],
};
/** Web plays the same keyframes itself; see `useWebContentEntering`. */
const nativeContentEntering = isWeb ? undefined : contentEntering;
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
function releaseFixedMenuHeight(surfaceNativeID: string): void {
if (!isWeb) return;
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
}
/**
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface.
* Once the menu is open, height must return to content-sized so rows can grow in place — and so
* a submenu page taller than the page it replaced is not clipped to the old height.
*
* `revision` is what makes that second case work: bump it whenever the rendered content changes
* identity (a page push, for instance) and the snapshot is released again.
* On web the surface enters through the Web Animations API rather than Reanimated. Reanimated's
* web path snapshots an element's rect when a custom keyframe ends and, when it cleans the keyframe
* up 750ms after the start, writes that rect back as inline top/left/width/height. A surface that
* moved or resized in between — content that grew once its data arrived — snapped back to the old
* rect and stayed there, clipped or hanging past its anchor. A Web Animation leaves no inline style.
*/
function useReleaseFixedMenuHeight({
contentSize,
enabled,
function useWebContentEntering({
placed,
surfaceNativeID,
revision,
}: {
contentSize: Size | null;
enabled: boolean;
placed: boolean;
surfaceNativeID: string;
revision?: string | number;
}): void {
useEffect(() => {
if (!enabled) return undefined;
const release = () => {
releaseFixedMenuHeight(surfaceNativeID);
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID));
}
};
const timers: ReturnType<typeof setTimeout>[] = [
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
];
if (contentSize) {
timers.push(setTimeout(release, 0));
}
return () => {
for (const timer of timers) clearTimeout(timer);
};
}, [contentSize, enabled, surfaceNativeID, revision]);
useLayoutEffect(() => {
if (!isWeb || !placed || typeof document === "undefined") return undefined;
const animation = document
.getElementById(surfaceNativeID)
?.animate(WEB_CONTENT_ENTERING_KEYFRAMES, CONTENT_ENTERING_DURATION_MS);
return () => animation?.cancel();
}, [placed, surfaceNativeID]);
}
/**
@@ -224,8 +210,6 @@ export interface AnchoredSurfaceProps {
fullWidth?: boolean;
horizontalPadding?: number;
scrollable?: boolean;
/** Bump when the rendered content changes identity, so the height snapshot is released. */
revision?: string | number;
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
backdrop?: boolean;
/**
@@ -259,7 +243,6 @@ export function AnchoredSurface({
fullWidth = false,
horizontalPadding = 16,
scrollable = false,
revision,
backdrop = true,
onPointerEnter,
onPointerLeave,
@@ -269,33 +252,25 @@ export function AnchoredSurface({
}: AnchoredSurfaceProps): ReactElement | null {
const { t } = useTranslation();
const surfaceNativeID = useId();
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } =
useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
});
useReleaseFixedMenuHeight({
contentSize,
enabled: open,
surfaceNativeID,
revision,
const { position, actualPlacement, visibleContentSize, onContentLayout } = useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
});
// The surface is placed once both measurements land. Until then it sits off-screen so it can
// be measured, and it must not animate: Reanimated's web entering path snapshots the element's
// rect when the animation ends and writes that rect back as inline top/left when it cleans the
// keyframe up 750ms later. If the animation ends before placement, the snapshot is the
// off-screen rect and the menu jumps back off-screen. Remounting on placement starts the
// animation from the final position.
// be measured, and it must not animate: an entering animation that ran there would be over
// before anyone saw the menu. Remounting on placement starts the native animation from the
// final position; web starts its own animation on placement.
const placed = position !== null;
useWebContentEntering({ placed, surfaceNativeID });
useEffect(() => {
if (!isWeb || !open || !placed || typeof document === "undefined") return undefined;
const frame = requestAnimationFrame(() => {
@@ -383,7 +358,7 @@ export function AnchoredSurface({
dataSet={surfaceDataSet}
style={styles.content}
frameStyle={frameStyle}
entering={placed ? contentEntering : undefined}
entering={placed ? nativeContentEntering : undefined}
exiting={
!placed || isWeb || !onExited
? undefined
@@ -344,7 +344,6 @@ function MenuFlyout({
maxHeight={maxHeight}
scrollable={scrollable}
backdrop={false}
revision={page.id}
onPointerEnter={cancelHoverClose}
onPointerLeave={handleHoverOut}
testID={testID}
+13
View File
@@ -0,0 +1,13 @@
import { Modal as NativeModal, type ModalProps } from "react-native";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
const orientations: NonNullable<ModalProps["supportedOrientations"]> = [
"portrait",
"portrait-upside-down",
"landscape-left",
"landscape-right",
];
export function Modal(props: ModalProps) {
return <NativeModal supportedOrientations={orientations} {...props} />;
}
+1 -1
View File
@@ -1,3 +1,4 @@
import { Modal } from "@/components/ui/modal";
import {
Children,
cloneElement,
@@ -16,7 +17,6 @@ import { createPortal } from "react-dom";
import {
Dimensions,
Platform,
Modal,
Pressable,
View,
type PressableProps,
@@ -455,10 +455,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
reportError: (error) => {
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
},
schedule: (task, delayMs) => {
const timeout = setTimeout(task, delayMs);
return () => clearTimeout(timeout);
},
});
viewedTimelineSyncRef.current = sync;
setViewedTimelineSync(serverId, sync);
@@ -292,7 +292,10 @@ const CATALOG_DATA = [
version: "0.6.13",
iconId: "minimax-code",
installLink: "https://agent.minimax.io",
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
// `-p <pkg>` names the package and `mcode` names its binary. Without the
// explicit binary, npx resolves the executable from the unscoped package
// name (`code`), which this package does not publish.
command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"],
},
{
id: "minion-code",
@@ -62,7 +62,9 @@ describe("ACP provider catalog", () => {
expect(findProvider("minimax-code")).toMatchObject({
title: "MiniMax Code",
version: "0.6.13",
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
// The package publishes `mcode`, not `code`, so npx needs the package and
// the binary named separately.
command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"],
});
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
});
@@ -422,6 +422,23 @@ describe("deriveAgentScreenViewState", () => {
expect(result.memory.lastReadyAgent).toBeNull();
});
it.each(["error", "retrying"] as const)("shows archived history sync %s", (status) => {
const result = deriveAgentScreenViewState({
input: {
...createBaseInput(),
agent: createAgent("agent-1"),
isArchived: true,
hasHydratedHistoryBefore: true,
visibilityCatchUpStatus: status,
},
memory: createBaseMemory(),
});
expect(expectReadyState(result.state).sync).toEqual({
status: "sync_error",
isRetrying: status === "retrying",
});
});
it("renders an archived agent before provider history is initialized", () => {
const result = deriveAgentScreenViewState({
input: {
@@ -175,10 +175,14 @@ function resolveAgentScreenSync(args: {
hadInitialSyncFailure: boolean;
}): AgentScreenReadySyncState {
const { input, hadInitialSyncFailure } = args;
if (input.isArchived) {
if (
input.isArchived &&
input.visibilityCatchUpStatus !== "error" &&
input.visibilityCatchUpStatus !== "retrying"
) {
return { status: "idle" };
}
if (!input.isConnected) {
if (!input.isConnected && !input.isArchived) {
return { status: "reconnecting" };
}
if (input.missingAgentState.kind === "error") {
+2 -8
View File
@@ -1,13 +1,7 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
+5
View File
@@ -2092,6 +2092,11 @@ export const ar: TranslationResources = {
},
},
providerUsage: {
checkUsage: "التحقق من الاستخدام",
checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة",
off: "التحقق من الاستخدام متوقف على {{host}}.",
turnOn: "تشغيل",
options: "الإعدادات",
refreshFailed: "تعذّر تحديث الاستخدام",
noHosts: "لا توجد مضيفات متصلة",
+5
View File
@@ -2117,6 +2117,11 @@ export const en = {
},
},
providerUsage: {
checkUsage: "Check usage",
checkUsageHint: "Applies to {{host}} on every device",
off: "Usage is off on {{host}}.",
turnOn: "Turn on",
options: "Settings",
refreshFailed: "Unable to refresh usage",
noHosts: "No connected hosts",
+5
View File
@@ -2140,6 +2140,11 @@ export const es: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Consultar uso",
checkUsageHint: "Se aplica a {{host}} en todos los dispositivos",
off: "El uso está desactivado en {{host}}.",
turnOn: "Activar",
options: "Configuración",
refreshFailed: "No se pudo actualizar el uso",
noHosts: "No hay hosts conectados",
+5
View File
@@ -2140,6 +2140,11 @@ export const fr: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Vérifier l’utilisation",
checkUsageHint: "S’applique à {{host}} sur tous les appareils",
off: "L’utilisation est désactivée sur {{host}}.",
turnOn: "Activer",
options: "Paramètres",
refreshFailed: "Impossible d’actualiser l’utilisation",
noHosts: "Aucun hôte connecté",
+5
View File
@@ -2113,6 +2113,11 @@ export const ja: TranslationResources = {
},
},
providerUsage: {
checkUsage: "使用状況を確認",
checkUsageHint: "すべてのデバイスで {{host}} に適用",
off: "{{host}} の使用状況確認はオフです。",
turnOn: "オンにする",
options: "設定",
refreshFailed: "使用状況を更新できません",
noHosts: "接続中のホストがありません",
+5
View File
@@ -2102,6 +2102,11 @@ export const ko: TranslationResources = {
},
},
providerUsage: {
checkUsage: "사용량 확인",
checkUsageHint: "모든 기기에서 {{host}}에 적용",
off: "{{host}}의 사용량 확인이 꺼져 있습니다.",
turnOn: "켜기",
options: "설정",
refreshFailed: "사용량을 새로 고칠 수 없습니다",
noHosts: "연결된 호스트가 없습니다",
+5
View File
@@ -2125,6 +2125,11 @@ export const ptBR: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Consultar uso",
checkUsageHint: "Aplica-se a {{host}} em todos os dispositivos",
off: "O uso está desativado em {{host}}.",
turnOn: "Ativar",
options: "Configurações",
refreshFailed: "Não foi possível atualizar o uso",
noHosts: "Nenhum host conectado",
+5
View File
@@ -2125,6 +2125,11 @@ export const ru: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Проверять использование",
checkUsageHint: "Применяется к {{host}} на всех устройствах",
off: "Проверка использования отключена на {{host}}.",
turnOn: "Включить",
options: "Настройки",
refreshFailed: "Не удалось обновить использование",
noHosts: "Нет подключённых хостов",
+5
View File
@@ -2070,6 +2070,11 @@ export const zhCN: TranslationResources = {
},
},
providerUsage: {
checkUsage: "检查用量",
checkUsageHint: "在所有设备上应用于 {{host}}",
off: "{{host}} 的用量检查已关闭。",
turnOn: "开启",
options: "设置",
refreshFailed: "无法刷新用量",
noHosts: "没有已连接的主机",
@@ -174,7 +174,6 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
onCatchUpEnded: () => undefined,
reportError: () => undefined,
schedule: () => () => undefined,
});
// The edge session-context.tsx owns. Pointing it at subscription instead would resume every
// agent behind a restored tab on the daemon.
@@ -57,7 +57,6 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
reportError: () => undefined,
schedule: () => () => undefined,
},
});
}
@@ -617,7 +616,6 @@ describe("create handoff lifetime", () => {
},
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
reportError: () => undefined,
schedule: () => () => undefined,
},
});
@@ -90,7 +90,7 @@ test("visible catch-up settles before hidden open chats start fetching", async (
world.sync.dispose();
});
test("a failed visible catch-up releases hidden chats without bypassing its retry delay", async () => {
test("a failed visible catch-up releases hidden chats without bypassing its error", async () => {
const world = new TimelineWorld();
world.openedEarlier(["a-hidden"]);
world.sync.replaceVisibleAgentIds("pane", ["z-visible"]);
@@ -103,7 +103,7 @@ test("a failed visible catch-up releases hidden chats without bypassing its retr
hidden.respond({ hasNewer: false });
await world.nextError();
world.expectNoPendingFetch();
(await world.nextRetry())();
world.sync.retryVisibleAgentTimeline("z-visible");
(await world.nextFetch("z-visible")).respond({ hasNewer: false });
world.sync.dispose();
});
@@ -200,20 +200,6 @@ class TimelineWorld {
const waiter = this.errorWaiters.shift();
if (waiter) waiter(this.errors.at(-1) ?? "");
},
schedule: (task, delayMs) => {
const scheduled = { task, delayMs };
this.scheduled.push(scheduled);
const waiterIndex = this.retryWaiters.findIndex((waiter) => waiter.delayMs === delayMs);
if (waiterIndex >= 0) {
const [waiter] = this.retryWaiters.splice(waiterIndex, 1);
this.scheduled.splice(this.scheduled.indexOf(scheduled), 1);
waiter.resolve(task);
}
return () => {
const index = this.scheduled.indexOf(scheduled);
if (index >= 0) this.scheduled.splice(index, 1);
};
},
});
show(sourceId: string, agentIds: string[]): void {
@@ -235,12 +221,6 @@ class TimelineWorld {
}> = [];
private readonly errorWaiters: Array<(message: string) => void> = [];
private readonly cacheRequestWaiters: Array<(agentId: string) => void> = [];
private readonly scheduled: Array<{ task: () => void; delayMs: number }> = [];
private readonly retryWaiters: Array<{
delayMs: number;
resolve(retry: () => void): void;
}> = [];
get pendingFetchCount(): number {
return this.fetches.length;
}
@@ -300,22 +280,6 @@ class TimelineWorld {
return new Promise((resolve) => this.errorWaiters.push(resolve));
}
nextRetry(delayMs = 1_000): Promise<() => void> {
const index = this.scheduled.findIndex((entry) => entry.delayMs === delayMs);
if (index >= 0) return Promise.resolve(this.scheduled.splice(index, 1)[0].task);
return new Promise((resolve) => this.retryWaiters.push({ delayMs, resolve }));
}
elapse(elapsedMs: number): void {
const due = this.scheduled.filter((entry) => entry.delayMs <= elapsedMs);
this.scheduled.splice(
0,
this.scheduled.length,
...this.scheduled.filter((entry) => entry.delayMs > elapsedMs),
);
for (const entry of due) entry.task();
}
private releaseMembershipWaiter(): void {
const waiter = this.membershipWaiters.shift();
if (!waiter) return;
@@ -483,6 +447,28 @@ test("reports a chat current only once its parked follow-up page completes", asy
world.sync.dispose();
});
test("a gap during manual retry is recovered before reporting ready", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-a")).fail("unavailable");
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
const retry = await world.nextFetch("agent-a");
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 });
retry.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.pendingFetchCount).toBe(1));
const recovery = await world.nextFetch("agent-a");
expect(recovery.request).toMatchObject({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 9 },
});
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
recovery.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("reports a chat current once the latest-tail fallback replaces an overflowing catch-up", async () => {
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
@@ -780,6 +766,21 @@ test("overlapping sources deduplicate membership and retain open hidden chats",
world.expectNoPendingFetch();
});
test("failed timeline fetch does not schedule a retry", async () => {
vi.useFakeTimers();
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-a")).fail("unavailable");
await world.nextError();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(120_000);
world.expectNoPendingFetch();
world.expectNoPendingMembership();
vi.useRealTimers();
});
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -788,12 +789,12 @@ test("a failed catch-up reports once and retries through the explicit retry poli
membership.succeed();
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]);
const error = await world.nextError();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retryCatchUp();
world.sync.retryVisibleAgentTimeline("agent-a");
const retry = await world.nextFetch("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
retry.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
@@ -804,35 +805,6 @@ test("a failed catch-up reports once and retries through the explicit retry poli
world.expectNoPendingMembership();
});
test("a failed catch-up retries with exponential backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const first = await world.nextFetch("agent-a");
first.fail("timeline unavailable");
const [firstError, retryAfterFirstFailure] = await Promise.all([
world.nextError(),
world.nextRetry(),
]);
expect(firstError).toBe("timeline unavailable");
retryAfterFirstFailure();
const second = await world.nextFetch("agent-a");
second.fail("timeline unavailable");
await world.nextError();
const retryAfterSecondFailure = await world.nextRetry(2_000);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retryAfterSecondFailure();
const third = await world.nextFetch("agent-a");
third.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("manual retries can immediately re-attempt a failed catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -842,7 +814,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () =>
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
await world.nextRetry();
await world.nextError();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
world.sync.retryVisibleAgentTimeline("agent-a");
@@ -853,7 +825,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () =>
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => {
test("redeclaring unchanged visibility does not bypass catch-up error", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
@@ -862,9 +834,11 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
await world.nextRetry();
await world.nextError();
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.setActive(false);
world.sync.setActive(true);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
@@ -880,7 +854,7 @@ test("a manual retry that fails returns to the error state", async () => {
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
await world.nextRetry();
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
@@ -943,20 +917,34 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as
gapPage.respond({ hasNewer: false });
});
test("membership failure autonomously retries without another visibility declaration", async () => {
test("failed membership does not schedule a retry", async () => {
vi.useFakeTimers();
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).fail("unavailable");
await world.nextError();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(120_000);
world.expectNoPendingFetch();
world.expectNoPendingMembership();
vi.useRealTimers();
});
test("membership failure waits for manual retry", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
const error = await world.nextError();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retryMembership();
world.sync.retryVisibleAgentTimeline("agent-a");
const retry = await world.nextMembership();
retry.succeed();
const catchUp = await world.nextFetch("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
catchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
@@ -967,44 +955,68 @@ test("membership failure autonomously retries without another visibility declara
});
});
test("membership failures retry with exponential backoff", async () => {
test("retrying membership restores a previously loaded chat", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const first = await world.nextMembership();
first.fail("subscription unavailable");
const [firstError, retryAfterFirstFailure] = await Promise.all([
world.nextError(),
world.nextRetry(),
]);
expect(firstError).toBe("subscription unavailable");
retryAfterFirstFailure();
const second = await world.nextMembership();
second.fail("subscription unavailable again");
const retryAfterSecondFailure = await world.nextRetry(2_000);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retryAfterSecondFailure();
const retry = await world.nextMembership();
retry.succeed();
const catchUp = await world.nextFetch("agent-a");
catchUp.respond({ hasNewer: false });
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-a")).respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
world.show("workspace", ["agent-b"]);
(await world.nextMembership()).fail("subscription unavailable");
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-b")).respond({ hasNewer: false });
const resumed = await world.nextFetch("agent-a");
expect(resumed.request).toMatchObject({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 10 },
});
resumed.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("redeclaring unchanged visibility does not bypass membership backoff", async () => {
test("a fetch that finishes after membership failure cannot clear the error", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
const inFlight = await world.nextFetch("agent-a");
world.show("workspace", ["agent-b"]);
(await world.nextMembership()).fail("subscription unavailable");
await world.nextError();
inFlight.respond({ hasNewer: false });
await Promise.resolve();
await Promise.resolve();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
});
test("a failed membership retry returns to error", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).fail("unavailable");
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
(await world.nextMembership()).fail("still unavailable");
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"));
});
test("redeclaring unchanged visibility does not bypass membership error", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
await world.nextRetry();
await world.nextError();
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.setActive(false);
world.sync.setActive(true);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
@@ -1050,7 +1062,6 @@ test("backgrounding preserves subscriptions and returning verifies the visible c
world.sync.setActive(false);
world.expectNoPendingMembership();
world.elapse(60_000);
world.sync.setActive(true);
world.expectNoPendingMembership();
@@ -1067,11 +1078,10 @@ test("stale membership retry cannot overwrite a newer effective set", async () =
world.show("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const staleRetry = await world.nextRetry();
await world.nextError();
world.show("workspace", ["agent-b"]);
const current = await world.nextMembership();
staleRetry();
current.succeed();
const agentB = await world.nextFetch("agent-b");
agentB.respond({ hasNewer: false });
@@ -1088,10 +1098,9 @@ test("membership retry cannot run while disconnected", async () => {
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const disconnectedRetry = await world.nextRetry();
await world.nextError();
world.sync.setConnected(false);
disconnectedRetry();
world.expectNoPendingMembership();
world.sync.setConnected(true);
const restored = await world.nextMembership();
@@ -1118,7 +1127,6 @@ test("returning to an open hidden agent stays live after inactivity", async () =
agentBCatchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
world.elapse(60_000);
world.show("workspace", ["agent-a"]);
world.expectNoPendingMembership();
@@ -329,7 +329,6 @@ export interface ViewedTimelineSyncPorts {
*/
onCatchUpEnded(agentId: string): void;
reportError(error: unknown): void;
schedule(task: () => void, delayMs: number): () => void;
}
export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying";
@@ -423,26 +422,14 @@ export function createViewedTimelineOwner(input: {
};
}
const RETRY_DELAY_MS = 1_000;
const MAX_RETRY_DELAY_MS = 30_000;
type CatchUpStatus = "running" | "complete" | "error";
interface CatchUpState {
generation: number;
status: CatchUpStatus;
request?: ProjectedTimelineForwardFetchPlan;
cancelRetry?: () => void;
retryDelayMs?: number;
}
const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => {
if (previousDelayMs == null) {
return RETRY_DELAY_MS;
}
return Math.min(previousDelayMs * 2, MAX_RETRY_DELAY_MS);
};
function isSameCatchUpRequest(
left: ProjectedTimelineForwardFetchPlan | undefined,
right: ProjectedTimelineForwardFetchPlan | undefined,
@@ -472,9 +459,7 @@ function decideCatchUp(input: {
}
return "replace";
}
return input.current.status === "running" || input.current.status === "complete"
? "keep"
: "replace";
return "keep";
}
function normalizeAgentIds(agentIds: string[]): string[] {
@@ -494,8 +479,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
const visibilityCatchUpPending = new Set<string>();
const visibilityCatchUpErrors = new Map<string, string>();
// User-initiated retries only. Background retries stay silent; a retry the user asked for
// owes them a pending state until it settles.
// A user-initiated retry owes a pending state until it settles.
const manualRetries = new Set<string>();
const loadedCache = new Set<string>();
const cacheLoads = new Map<string, Promise<void>>();
@@ -511,8 +495,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
let acknowledged: string[] = [];
let membershipGeneration = 0;
let membershipNeedsRetry = false;
let membershipRetryDelayMs: number | undefined;
let cancelMembershipRetry: (() => void) | null = null;
// Chats this session has opened. Visibility is the only way in, so an agent is never
// subscribed — and never resumed on the daemon — until the user actually opens it.
// Membership then survives hiding, workspace switches, backgrounding and reconnect;
@@ -563,7 +545,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const cancelCatchUp = (agentId: string) => {
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
catchUps.get(agentId)?.cancelRetry?.();
catchUps.delete(agentId);
pendingCatchUps.delete(agentId);
};
@@ -607,19 +588,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
setVisibilityCatchUpReady(agentId);
} catch (error) {
if (catchUps.get(agentId)?.generation === generation) {
const nextRetryDelayMs = getNextRetryDelayMs(catchUps.get(agentId)?.retryDelayMs);
const cancelRetry = ports.schedule(() => {
const current = catchUps.get(agentId);
if (current?.generation !== generation || current.status !== "error") return;
startCatchUp(agentId);
}, nextRetryDelayMs);
catchUps.set(agentId, {
generation,
status: "error",
request,
cancelRetry,
retryDelayMs: nextRetryDelayMs,
});
catchUps.set(agentId, { generation, status: "error", request });
setVisibilityCatchUpError([agentId], error);
ports.reportError(error);
}
@@ -686,17 +655,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (decision === "keep") {
return;
}
current?.cancelRetry?.();
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
catchUpGenerations.set(agentId, generation);
const retryDelayMs =
supersede || current?.status !== "error" ? undefined : current.retryDelayMs;
catchUps.set(agentId, {
generation,
status: "running",
request: nextRequest,
retryDelayMs,
});
catchUps.set(agentId, { generation, status: "running", request: nextRequest });
pendingCatchUps.delete(agentId);
void fetchUntilCurrent(
agentId,
@@ -715,7 +676,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
for (const agentId of ordered) {
const pendingCatchUp = pendingCatchUps.get(agentId);
const current = catchUps.get(agentId);
// Completion of a different chat must not bypass a failed chat's backoff or
// Completion of a different chat must not bypass a failed chat's error or
// supersede an in-flight tail that owns its parked gap recovery.
if (current && !(current.status === "complete" && pendingCatchUp)) continue;
startCatchUp(agentId, {
@@ -741,42 +702,31 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
} catch (error) {
if (disposed || !connected || generation !== membershipGeneration) return;
membershipNeedsRetry = true;
acknowledged = [];
for (const agentId of requested) cancelCatchUp(agentId);
setVisibilityCatchUpError(requested, error);
cancelMembershipRetry?.();
const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs);
cancelMembershipRetry = ports.schedule(() => {
cancelMembershipRetry = null;
if (disposed || !connected || membershipGeneration !== generation) return;
void reconcileMembership();
}, nextRetryDelayMs);
membershipRetryDelayMs = nextRetryDelayMs;
ports.reportError(error);
return;
}
if (disposed || !connected || generation !== membershipGeneration) return;
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipRetryDelayMs = undefined;
acknowledged = requested;
startAcknowledgedCatchUps();
};
const retryVisibleAgentTimeline = (agentId: string) => {
if (!isDesired(agentId) || manualRetries.has(agentId)) return;
if (!connected || !isDesired(agentId) || manualRetries.has(agentId)) return;
const catchUp = catchUps.get(agentId);
const membershipRetryable = membershipNeedsRetry && connected;
if (catchUp?.status !== "error" && !membershipRetryable) return;
manualRetries.add(agentId);
notifyListeners();
if (catchUp?.status === "error") {
catchUp.cancelRetry?.();
startCatchUp(agentId, { request: catchUp.request, supersede: true });
if (membershipRetryable) {
void reconcileMembership();
return;
}
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipRetryDelayMs = undefined;
void reconcileMembership();
if (catchUp?.status === "error") {
startCatchUp(agentId, { request: catchUp.request, supersede: true });
}
};
const commitDesiredMembership = (
@@ -817,8 +767,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
ensureCacheLoaded(agentId);
}
}
cancelMembershipRetry?.();
cancelMembershipRetry = null;
desired = nextDesired;
ports.replaceDemandedAgentIds(desired);
membershipGeneration += 1;
@@ -869,6 +817,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
return;
}
for (const agentId of visibleAgentIds()) {
if (visibilityCatchUpErrors.has(agentId)) continue;
visibilityCatchUpPending.add(agentId);
visibilityCatchUpErrors.delete(agentId);
startCatchUp(agentId, { supersede: true });
@@ -880,9 +829,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
connected = nextConnected;
if (!connected) {
commitDesiredMembership(desired, { resetCatchUpStatus: true });
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipRetryDelayMs = undefined;
void observation?.handle.release().catch(ports.reportError);
observation = null;
acknowledged = [];
@@ -894,7 +840,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
void reconcileMembership();
},
recoverGap(agentId, cursor) {
if (!isDesired(agentId)) return;
if (!isDesired(agentId) || catchUps.get(agentId)?.status === "error") return;
startCatchUp(agentId, {
request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }),
supersede: true,
@@ -904,10 +850,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
disposed = true;
void observation?.handle.release().catch(ports.reportError);
observation = null;
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipNeedsRetry = false;
membershipRetryDelayMs = undefined;
sources.clear();
membershipGeneration += 1;
for (const agentId of desired) cancelCatchUp(agentId);
+10 -3
View File
@@ -21,10 +21,12 @@ export interface UsageHostSelection {
*/
export function UsageControls({
view,
serverId,
onRefresh,
hostSelection,
}: {
view: UsageView;
serverId: string;
onRefresh: () => void;
hostSelection?: UsageHostSelection;
}) {
@@ -43,8 +45,8 @@ export function UsageControls({
) : null}
{view.kind === "unavailable" ? null : (
<>
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
<UsageOptions />
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
<UsageOptions serverId={serverId} />
</>
)}
</View>
@@ -84,7 +86,12 @@ export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
const controls = useMemo(
() =>
hasControls ? (
<UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />
<UsageControls
serverId={hostSelection.serverId}
view={view}
onRefresh={refresh}
hostSelection={hostSelection}
/>
) : null,
[hasControls, hostSelection, refresh, view],
);
-8
View File
@@ -1,7 +1,5 @@
import { useCallback, useMemo } from "react";
import { useAppSettings } from "@/hooks/use-settings";
import { useUsageHostId } from "./hosts";
import { useUsageHostReports } from "./queries";
import {
isUsagePinned,
setUsageDisplayAs,
@@ -25,12 +23,6 @@ interface UsagePreferencesState {
display: UsageDisplay;
}
/** The device's usage preferences, with pins resolved against a host's reports. */
export function useUsagePreferences(serverId?: string): UsagePreferencesState {
const defaultServerId = useUsageHostId();
return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId));
}
/** The device's usage preferences, with pins resolved against the given reports. */
export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState {
const { settings, updateSettings } = useAppSettings();
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { useUsagePreferences } from "./display";
import { useUsageDisplay } from "./display";
import { useHostUsage } from "./queries";
import { UsageSection } from "./usage-section";
@@ -7,7 +7,7 @@ import { UsageSection } from "./usage-section";
export function HostUsageSection({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const { view, refresh } = useHostUsage(serverId);
const { display } = useUsagePreferences(serverId);
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
return (
<UsageSection
serverId={serverId}
+1 -1
View File
@@ -4,6 +4,6 @@ export { useHostReportsUsage } from "./queries";
export {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useUsageSummary,
useOpenSidebarUsage,
} from "./sidebar-item";
+12
View File
@@ -405,3 +405,15 @@ it("available and retained usage show the same successful completion time", () =
);
expect(upsertReport([failure], success)).toEqual([failure]);
});
it("disabled host shows off even with cached reports", () => {
expect(
resolveUsageView({
hostLabel: "Laptop",
isConnected: true,
supportsUsage: true,
enabled: false,
query: { data: [], error: null, isFetching: false },
}),
).toMatchObject({ kind: "off" });
});
+3
View File
@@ -106,6 +106,7 @@ export interface UsageQueryState {
export function resolveUsageView(
input: {
enabled?: boolean;
hostLabel: string;
isConnected: boolean;
supportsUsage: boolean;
@@ -125,6 +126,8 @@ export function resolveUsageView(
message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
};
}
if (input.enabled === false)
return { kind: "off", message: t("providerUsage.off", { host: hostLabel }) };
if (query?.data) {
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
}
+27 -10
View File
@@ -8,15 +8,20 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-
import { useIsCompactFormFactor } from "@/constants/layout";
import { SettingsRow, SettingsSwitch } from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { Alert } from "@/components/ui/alert";
import { useHosts } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import { useUsagePreferences } from "./display";
import { useUsageSetting } from "./setting";
import { useUsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageDisplayAs } from "./preferences";
const ThemedSettings = withUnistyles(Settings);
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
export function UsageOptions() {
export function UsageOptions({ serverId }: { serverId: string | null }) {
const setting = useUsageSetting(serverId);
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
const { t } = useTranslation();
const displayAsOptions: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
@@ -27,7 +32,7 @@ export function UsageOptions() {
},
];
const compact = useIsCompactFormFactor();
const { display } = useUsagePreferences();
const { display } = useUsageDisplay([]);
const { inSidebar, setInSidebar } = useUsageInSidebar();
return (
<DropdownMenu compactMode="sheet">
@@ -49,13 +54,25 @@ export function UsageOptions() {
testID="usage-options-surface"
>
<View testID="usage-options-fields">
<SettingsSwitch
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
{setting.supported ? (
<SettingsSwitch
label={t("providerUsage.checkUsage")}
hint={t("providerUsage.checkUsageHint", { host: hostLabel })}
disabled={!setting.ready}
value={setting.enabled}
onValueChange={setting.setEnabled}
/>
) : null}
<View style={setting.supported ? settingsStyles.rowBorder : undefined}>
<SettingsSwitch
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
</View>
<View style={settingsStyles.rowBorder}>
<SettingsRow label={t("providerUsage.displayAs")}>
<SegmentedControl
+9 -3
View File
@@ -17,6 +17,7 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import { useUsageSetting } from "./setting";
import { usageCopy } from "./copy";
import {
replaceReport,
@@ -132,13 +133,14 @@ function toQueryState(query: {
/** Usage reports for one host, as shown on its settings page. */
export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
const query = useFetchQuery({
queryKey: usageReportsQueryKey(serverId),
queryFn: () => listReports(queryClient, serverId),
enabled: isConnected && isSupported,
enabled: isConnected && isSupported && setting.enabled,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
@@ -150,6 +152,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const view = resolveUsageView(
{
hostLabel,
enabled: setting.ready ? setting.enabled : undefined,
isConnected,
supportsUsage: isSupported,
query: toQueryState(query),
@@ -166,14 +169,16 @@ const NO_REPORTS: UsageReportEntry[] = [];
* load, or without a host.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const query = useFetchQuery({
enabled: setting.enabled,
queryKey: usageReportsQueryKey(serverId ?? ""),
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
return query.data ?? NO_REPORTS;
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
}
/**
@@ -209,7 +214,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView
export function useHostReportsUsage(serverId: string): boolean {
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
return isConnected && isSupported;
const setting = useUsageSetting(serverId);
return isConnected && isSupported && setting.enabled;
}
/** Every host with whether it is connected and reports usage, in host order. */
+26
View File
@@ -0,0 +1,26 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback } from "react";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useSessionStore } from "@/stores/session-store";
/** Host policy comes from the live daemon config replica. */
export function useUsageSetting(serverId: string | null) {
const supported = useSessionStore((state) =>
Boolean(serverId && state.sessions[serverId]?.serverInfo?.features?.usageSetting),
);
const { config, patchConfig } = useDaemonConfig(supported ? serverId : null);
const mutation = useMutation({
mutationFn: (enabled: boolean) => patchConfig({ usage: { enabled } }),
});
const setEnabled = mutation.mutate;
const turnOn = useCallback(() => setEnabled(true), [setEnabled]);
return {
supported,
error: mutation.error,
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
enabled: !supported || (config !== null && config.usage?.enabled !== false),
ready: !supported || config !== null,
setEnabled,
turnOn,
};
}
+8 -11
View File
@@ -17,7 +17,8 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useUsagePreferences } from "./display";
import { useUsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import { useUsageHostId } from "./hosts";
import { useUsageHostReports } from "./queries";
import { UsageSourceIcon } from "./source-icon";
@@ -33,28 +34,24 @@ import {
import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
function useUsageSummary(): readonly PinnedUsageSource[] {
export function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
const { preferences } = useUsagePreferences();
const reports = useUsageHostReports(useUsageHostId());
const { inSidebar } = useUsageInSidebar();
const serverId = useUsageHostId();
const reports = useUsageHostReports(inSidebar ? serverId : null);
const { preferences } = useUsageDisplay(reports);
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
/** Whether the sidebar Usage item has anything to show. */
export function useHasUsageSummary(): boolean {
return useUsageSummary().length > 0;
}
/**
* The sidebar footer's usage entry: each summary window's source icon and percent, and nothing
* while no summary window has data, since the footer's Usage icon already opens Usage.
* Pressing it opens the Usage modal.
*/
export function UsageSidebarItem() {
export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSource[] }) {
const { t } = useTranslation();
const label = t(builtinSidebarNavLabelKey("usage"));
const openUsage = useOpenSidebarUsage();
const sources = useUsageSummary();
if (sources.length === 0) return null;
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
}
+1
View File
@@ -10,6 +10,7 @@ export type UsageBalanceUnit = UsageBalance["unit"];
/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */
export type UsageView =
| { kind: "off"; message: string }
| { kind: "unavailable"; message: string }
| { kind: "loading" }
| { kind: "error"; message: string }
+2 -2
View File
@@ -4,7 +4,7 @@ import { View } from "react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useHostUsageWithControls } from "./controls";
import { useUsagePreferences } from "./display";
import { useUsageDisplay } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
import { UsageBody, UsageMessage } from "./usage-section";
@@ -52,12 +52,12 @@ function HostUsageModal({
hosts: UsageHost[];
onSelectHost: (serverId: string) => void;
}) {
const { display } = useUsagePreferences();
const hostSelection = useMemo(
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
return (
<UsageModalFrame {...props} actions={controls}>
<View testID={`usage-host-${serverId}`}>
+12 -2
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import type { UsageDisplay } from "./display";
import { useUsageSetting } from "./setting";
import { UsageControls } from "./controls";
import { UsageList } from "./list";
import type { UsageView } from "./types";
@@ -27,8 +28,8 @@ export function UsageSection({
testID?: string;
}) {
const trailing = useMemo(
() => <UsageControls view={view} onRefresh={onRefresh} />,
[onRefresh, view],
() => <UsageControls serverId={serverId} view={view} onRefresh={onRefresh} />,
[onRefresh, view, serverId],
);
return (
@@ -51,6 +52,15 @@ export function UsageBody({
onRefresh: () => void;
}) {
const { t } = useTranslation();
const setting = useUsageSetting(serverId);
if (view.kind === "off")
return (
<View>
<UsageMessage text={view.message} />
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
<Button onPress={setting.turnOn}>{t("providerUsage.turnOn")}</Button>
</View>
);
if (view.kind === "unavailable") {
return <UsageMessage text={view.message} />;
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"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.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+14 -6
View File
@@ -2,6 +2,7 @@ export { createHostChannelTransport } from "../hosts/index.js";
import { HostTransportBudget } from "./buffering.js";
export { HostTransportBudget } from "./buffering.js";
import { homedir } from "node:os";
import type { Agent } from "node:http";
import { WebSocket } from "ws";
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js";
@@ -19,10 +20,15 @@ export { parseHostTarget };
export type { SshTunnel } from "./ssh-tunnel.js";
export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js";
export interface HostTransportOptions {
webSocketAgent?: (url: string) => Agent | undefined;
}
/** A factory owns one attempt, including a fresh SSH process and its teardown. */
export function createHostTransportFactory(
connection: string,
budget = new HostTransportBudget(),
options: HostTransportOptions = {},
): DaemonTransportFactory {
const target = parseHostTarget(connection);
// Rises to "protected" once the daemon negotiates it, so a later attempt
@@ -68,12 +74,13 @@ export function createHostTransportFactory(
return;
}
const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir());
let factory = createWebSocketTransportFactory((url, options) => {
let factory = createWebSocketTransportFactory((url, socketOptions) => {
const socket = new WebSocket(
socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url,
options?.protocols,
socketOptions?.protocols,
{
headers: options?.headers,
headers: socketOptions?.headers,
agent: socketPath ? undefined : options.webSocketAgent?.(url),
...(socketPath ? { socketPath } : {}),
maxPayload: 100 * 1024 * 1024,
},
@@ -191,14 +198,15 @@ export function createHostTransportFactory(
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url" | "transportFactory">,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
const { webSocketAgent, ...clientConfig } = config;
return new DaemonClient({
...config,
...clientConfig,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection, budget),
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
});
}
@@ -0,0 +1,158 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import electronPath from "electron";
import { build } from "esbuild";
export async function runAdaptiveThemeRegression(artifactDir) {
const { outputFiles } = await build({
stdin: {
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
contents: `
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
StyleSheet.configure({
themes: {
dark: { foreground: "#fafafa", background: "#181b1a" },
light: { foreground: "#1a1a1e", background: "#ffffff" },
},
settings: { adaptiveThemes: true },
});
const Message = withUnistyles(({ color }) =>
createElement("p", { style: { color } }, "Theme-reactive message"));
createRoot(document.getElementById("root")).render(createElement(Message, {
style: { margin: 0 },
uniProps: theme => ({ color: theme.foreground }),
}));
window.readThemeForTest = () => UnistylesRuntime.themeName;
`,
},
bundle: true,
write: false,
format: "iife",
platform: "browser",
alias: { "react-native": "react-native-web" },
define: { __DEV__: "false" },
resolveExtensions: [".web.js", ".js", ".json"],
});
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-adaptive-theme-"));
try {
await fs.writeFile(path.join(directory, "renderer.js"), outputFiles[0].text);
await fs.writeFile(path.join(directory, "main.cjs"), `(${electronRegression.toString()})()`);
// A separate process avoids Playwright's focus emulation, which keeps hidden pages visible.
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
const args = [
...(process.platform === "linux" ? ["--no-sandbox"] : []),
path.join(directory, "main.cjs"),
artifactDir,
];
const command = process.platform === "linux" ? "xvfb-run" : electronPath;
const commandArgs =
process.platform === "linux"
? ["-a", "--server-args=-screen 0 1280x800x24", electronPath, ...args]
: args;
await promisify(execFile)(command, commandArgs, {
env,
timeout: 30_000,
});
return JSON.parse(await fs.readFile(path.join(artifactDir, "adaptive-theme.json"), "utf8"));
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
}
async function electronRegression() {
const { app, BrowserWindow, nativeTheme } = require("electron");
const assert = require("node:assert/strict");
const files = require("node:fs");
const paths = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
app.setPath("userData", paths.join(__dirname, "profile"));
app.on("window-all-closed", () => {});
// Electron keeps Linux's --no-sandbox in argv; the artifact directory is always last.
const artifactDir = process.argv.at(-1);
let window;
const observations = [];
const colors = {
dark: { foreground: "rgb(250, 250, 250)", background: "rgb(24, 27, 26)" },
light: { foreground: "rgb(26, 26, 30)", background: "rgb(255, 255, 255)" },
};
async function read(expression) {
return window.webContents.executeJavaScript(expression);
}
async function expectValue(expression, expected) {
let actual;
for (let attempt = 0; attempt < 50; attempt++) {
actual = await read(expression);
if (actual === expected) return;
await delay(50);
}
assert.equal(actual, expected, expression);
}
try {
await app.whenReady();
if (process.platform === "darwin") {
app.setActivationPolicy("accessory");
app.dock.hide();
}
for (const [from, to] of [
["dark", "light"],
["light", "dark"],
]) {
nativeTheme.themeSource = from;
window = new BrowserWindow({ width: 600, height: 200, show: false, skipTaskbar: true });
await window.loadURL(
"data:text/html," +
encodeURIComponent(`<!doctype html>
<style>body { background: var(--background); } p { font: 20px system-ui; }</style>
<div id="root"></div>`),
);
await read(files.readFileSync(paths.join(__dirname, "renderer.js"), "utf8"));
window.showInactive();
const foreground =
"document.querySelector('p') && getComputedStyle(document.querySelector('p')).color";
const background = "getComputedStyle(document.body).backgroundColor";
await expectValue(foreground, colors[from].foreground);
await expectValue(background, colors[from].background);
window.hide();
await expectValue("document.visibilityState", "hidden");
nativeTheme.themeSource = to;
await expectValue(`matchMedia('(prefers-color-scheme: ${to})').matches`, true);
// Reading the public runtime while hidden must not consume the pending theme notification.
assert.equal(await read("window.readThemeForTest()"), to);
window.showInactive();
await expectValue("document.visibilityState", "visible");
await expectValue(background, colors[to].background);
try {
await expectValue(foreground, colors[to].foreground);
} finally {
files.writeFileSync(
paths.join(artifactDir, `theme-${from}-to-${to}.png`),
(await window.webContents.capturePage()).toPNG(),
);
}
observations.push({
from,
to,
foreground: await read(foreground),
background: await read(background),
});
window.destroy();
window = undefined;
}
files.writeFileSync(
paths.join(artifactDir, "adaptive-theme.json"),
JSON.stringify(observations, null, 2),
);
app.exit(0);
} catch (error) {
console.error(error);
window?.destroy();
app.exit(1);
}
}
+8 -1
View File
@@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { chromium } from "playwright";
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
@@ -1241,6 +1242,7 @@ async function main() {
return;
}
const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir);
await runAppearanceFontSizeRegression(page);
const callerAgentId = await createCallerAgent(daemonPort);
@@ -1265,7 +1267,12 @@ async function main() {
artifactDir,
});
const pluginLinks = await checkPluginLinks();
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks });
writeJson(path.join(artifactDir, "result.json"), {
...report,
settingsMemory,
pluginLinks,
adaptiveTheme,
});
console.log(
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -77,8 +77,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -88,8 +88,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
-4
View File
@@ -509,14 +509,10 @@ export interface McpToolRef {
}
export interface ToolPolicy {
/** Missing or required fails closed; if-supported requests approval convenience only. */
enforcement?: "required" | "if-supported";
preapproved: McpToolRef[];
}
export interface AgentSessionConfig {
/** Per-server launch requirement; unspecified servers are required. */
mcpServerRequirements?: Record<string, "optional" | "required">;
provider: AgentProvider;
cwd: string;
/**
-35
View File
@@ -571,38 +571,3 @@ test.each(["all", "base", "uncommitted"] as const)(
expect(parsed.attachments).toEqual([attachment]);
},
);
test("native agent creation accepts optional approval without weakening legacy strict acknowledgement", () => {
const toolPolicy = {
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
enforcement: "if-supported",
};
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "req",
config: { provider: "pi", cwd: "/tmp", toolPolicy },
});
expect(parsed).toMatchObject({ config: { toolPolicy } });
expect(
SessionInboundMessageSchema.safeParse({
type: "hub.execution.agent.create.request",
requestId: "req",
executionId: "exec",
provider: "pi",
cwd: "/tmp",
prompt: "test",
toolPolicy: { preapproved: toolPolicy.preapproved },
}).success,
).toBe(true);
expect(
SessionInboundMessageSchema.safeParse({
type: "hub.execution.agent.create.request",
requestId: "req",
executionId: "exec",
provider: "pi",
cwd: "/tmp",
prompt: "test",
toolPolicy,
}).success,
).toBe(false);
});
+6 -5
View File
@@ -229,6 +229,8 @@ export const MutableDaemonConfigSchema = z
.optional(),
app: z.object({ baseUrl: z.string() }).optional(),
catalogRefreshTimeoutMs: z.number().int().positive().optional(),
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
usage: z.object({ enabled: z.boolean() }).optional(),
browserTools: MutableBrowserToolsConfigSchema.default({ enabled: false }),
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }),
@@ -256,6 +258,7 @@ export const MutableDaemonConfigPatchSchema = z
.object({
relay: MutableRelayConfigSchema.partial().optional(),
mcp: z.object({ injectIntoAgents: z.boolean().optional() }).passthrough().optional(),
usage: z.object({ enabled: z.boolean() }).optional(),
browserTools: MutableBrowserToolsConfigSchema.partial().optional(),
providers: z
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
@@ -490,11 +493,8 @@ const AgentSessionConfigSchema = z.object({
featureValues: z.record(z.string(), z.unknown()).optional(),
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
providerOptions: ProviderOptionsSchema.optional(),
toolPolicy: ToolPolicySchema.extend({
enforcement: z.enum(["required", "if-supported"]).optional(),
}).optional(),
toolPolicy: ToolPolicySchema.optional(),
systemPrompt: z.string().optional(),
mcpServerRequirements: z.record(z.string(), z.enum(["optional", "required"])).optional(),
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
});
@@ -3674,9 +3674,10 @@ export const ServerInfoStatusPayloadSchema = z
internalWorkspaces: z.boolean().optional(),
backgroundWorkspaces: z.boolean().optional(),
providersSnapshot: z.boolean().optional(),
optionalMcpSupport: z.boolean().optional(),
connectedHosts: z.boolean().optional(),
hostMultiplexing: z.boolean().optional(),
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
usageSetting: z.boolean().optional(),
usageSources: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+9 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.14.0-beta.2",
"version": "0.14.0",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -107,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.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -124,6 +124,7 @@
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
@@ -141,6 +142,7 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.24.8",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -0,0 +1,108 @@
// Opt-in diagnostic for #3905. Run from the repository root:
// node --import tsx packages/server/scripts/reproduce-codex-history-string-limit.mts 520 [legacy|paginated]
// Requires Codex 0.148+ on PATH and approximately 2 GiB of available memory.
// `paginated` migrates the synthetic rollout with `codex migrate-rollouts` first.
import { execFileSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, mkdir, open, rm } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import { CodexAppServerAgentSession } from "../src/server/agent/providers/codex-app-server-agent.js";
const megabytes = Number(process.argv[2] ?? "520");
if (!Number.isSafeInteger(megabytes) || megabytes < 1 || megabytes > 600) {
throw new Error("Expected an integer size between 1 and 600 MiB");
}
const historyMode = process.argv[3] ?? "legacy";
if (historyMode !== "legacy" && historyMode !== "paginated") {
throw new Error("Expected history mode legacy or paginated");
}
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-repro-"));
const home = path.join(root, "codex-home");
const now = new Date();
const timestamp = now.toISOString();
const threadId = randomUUID();
const turnId = randomUUID();
const sessions = path.join(home, "sessions", ...timestamp.slice(0, 10).split("-"));
await mkdir(sessions, { recursive: true });
const rollout = await open(
path.join(sessions, `rollout-${timestamp.slice(0, 19).replaceAll(":", "-")}-${threadId}.jsonl`),
"w",
);
async function write(type: string, payload: unknown) {
await rollout.write(`${JSON.stringify({ timestamp, type, payload })}\n`);
}
await write("session_meta", {
id: threadId,
timestamp,
cwd: root,
originator: "codex_cli_rs",
cli_version: "0.159.0",
source: "cli",
model_provider: "openai",
history_mode: "legacy",
});
await write("event_msg", { type: "task_started", turn_id: turnId, model_context_window: 200000 });
await write("event_msg", {
type: "user_message",
message: "Synthetic oversized history",
images: [],
local_images: [],
text_elements: [],
});
for (let index = 0; index < megabytes; index++) {
await write("event_msg", {
type: "agent_message",
message: "x".repeat(1024 * 1024),
phase: "commentary",
});
}
await write("event_msg", { type: "task_complete", turn_id: turnId, last_agent_message: "done" });
await rollout.close();
if (historyMode === "paginated") {
execFileSync("codex", ["migrate-rollouts", "--apply", "--thread", threadId], {
env: { ...process.env, CODEX_HOME: home },
stdio: "ignore",
});
}
console.log(
`Opening synthetic ${megabytes} MiB ${historyMode} history through the real Codex app-server`,
);
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: root },
{ sessionId: threadId },
pino({ level: "silent" }),
async () =>
spawn(
"codex",
["app-server", "--disable", "plugins", "--disable", "apps", "--disable", "remote_plugin"],
{
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, CODEX_HOME: home },
},
),
{ codexHome: home },
false,
false,
false,
undefined,
"history",
);
try {
await session.connect();
let count = 0;
for await (const event of session.streamHistory()) {
if (event.type !== "timeline") continue;
count++;
if (event.item.type === "notification") console.log(event.item.message);
}
console.log(`History loaded: ${count} timeline rows`);
} catch (error) {
// A recoverable session error is acceptable for the crash-isolation assertion.
console.log(
`History rejected without crashing: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
await session.close();
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
@@ -19,7 +19,6 @@ import {
type AgentManagerEvent,
type ManagedAgent,
} from "./agent-manager.js";
import { buildProviderRegistry } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
import { toAgentPayload } from "./agent-projections.js";
@@ -446,7 +445,7 @@ class EnvProbeAgentClient extends TestAgentClient {
}
class TestAgentSession implements AgentSession {
readonly provider: AgentProvider;
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
private runtimeModel: string | null = null;
@@ -454,9 +453,7 @@ class TestAgentSession implements AgentSession {
private turnIdCounter = 0;
private interrupted = false;
constructor(private readonly config: AgentSessionConfig) {
this.provider = config.provider;
}
constructor(private readonly config: AgentSessionConfig) {}
async run(): Promise<AgentRunResult> {
return {
@@ -3825,85 +3822,54 @@ test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime in
expect(snapshot.config.mcpServers).toBeUndefined();
});
test.each([
{ provider: "pi", supportsMcp: true },
{ provider: "pi", supportsMcp: false },
{ provider: "codex", supportsMcp: false },
])(
"createAgent permits optional approval for $provider with MCP capability $supportsMcp",
async ({ provider, supportsMcp }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
test("createAgent preserves a user-provided paseo MCP config", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
class CaptureClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return supportsMcp ? new McpCapableTestAgentSession(config) : new TestAgentSession(config);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return new McpCapableTestAgentSession(config);
}
}
const client = new CaptureClient(provider);
const manager = new AgentManager({
clients: {
[provider]: client,
},
providerDefinitions: {
[provider]: {
enabled: true,
applyToolPolicy: buildProviderRegistry(logger)[provider]!.applyToolPolicy,
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
mcpServers: {
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
},
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
},
undefined,
{ workspaceId: undefined },
);
const snapshot = await manager.createAgent(
{
provider,
mcpServerRequirements: { paseo: "optional" },
cwd: workdir,
toolPolicy: {
enforcement: "if-supported",
preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }],
},
mcpServers: {
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
},
},
undefined,
{ workspaceId: undefined },
);
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
});
expect((await storage.get(snapshot.id))?.config?.mcpServerRequirements).toEqual({
paseo: "optional",
});
await manager.reloadAgentSession(snapshot.id);
expect(manager.getAgent(snapshot.id)?.config.mcpServerRequirements).toEqual({
paseo: "optional",
});
const expectedPolicy =
provider === "codex"
? { preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }] }
: undefined;
expect(snapshot.config.toolPolicy).toEqual(expectedPolicy);
expect(client.lastConfig?.toolPolicy).toEqual(expectedPolicy);
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
},
);
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
});
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
});
test("createAgent fails when cwd does not exist", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
@@ -8093,6 +8059,60 @@ test("global subscribers receive internal agent events only when they opt in", a
unsubscribeOptedIn();
});
test("agent removal notifications respect scoped subscriptions and internal visibility", () => {
const manager = new AgentManager({ clients: {}, logger });
const removedId = "00000000-0000-4000-8000-000000000901";
const internalId = "00000000-0000-4000-8000-000000000902";
const otherId = "00000000-0000-4000-8000-000000000903";
const publicEvents: AgentManagerEvent[] = [];
const allEvents: AgentManagerEvent[] = [];
const scopedEvents: AgentManagerEvent[] = [];
const internalEvents: AgentManagerEvent[] = [];
const unrelatedEvents: AgentManagerEvent[] = [];
const releases = [
manager.subscribe((event) => publicEvents.push(event), { replayState: false }),
manager.subscribe((event) => allEvents.push(event), {
replayState: false,
includeInternal: true,
}),
manager.subscribe((event) => scopedEvents.push(event), {
replayState: false,
agentId: removedId,
}),
manager.subscribe((event) => internalEvents.push(event), {
replayState: false,
agentId: internalId,
}),
manager.subscribe((event) => unrelatedEvents.push(event), {
replayState: false,
agentId: otherId,
}),
];
try {
manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" });
manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true });
const publicRemoval = {
type: "agent_removed",
agentId: removedId,
workspaceId: "workspace",
internal: false,
};
const internalRemoval = {
type: "agent_removed",
agentId: internalId,
workspaceId: "workspace",
internal: true,
};
expect(publicEvents).toEqual([publicRemoval]);
expect(allEvents).toEqual([publicRemoval, internalRemoval]);
expect(scopedEvents).toEqual([publicRemoval]);
expect(internalEvents).toEqual([internalRemoval]);
expect(unrelatedEvents).toEqual([]);
} finally {
for (const release of releases) release();
}
});
test("getAgent returns internal agents by ID", async () => {
const internalAgentId = "00000000-0000-4000-8000-000000000107";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
@@ -12609,21 +12629,3 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
expect(h.commands).toEqual(["/maintenance"]);
expect(h.prompts).toEqual([]);
});
test("MCP requirements cannot mark a later plugin server optional", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
await expect(
manager.createAgent(
{ provider: "codex", cwd: workdir, mcpServerRequirements: { plugin: "optional" } },
undefined,
{ workspaceId: undefined },
),
).rejects.toThrow(
"MCP server requirement 'plugin' requires MCP server 'plugin' in the same agent request",
);
});
@@ -216,8 +216,6 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
if (record.config.providerOptions != null) {
config.providerOptions = record.config.providerOptions;
}
if (record.config.mcpServerRequirements != null)
config.mcpServerRequirements = record.config.mcpServerRequirements;
if (record.config.toolPolicy != null) config.toolPolicy = record.config.toolPolicy;
if (record.config.systemPrompt != null) {
config.systemPrompt = record.config.systemPrompt;
@@ -238,6 +236,7 @@ export type {
export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent }
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
| { type: "timeline_replacement"; agentId: string; epoch: string }
| {
@@ -1688,7 +1687,7 @@ export class AgentManager {
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if (
this.hasRequiredExternalMcpServers(providerLaunchConfig) &&
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
existing.session.capabilities.supportsMcpServers !== true
) {
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
@@ -3521,6 +3520,15 @@ export class AgentManager {
await this.deleteCommittedTimeline(agentId);
}
notifyAgentRemoved(agent: Pick<StoredAgentRecord, "id" | "workspaceId" | "internal">): void {
this.dispatch({
type: "agent_removed",
agentId: agent.id,
workspaceId: agent.workspaceId,
internal: agent.internal === true,
});
}
async deleteCommittedTimeline(agentId: string): Promise<void> {
await this.durableTimelineStore?.deleteAgent(agentId);
}
@@ -3890,19 +3898,13 @@ export class AgentManager {
}
}
private hasRequiredExternalMcpServers(config: AgentSessionConfig): boolean {
return Object.keys(stripInternalPaseoMcpServer(config).mcpServers ?? {}).some(
(name) => config.mcpServerRequirements?.[name] !== "optional",
);
}
private async requireExternalMcpSupport(
session: AgentSession,
launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
): Promise<void> {
if (
!this.hasRequiredExternalMcpServers(launchConfig) ||
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
session.capabilities.supportsMcpServers === true
) {
return;
@@ -5384,7 +5386,7 @@ export class AgentManager {
for (const subscriber of this.subscribers) {
if (
subscriber.agentId &&
event.type === "agent_stream" &&
(event.type === "agent_stream" || event.type === "agent_removed") &&
subscriber.agentId !== event.agentId
) {
continue;
@@ -5420,6 +5422,7 @@ export class AgentManager {
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
if (event.type === "agent_state") return event.agent.internal === true;
if (event.type === "agent_removed") return event.internal;
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
if (event.type !== "provider_subagent") return false;
const parentAgentId =
@@ -5465,7 +5468,6 @@ export class AgentManager {
private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig {
const definition = this.providerDefinitions.get(config.provider);
this.validateToolPolicyServers(config);
this.validateMcpServerRequirements(config);
if (config.toolPolicy && !definition?.applyToolPolicy) {
throw new Error(
`Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`,
@@ -5476,16 +5478,6 @@ export class AgentManager {
: config;
}
private validateMcpServerRequirements(config: AgentSessionConfig): void {
for (const name of Object.keys(config.mcpServerRequirements ?? {})) {
if (!Object.hasOwn(config.mcpServers ?? {}, name)) {
throw new Error(
`MCP server requirement '${name}' requires MCP server '${name}' in the same agent request`,
);
}
}
}
private validateToolPolicyServers(config: AgentSessionConfig): void {
if (!config.toolPolicy) return;
const serverNames = new Set(Object.keys(config.mcpServers ?? {}));
@@ -395,9 +395,6 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
if (config.systemPrompt) {
serializable.systemPrompt = config.systemPrompt;
}
if (config.mcpServerRequirements) {
serializable.mcpServerRequirements = config.mcpServerRequirements;
}
if (config.mcpServers) {
serializable.mcpServers = config.mcpServers;
}
@@ -607,7 +607,6 @@ export interface AgentSessionConfig {
title?: string | null;
providerOptions?: ProviderOptions;
toolPolicy?: ToolPolicy;
mcpServerRequirements?: Record<string, "optional" | "required">;
mcpServers?: Record<string, McpServerConfig>;
/**
* Internal agents are hidden from listings and don't trigger notifications.
@@ -27,10 +27,6 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
.nullable()
.optional(),
systemPrompt: z.string().nullable().optional(),
mcpServerRequirements: z
.record(z.string(), z.enum(["optional", "required"]))
.nullable()
.optional(),
mcpServers: z.record(z.string(), z.any()).nullable().optional(),
})
.nullable()
@@ -93,7 +89,6 @@ export type SerializableAgentConfig = Pick<
| "toolPolicy"
| "systemPrompt"
| "mcpServers"
| "mcpServerRequirements"
>;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
@@ -1,4 +1,6 @@
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
@@ -251,23 +253,52 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
});
async function measureTimelineTail(wholeTurns: boolean) {
const fixture = fileURLToPath(
new URL("./test-utils/timeline-responsiveness.ts", import.meta.url),
);
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)],
{ timeout: 15_000 },
);
process.stderr.write(stderr);
return JSON.parse(stdout) as {
result: ReturnType<InMemoryAgentTimelineStore["fetch"]>;
blockingMs: number;
};
}
it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => {
let unrelatedWork: ReturnType<typeof setImmediate>;
const burnCpu = () => {
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
process,
"threadCpuUsage",
);
const start = threadCpuUsage();
let elapsed: NodeJS.CpuUsage;
do {
elapsed = threadCpuUsage(start);
} while (elapsed.user + elapsed.system < 80_000);
unrelatedWork = setImmediate(burnCpu);
};
unrelatedWork = setImmediate(burnCpu);
try {
const { result: tail, blockingMs } = await measureTimelineTail(false);
expect(tail.rows).toHaveLength(200);
expect(tail.startSeq).toBe(99_801);
expect(tail.endSeq).toBe(100_000);
expect(blockingMs).toBeLessThan(50);
} finally {
clearImmediate(unrelatedWork);
}
});
it.each([false, true])(
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
async (wholeTurns) => {
const store = new InMemoryAgentTimelineStore();
store.initialize("large");
for (let index = 0; index < 100_000; index++) {
store.append("large", { type: "user_message", text: String(index) });
}
const { result: tail, blockingMs } = await measureMainThreadWork(
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
() => {
let page = store.fetch("large", { limit: 200, wholeTurns });
for (let repeat = 1; repeat < 8; repeat++)
page = store.fetch("large", { limit: 200, wholeTurns });
return page;
},
);
const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns);
expect(tail.rows).toHaveLength(200);
expect(tail.startSeq).toBe(99_801);
expect(tail.endSeq).toBe(100_000);

Some files were not shown because too many files have changed in this diff Show More