Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 1aba2a5f23 Measure composer pills after the compact layout replaces them (#9546)
Resizing to a compact width mounts a new composer, so boundingBox() on a
pill could resolve the wide layout's element and return null once it
unmounted. Wait for the compact header, then poll each pill's width.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-13 03:42:48 +00:00
aead84bf0f Interrupting sends no longer fail with "already has an active run" (#9537)
* Interrupting sends no longer fail with "already has an active run"

Two sends that interrupt the same running agent raced: the first left the
foreground lane after cancelling and before reserving its new run, so the
second send's cancel ran in that gap, saw the agent still marked running,
and tracked a phantom run that made the first send's start throw.

Replacements now queue per agent in arrival order, and each cancels and
reserves its run inside one foreground mutation. A replacement that finds a
run still starting waits for it outside the lane, so Stop still reaches it.

Co-authored-by: Michael Wu <421504+michaelmwu@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Interrupting sends still recover an agent whose turn never starts

A replacement now waits for a starting run only up to the rescue
timeout, then cancels it the way main did, so sending another message
still unsticks an agent whose provider never acknowledges a start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A queued replacement acts on the agent as it is when it gets the lane

Resolve the managed agent inside the foreground mutation instead of
before queueing, so a session reload while the send waits cannot leave
it writing state to, or checking steer ownership against, the old agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Michael Wu <421504+michaelmwu@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 03:09:32 +00:00
paseo-bot[bot] 7980ad008b Keep keystrokes typed while a terminal restores its screen (#9533)
* fix(app): keep keystrokes typed while a terminal restores its screen

When a terminal re-attaches, for example after the host reconnects, the
browser terminal replays the screen snapshot and ignores everything xterm
emits until the replay is parsed, so replies to queries in the restored
output don't reach the shell. That also threw away what the user typed
during the replay: the first characters typed right after a reconnect
were lost.

xterm emits both through the same callback. Replies only come out of
parsing a write, so data emitted while a keyboard, input or paste event
is dispatching on the terminal is the user's, and it now goes through.

* fix(app): keep pastes suppressed while a terminal restores its screen

Until the replayed snapshot restores bracketed paste mode, a multi-line
paste would reach the shell unbracketed and run line by line. Pastes
during a replay are dropped as before; only typed input goes through.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-13 03:01:35 +00:00
7c35ad90b9 Start an edited agent draft after its first start failed (#9534)
* test(app): reproduce edited draft retries after a failed agent start

Resubmitting an agent draft after its start failed reuses the draft ID as
the creation key with different inputs, and the daemon rejects it with
agent_request_key_conflict (or workspace_request_key_conflict from New
workspace).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): start an edited agent draft after its first start failed

The draft sent its draft ID as the creation key on every submit. After a
failed start the daemon keeps that key's request, so resubmitting an edited
draft was rejected with agent_request_key_conflict. The key now names the
draft's exact request: a retry resumes it, an edit starts a new one.

A retry after New workspace's agent failed to start creates the agent in
the workspace that is already ready, instead of replaying workspace
provisioning with a different agent.

Co-authored-by: michaelmwu <421504+michaelmwu@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: michaelmwu <421504+michaelmwu@users.noreply.github.com>
2026-11-13 02:05:34 +00:00
9eccb4722b Model picker profile buttons respond to taps on Android tablets (#9526)
* fix(app): keep the model picker's profile action tappable on Android

On Android tablets the model list scrolls inside an independent viewport whose native scroll gesture cancels a plain Pressable's touches. Rows use the gesture-aware pressable; the profile + and the Profiles pencil were left as plain Pressables. Route both through the same gesture path.

* test(app): cover the tablet model row's profile action on Android

Adds an agent-device journey that taps a model row's `+` in the New
workspace picker on an Android tablet and waits for the seeded profile
form. It fails on main, where the tap is swallowed, and passes with the
gesture-aware profile action.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): make the tablet profile journey follow the documented setup

The journey now starts from a remembered model like its sibling, so it
runs after model-tablet.android.ad, and asserts that closing the profile
form leaves the remembered model selected, so a tap on `+` that also
selected the row fails it.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): keep the no-profiles requirement for the tablet profile journey

With a saved profile the picker opens on its root view, which has no
Back, so the journey needs a host with no agent profiles.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): hide the model row's profile action through its style

IconActionButton took a pointerEvents prop that only its web branch
applied, so a native caller hiding the action would get an invisible
button that still took taps. The hidden style now carries
pointerEvents: "none", which every pressable branch already applies,
and the prop is gone. The web journey checks the plus ignores taps
until its row is hovered.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: moevis <moevery@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:00:22 +00:00
LiebeLee 536846eb60 fix(omp): persist the resumed session identity when importing (#8854)
OMP import listings return a session file path, and the import helper stored
that path as both sessionId and nativeHandle. Records then carried a path in
sessionId while runtimeInfo carried the real id, which breaks identity-based
lookups and makes a later import of the same file look unrelated.

Prefer the resumed session's own describePersistence() identity and keep the
listed handle as nativeHandle, merging metadata so provider-specific fields
survive.
2026-11-13 00:02:23 +00:00
cc16ac6ac4 fix(app): keep line breaks when copying a selection that crosses part of a code block (#9515)
* Preserve partial code blocks in assistant selection copy

* test(app): cover partial code copies in the real assistant selection journey

Copying from a paragraph into part of a highlighted code block, or from
partway through a block out to the next paragraph, now has browser
journey coverage. On main both copies join the code lines into one line
and drop the fence.

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): keep indented code lines inside a copied list item

Copying a list item that holds a code block indented only the code lines
that start with a non-space character, so an indented line fell outside
the item and broke the fence. Indent every non-empty line of the item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:52:13 +00:00
paseo-bot[bot] fd9aebd622 fix(app): close a bottom sheet hidden before it finished opening (#9519)
A sheet hidden right after it was presented, such as the context usage
sheet when a plugin focuses the composer, could stay open. The dismiss
reached the sheet before it had measured its layout, and the library
drops a close it cannot position yet. The sheet then finished opening,
and the visibility tracker took that as presented while its owner
wanted it hidden.

The tracker now dismisses again when a sheet reports an open position
while its owner wants it hidden.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 21:49:05 +00:00
RockandClaude Opus 5.5 21709812bb fix(codex): retry an oversized history page with smaller pages (#9432)
thread/items/list asks for 100 items a page. On image-heavy threads a page
can exceed the app-server's line cap, and the history stopped at the first
such page with a "too large to load" warning, dropping every later item
even when each one fits. The reader now halves the page and retries the
same cursor, and shows the warning only when a single item cannot fit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:02:10 +00:00
d426088c3f Stop agent updates re-rendering the workspace screen, favicon, and command center (#9497)
* Stop hidden surfaces rebuilding the agent directory on every update

The favicon is mounted at the root of every web and Electron session, and it
derived the whole aggregated agent list to answer three questions: is any agent
running, does any want attention, does any need input. The aggregated list
allocates a projection per agent, deep-compares it, sorts the whole host and
rebuilds its identity cache, and useAggregatedAgents re-ran all of that on
every agent update on any host. The dock badge did the same for workspaces: it
materialised every workspace status and deep-compared the array for a number.

Both now read a scalar selector: selectFaviconStatus walks the agent maps and
returns one of three strings, and selectMacDockBadgeCount counts actionable
workspaces without building a list. A store write that leaves those values
alone no longer re-renders the root component.

useAggregatedAgents also treated `demand` as "hold the directory subscription"
but still subscribed to every host's agent map, so the closed command center
(useAggregatedAgents({ demand: open })) rebuilt the full list on every update
too. With demand false the hook now returns a stable empty result and stays off
the maps.

Measured on the many-agent-tabs harness (5 tabs, 20 s windows, agent_update
churn from a background host): the root favicon component rendered 1554 times
in 20 s before this change and 1 time after.

* Complete the host runtime mock in the favicon test

* Keep the profiling harness out of the fix

The harness scans every process environment to find the daemon. Whether
Paseo carries a renderer CPU harness, and how it samples processes, is a
separate decision from the rendering fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop re-rendering the workspace screen on unrelated agent updates

useOpenAgentTabLabels subscribed the workspace screen to the host's whole
agent map. Every agent update on the host replaces that map, so each
update re-rendered the open workspace screen, its split panes and tab
menus. Subscribe only to the agents that have open tabs.

Co-authored-by: jl97mcad <56251999+jl97mcad@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test render isolation through pure selectors and the browser

Replace the JSDOM hook tests and module mocks with pure selector tests
and a browser test that updates an agent in another workspace and checks
the open workspace screen does not re-render. Memoize the favicon and
dock badge selectors on the session maps they read, so unrelated store
writes skip the scan, and pass directory demand as an object.

Co-authored-by: jl97mcad <56251999+jl97mcad@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: jl97mcad <56251999+jl97mcad@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:04:24 +00:00
Don Phillips b5c25e5c10 fix(opencode): discover V2 tools from bound session catalogs (#9426)
Fetch tool definitions for the bound session instead of the global manifest so plugin-contributed tools can be registered and invoked. Preserve global discovery for legacy callers and existing session filtering and execution authorization.

Cover session-only tool discovery, invocation, child inheritance, disabled and unrelated sessions, unbinding, and plugin reload.
2026-11-12 17:56:32 +00:00
paseo-bot[bot]andFluidXin 525ee7f222 fix(app): keep Mermaid chart axes from overlapping and clipping (#9483)
* fix(app): render Mermaid diagrams in an unscaled offscreen staging container

* fix(app): keep gantt charts at a fixed width in the Mermaid staging container

Rendering into the oversized staging container gave gantt charts its
16384px width, because Mermaid sizes a gantt chart from the element it
renders in. The host then fit that width into the message column and the
chart drew a few pixels tall. Give gantt charts the 1200px width Mermaid
uses when there is no container width, so they report the same size
whatever frame the host gives the runtime.

Co-authored-by: FluidXin <266379324+FluidXin@users.noreply.github.com>

* fix(app): keep gantt charts as wide as the Mermaid frame

Pinning gantt charts to 1200px shrank them on iOS and Android, where the
measuring frame is as wide as the message column and gantt charts used
to fill it. Use the frame's own width instead, which is what Mermaid
read from the page before rendering moved into the staging container.

Co-authored-by: FluidXin <266379324+FluidXin@users.noreply.github.com>

---------

Co-authored-by: FluidXin <266379324+FluidXin@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 17:11:35 +00:00
paseo-bot[bot]andPhieu Dinh 065c487a90 fix(server): keep workspace scripts when paseo.json checks are slow (#9477)
* fix(server): answer workspace listings from the last paseo.json snapshot

Every workspace listing and update builds a descriptor per workspace, and each descriptor read that workspace's paseo.json through a stat inside the daemon-wide two-slot pool, with a 5 s timeout that counted the queue wait. With many workspaces and a few clients listing, the last workspaces timed out, dropped their scripts, and the timed-out reads still ran, so other requests waited minutes.

The projection path now answers from the last snapshot and re-checks it behind the caller once it is 2 s old (a clock stepped backwards counts as stale). Edits, worktree setup and metadata prompts keep the fresh read. The snapshot cap rises to 1024 roots so a listing over more roots than the cap does not evict the next root it needs.

* fix(server): check paseo.json on every listing, answer the last config only when the check is late

Answering listings from the snapshot showed the previous scripts on the
first listing after an external edit to paseo.json, such as a branch
switch or an agent editing the file, and nothing re-sent the update
once the background check saw the change. A file that stopped being
admissible also kept its old scripts indefinitely.

Listings and script status now check paseo.json as before. When the
check does not finish within the 5 s budget, they answer the last
config read for that root instead of no scripts. Edits and execution
keep failing on a late check.

Co-authored-by: Phieu Dinh <phieu@fiu.one>

* fix(server): keep the saved paseo.json as the last known config after an in-app save

Saving through the project config editor dropped the snapshot, so a
late check on the next listing showed no scripts for that workspace.
The save now records the config it wrote.

* fix(server): record the saved paseo.json without another filesystem read

Re-reading the file after the rename made a completed save wait on an
unbudgeted read. The save now records the config and file stats it
already has.

---------

Co-authored-by: Phieu Dinh <phieu@fiu.one>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 16:04:52 +00:00
paseo-bot[bot]andTianyu Yang 4df5307eaa fix: keep failed ACP tools valid in conversation history (#9467)
Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
2026-11-12 15:14:46 +00:00
paseo-bot[bot] aa7cabb455 test(app): cover composer recall in one journey (#9409)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 15:08:41 +00:00
paseo-bot[bot]andmoevis 9e607a3aa3 fix(app): restore physical arrow keys in Android terminals (#9407)
* fix(app): send hardware arrow keys to the terminal on Android

TextInput.onKeyPress is built from the IME input connection, so physical arrows never reach it and fell through to the hidden input as caret movement. The hardware module now claims arrows while the terminal input (nativeID) has focus, and the input forwards them to the PTY with their modifiers.

* test(app): guard Android terminal hardware arrow delivery

Exercise physical arrow ownership through a real Android window and editable, including repeats, modifiers and ordinary text-field editing. The terminal case fails on main because Left moves the hidden caret; the following typed character is then discarded as a replacement.

Co-authored-by: moevis <moevery@gmail.com>

---------

Co-authored-by: moevis <moevery@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 14:09:19 +00:00
paseo-ai[bot] 68818067f2 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-11 18:49:19 +00:00
Mohamed Boudra 2240d4effe chore(release): reconcile 0.16.0-beta.2 into main (#9422)
* docs(changelog): describe 0.16.0-beta.2

Folds the nine user-facing changes merged since beta.1 into the series entry and renames its heading. Highlights are unchanged.

* chore(release): cut 0.16.0-beta.2
2026-11-12 01:40:42 +07:00
MyasukaandTianyu Yang 18c002cbf2 fix: forward ACP prompt response token usage (#9396)
* fix: forward ACP prompt response token usage

* test: assert complete ACP usage snapshots

---------

Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
2026-11-11 14:06:49 +00:00
paseo-bot[bot] 47a7463182 test(app): cover email composer formats in one journey (#9398)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-11 12:42:00 +00:00
xxachangxx a4878656e2 fix(pi): track resumed pi-subagents runs and transcripts (#9261)
* 修复 Pi 恢复子任务的状态和对话跟踪

关联 #9200。

改动:
- 仅在 action:resume 确认成功启动新的 single async run 后登记独立子任务条目,按新 run ID 和 asyncDir 跟踪状态,拒绝失败及管理调用产生的假条目。
- 将对话去重从文件级调整为子任务条目与文件组合,支持恢复运行复用 session JSONL,避免旧通知结束新运行。
- 扩展原适配器回归测试,并附脱敏的命令输出、真实工具结果和 macOS Electron 截图,未改 UI、协议或共享生命周期。

实际验证:
- 原适配器下恢复条目回归为 RED;恢复旧文件级去重后共享对话回归为 RED。
- 基线 d46ee6558c 上 npm run build:server、npm run format、npm run typecheck、npm run lint、git diff --check 均成功。
- 在 packages/server 执行 npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1,21/21 通过。
- 已在此前 0.15.3 开发版中,通过真实 macOS Electron、Pi 1.5.0、pi-subagents 0.86.0 验证 A→B→C 恢复、运行条目和对话、完成状态、归档保留运行任务、失败恢复不产生假条目;普通启动走 CLI/API,恢复通过真实 composer。截图不是 0.15.4 发布包验收。
- 同步后适配器与测试文件字节和第三方锁定依赖均未变,复用独立代码评审与上述桌面证据。公开材料已做隐私扫描。
- 未执行全量测试(仓库禁止本地全量执行)、CI、其他平台或发布安装包验证;短暂重复 Shell 文案原因未定位,不宣称本次修复。

回滚:
- 本提交仍为 HEAD 时执行 git revert HEAD;否则先用 git log --oneline --grep='修复 Pi 恢复子任务的状态和对话跟踪' 确认本提交哈希,再 git revert <确认的哈希>,不要重置或覆盖其他修改。
- 回滚后重跑该适配器测试并执行 npm run build:server;若需要部署回滚产物,另行安排服务切换,禁止未经授权重启正在使用的 Paseo daemon。
- 本提交仅改源码、测试和 QA 证据,无数据迁移;回滚不应删除已有 Pi 会话或异步目录。独立 QA 服务不由本提交启停。

* 补强 Pi 恢复对话的新消息与实时回放断言

回应 PR #9261 的 Greptile P2 评论 discussion_r4521416122。

改动:
- 只修改 pi-subagents/index.test.ts,按子任务 ID 提取 assistant_message 文本,精确检查 C 的历史与本轮内容。
- 观察 C reader 初始读取后再追加消息,在写入终态前验证新消息实时到达且 C 仍运行,避免只靠完成时刷新通过。
- 回放精确校验三条预期消息、顺序与 resume-c 归属,保留旧通知隔离、完成状态和 bg_wait 断言。
- 不修改生产实现、历史 QA 证据或普通启动行为。

实际验证:
- 临时在测试观察回调中丢弃 C 本轮消息:定向测试 exit 1,只有 Attempt b 的历史不能通过初始断言。
- 临时只丢弃 C 后追加消息:定向测试 exit 1,缺少 Attempt c follow-up 的断言在写入完成状态前失败。
- 两次故障注入已移除;最终在 packages/server 执行 npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1,21/21 通过。
- npm run format、npm run typecheck、npm run lint 和 git diff --check 均通过;全仓 lint 为 0 warnings / 0 errors。
- LSP 推断项目模式仅报告未修改旧测试行的两处 results 类型警告;项目正式 typecheck 通过。
- 未运行全量测试、远端 CI 或重复桌面验收。本次只有测试变更,复用原产品评审和真实桌面验证,不将临时故障注入当作生产缺陷。

回滚:
- 本提交仍为 HEAD 时执行 git revert HEAD;否则用 git log --oneline --grep='补强 Pi 恢复对话的新消息与实时回放断言' 确认哈希后 git revert <该哈希>。
- 回滚后重跑上述适配器测试;这只撤销测试增强,不应回滚先前 #9200 产品修复,也无需重启 daemon 或删除任何会话数据。
2026-11-11 12:01:35 +00:00
97 changed files with 2890 additions and 550 deletions
+10 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.16.0-beta.1 - 2026-11-10
## 0.16.0-beta.2 - 2026-11-11
- **Background tasks:** Paseo now shows the background tasks an agent is running, such as shells and monitors, above the composer, with a Stop button for each. Works with Claude Code, Codex and OpenCode v2. ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162) by [@fxlelouarn](https://github.com/fxlelouarn))
- **Plugin sidebar views:** A plugin can replace the sidebar's content with its own layout of workspaces, agents, projects and terminals across every connected host. Pick the view per device under Settings → Sidebar; Paseo's sidebar header and footer stay in place. ([#8906](https://github.com/getpaseo/paseo/pull/8906))
@@ -30,6 +30,8 @@
- Changed the slash command menu to show loading and error states with Retry, and to stop selecting `/exit` by default ([#8905](https://github.com/getpaseo/paseo/pull/8905))
- Changed the workspace header and tabs to switch to compact controls when Explorer or a split pane narrows the workspace ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088) by [@zeyugao](https://github.com/zeyugao))
- Changed sending a message to a Pi agent whose process has exited to fail immediately with an instruction to reload the agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
- Changed Rename workspace in the sidebar on desktop and wide web layouts to edit the title in place instead of opening a dialog ([#9280](https://github.com/getpaseo/paseo/pull/9280))
- Changed What's new and the website changelog to show a release's highlights first, with the full list behind Show all changes ([#9281](https://github.com/getpaseo/paseo/pull/9281))
### Fixed
@@ -37,6 +39,7 @@
- Fixed chats with short collapsed turns opening with empty space until you scrolled ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
- Fixed Collapse turns hiding an earlier reply inside a later turn's summary line after an idle Claude, Codex or OpenCode agent was resumed ([#9098](https://github.com/getpaseo/paseo/pull/9098))
- Fixed the missing space between a prompt and the reply or collapsed-turn summary line below it ([#9009](https://github.com/getpaseo/paseo/pull/9009))
- Fixed a long user message showing without its fade and Show more for one frame when a chat opened ([#9302](https://github.com/getpaseo/paseo/pull/9302))
- Fixed Markdown tables showing as literal pipes when written directly below a paragraph with no blank line ([#8485](https://github.com/getpaseo/paseo/pull/8485) by [@ragokan](https://github.com/ragokan), [@suk-6](https://github.com/suk-6), [@thsrite](https://github.com/thsrite))
- Fixed linked badge images that share a line with text rendering as full-width blocks on web and desktop ([#8530](https://github.com/getpaseo/paseo/pull/8530))
- Fixed View diagram opening fullscreen instead of returning to the inline Mermaid diagram after View source ([#8932](https://github.com/getpaseo/paseo/pull/8932) by [@luoph](https://github.com/luoph))
@@ -48,6 +51,7 @@
- Fixed Add host sending you to Settings from the sidebar and New workspace ([#8512](https://github.com/getpaseo/paseo/pull/8512))
- Fixed a failed agent's red dot and Failed grouping staying in the sidebar after opening the agent or choosing Mark as read ([#8831](https://github.com/getpaseo/paseo/pull/8831))
- Fixed a workspace's unread indicator staying on after it was cleared while the agent's runtime was closed ([#9087](https://github.com/getpaseo/paseo/pull/9087))
- Fixed unarchiving a workspace not restoring the agents that were archived with it ([#9210](https://github.com/getpaseo/paseo/pull/9210))
- Fixed the context window popover closing by itself whenever the chat scrolled during a streaming reply ([#8742](https://github.com/getpaseo/paseo/pull/8742) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed the sidebar usage summary updating only when Usage was opened ([#7806](https://github.com/getpaseo/paseo/pull/7806) by [@ragokan](https://github.com/ragokan))
- Fixed tooltips and menus above their trigger briefly covering it on Android when their content grew after opening ([#9247](https://github.com/getpaseo/paseo/pull/9247))
@@ -73,11 +77,13 @@
- Fixed Codex "Apply file changes" approvals showing no file name or diff before you accept ([#8585](https://github.com/getpaseo/paseo/pull/8585) by [@ragokan](https://github.com/ragokan))
- Fixed the timeline showing only the first file's diff when Codex edits several files in one patch ([#8613](https://github.com/getpaseo/paseo/pull/8613) by [@ragokan](https://github.com/ragokan))
- Fixed Codex session discovery and startup intermittently failing with a SQLite initialization error when several Codex operations start at once ([#9008](https://github.com/getpaseo/paseo/pull/9008) by [@colonelpanic8](https://github.com/colonelpanic8), [@michaelmwu](https://github.com/michaelmwu))
- Fixed Codex reconnect attempts and warnings not appearing in the timeline ([#9370](https://github.com/getpaseo/paseo/pull/9370) by [@whatamelon](https://github.com/whatamelon))
- Fixed OpenCode conversations that were mid-turn during a daemon restart showing idle and ignoring Stop ([#8698](https://github.com/getpaseo/paseo/pull/8698))
- Fixed OpenCode being told you declined a tool call when you sent a message while its approval was pending ([#8477](https://github.com/getpaseo/paseo/pull/8477))
- Fixed reopened Pi conversations missing the messages from before a context compaction ([#8523](https://github.com/getpaseo/paseo/pull/8523))
- Fixed archived Pi chats failing to open with `spawn pi ENOENT` after their working folder was removed ([#8442](https://github.com/getpaseo/paseo/pull/8442) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed reading an archived Pi chat leaving a Pi process running when idle runtime cleanup is disabled ([#8554](https://github.com/getpaseo/paseo/pull/8554) by [@rylwin](https://github.com/rylwin))
- Fixed resumed pi-subagents runs missing from the Subagents track ([#9261](https://github.com/getpaseo/paseo/pull/9261) by [@xxachangxx](https://github.com/xxachangxx))
- Fixed a prompt sent to an ACP agent whose process has exited staying in running forever instead of failing with an error ([#8406](https://github.com/getpaseo/paseo/pull/8406) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed an ACP agent staying stuck on "waiting for permission" after it abandoned an approval request and ended its turn ([#8548](https://github.com/getpaseo/paseo/pull/8548) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed changing the mode of an ACP agent failing with `invalid_params` after the agent sends a config update ([#8912](https://github.com/getpaseo/paseo/pull/8912) by [@a-sh](https://github.com/a-sh))
@@ -109,10 +115,13 @@
### Plugins
- Added plugin subscriptions, which stream live events from the daemon to a plugin's panels, and binary (`Uint8Array`) payloads for plugin RPCs and events ([#8527](https://github.com/getpaseo/paseo/pull/8527))
- Added an agent's background task count (`backgroundWork.count`) and `background` flag to plugin agent snapshots, and a `background` flag to agent hooks ([#9362](https://github.com/getpaseo/paseo/pull/9362))
- Changed Settings → Plugins to open a plugin's settings from a cog button, with a tab per settings screen ([#8675](https://github.com/getpaseo/paseo/pull/8675))
- Added an upward chevron to plugin composer pills that open a menu or popover ([#8673](https://github.com/getpaseo/paseo/pull/8673))
- Changed daemon startup to launch configured plugins concurrently instead of one after another ([#8427](https://github.com/getpaseo/paseo/pull/8427) by [@DAnaeto](https://github.com/DAnaeto))
- Fixed registry plugin installs failing with "npm artifact changed since review" when the host's npm uses a registry mirror ([#8756](https://github.com/getpaseo/paseo/pull/8756) by [@tlipoca9](https://github.com/tlipoca9))
- Fixed a plugin the daemon terminated for an invalid message staying listed with no exit reported ([#9285](https://github.com/getpaseo/paseo/pull/9285) by [@Brandon168](https://github.com/Brandon168))
- Fixed ACP provider plugins dropping the token usage an agent reports in its prompt response ([#9396](https://github.com/getpaseo/paseo/pull/9396) by [@Myasuka](https://github.com/Myasuka))
- Fixed the published `@getpaseo/*` npm packages declaring no license; they now declare Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816) by [@mdlawson](https://github.com/mdlawson), [@ragokan](https://github.com/ragokan))
- Fixed forking from an earlier reply failing with "This checkpoint changed after it was created" after a plugin updated one of its timeline cards ([#8746](https://github.com/getpaseo/paseo/pull/8746) by [@monarchmaisuriya](https://github.com/monarchmaisuriya))
- Fixed Collapse turns leaving a turn open, or folding to an earlier reply, when a plugin renders the final reply ([#8811](https://github.com/getpaseo/paseo/pull/8811) by [@geoqiao](https://github.com/geoqiao))
+10 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.16.0-beta.1 - 2026-11-10
## 0.16.0-beta.2 - 2026-11-11
- **后台任务**:Paseo 现在会在 Composer 上方显示 Agent 正在运行的后台任务,例如 shell 和监视器,每项都带“停止”按钮。支持 Claude Code、Codex 和 OpenCode v2。 ([#6220](https://github.com/getpaseo/paseo/pull/6220), [#9162](https://github.com/getpaseo/paseo/pull/9162),贡献者:[@fxlelouarn](https://github.com/fxlelouarn))
- **插件侧边栏视图**:插件可以用自己的布局替换侧边栏内容,展示所有已连接主机上的工作区、Agent、项目和终端。在“设置 → 侧边栏”中为每台设备选择视图;Paseo 的侧边栏页眉和页脚保持不变。 ([#8906](https://github.com/getpaseo/paseo/pull/8906))
@@ -30,6 +30,8 @@
- 斜杠命令菜单现在显示加载和错误状态并提供“重试”,且不再默认选中 `/exit` ([#8905](https://github.com/getpaseo/paseo/pull/8905))
- 文件浏览器(Explorer)或分屏使工作区变窄时,工作区页眉和标签页改用紧凑控件 ([#8740](https://github.com/getpaseo/paseo/pull/8740), [#9088](https://github.com/getpaseo/paseo/pull/9088),贡献者:[@zeyugao](https://github.com/zeyugao))
- 向进程已退出的 Pi Agent 发送消息时立即失败,并提示重新加载该 Agent ([#8718](https://github.com/getpaseo/paseo/pull/8718))
- 在桌面端和宽屏网页布局中,侧边栏的“重命名 workspace”改为在原处编辑标题,不再打开对话框 ([#9280](https://github.com/getpaseo/paseo/pull/9280))
- “更新内容”和网站更新日志改为先显示每个版本的亮点,完整列表收在“显示全部变更”之后 ([#9281](https://github.com/getpaseo/paseo/pull/9281))
### 修复
@@ -37,6 +39,7 @@
- 修复包含较短折叠轮次的聊天打开时留有空白、需滚动后才填满的问题 ([#8358](https://github.com/getpaseo/paseo/pull/8358), [#9083](https://github.com/getpaseo/paseo/pull/9083))
- 修复空闲的 Claude、Codex 或 OpenCode Agent 恢复后,“折叠轮次”把较早的回复藏进后一轮摘要行的问题 ([#9098](https://github.com/getpaseo/paseo/pull/9098))
- 修复提示词与其下方的回复或折叠轮次摘要行之间缺少间距的问题 ([#9009](https://github.com/getpaseo/paseo/pull/9009))
- 修复聊天打开时,较长的用户消息有一帧不显示渐隐效果和“展开”的问题 ([#9302](https://github.com/getpaseo/paseo/pull/9302))
- 修复紧跟在段落之后、中间没有空行的 Markdown 表格显示为原始竖线的问题 ([#8485](https://github.com/getpaseo/paseo/pull/8485),贡献者:[@ragokan](https://github.com/ragokan)、[@suk-6](https://github.com/suk-6)、[@thsrite](https://github.com/thsrite))
- 修复网页版和桌面端中与文字同行的带链接徽章图片被渲染成整行宽度的问题 ([#8530](https://github.com/getpaseo/paseo/pull/8530))
- 修复“查看源码”后点击“查看图表”会打开全屏、而不是回到内联 Mermaid 图表的问题 ([#8932](https://github.com/getpaseo/paseo/pull/8932),贡献者:[@luoph](https://github.com/luoph))
@@ -48,6 +51,7 @@
- 修复从侧边栏和“新建工作区”点击“添加主机”会跳转到“设置”的问题 ([#8512](https://github.com/getpaseo/paseo/pull/8512))
- 修复打开失败的 Agent 或选择 Mark as read 后,其红点和“失败”分组仍留在侧边栏的问题 ([#8831](https://github.com/getpaseo/paseo/pull/8831))
- 修复在 Agent 运行时已关闭的情况下清除未读后,工作区的未读指示仍然亮着的问题 ([#9087](https://github.com/getpaseo/paseo/pull/9087))
- 修复取消归档工作区时未恢复随之归档的 Agent 的问题 ([#9210](https://github.com/getpaseo/paseo/pull/9210))
- 修复流式回复期间聊天一滚动上下文窗口浮层就自行关闭的问题 ([#8742](https://github.com/getpaseo/paseo/pull/8742),贡献者:[@3ae3ae](https://github.com/3ae3ae))
- 修复侧边栏用量摘要只在打开“使用情况”时才更新的问题 ([#7806](https://github.com/getpaseo/paseo/pull/7806),贡献者:[@ragokan](https://github.com/ragokan))
- 修复 Android 上位于触发元素上方的提示和菜单在打开后内容变多时短暂盖住触发元素的问题 ([#9247](https://github.com/getpaseo/paseo/pull/9247))
@@ -73,11 +77,13 @@
- 修复 Codex 的“Apply file changes”审批在你接受之前不显示文件名和 diff 的问题 ([#8585](https://github.com/getpaseo/paseo/pull/8585),贡献者:[@ragokan](https://github.com/ragokan))
- 修复 Codex 在一个补丁中编辑多个文件时,时间线只显示第一个文件的 diff 的问题 ([#8613](https://github.com/getpaseo/paseo/pull/8613),贡献者:[@ragokan](https://github.com/ragokan))
- 修复多个 Codex 操作同时启动时,Codex 会话发现和启动偶发 SQLite 初始化错误的问题 ([#9008](https://github.com/getpaseo/paseo/pull/9008),贡献者:[@colonelpanic8](https://github.com/colonelpanic8)、[@michaelmwu](https://github.com/michaelmwu))
- 修复 Codex 的重连尝试和警告未显示在时间线中的问题 ([#9370](https://github.com/getpaseo/paseo/pull/9370),贡献者:[@whatamelon](https://github.com/whatamelon))
- 修复 daemon 重启时正处于轮次中的 OpenCode 对话显示为空闲并忽略“停止”的问题 ([#8698](https://github.com/getpaseo/paseo/pull/8698))
- 修复审批待处理期间你发送消息时,OpenCode 被告知你拒绝了工具调用的问题 ([#8477](https://github.com/getpaseo/paseo/pull/8477))
- 修复重新打开的 Pi 对话缺少上下文压缩之前的消息的问题 ([#8523](https://github.com/getpaseo/paseo/pull/8523))
- 修复工作文件夹被移除后,已归档的 Pi 聊天因 `spawn pi ENOENT` 无法打开的问题 ([#8442](https://github.com/getpaseo/paseo/pull/8442),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复关闭空闲运行时清理的情况下,读取已归档的 Pi 聊天会遗留一个 Pi 进程的问题 ([#8554](https://github.com/getpaseo/paseo/pull/8554),贡献者:[@rylwin](https://github.com/rylwin))
- 修复恢复的 pi-subagents 运行未出现在 Subagent 栏中的问题 ([#9261](https://github.com/getpaseo/paseo/pull/9261),贡献者:[@xxachangxx](https://github.com/xxachangxx))
- 修复向进程已退出的 ACP Agent 发送提示词后一直停留在运行中、而不是报错失败的问题 ([#8406](https://github.com/getpaseo/paseo/pull/8406),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复 ACP Agent 放弃审批请求并结束轮次后一直卡在“waiting for permission”的问题 ([#8548](https://github.com/getpaseo/paseo/pull/8548),贡献者:[@3ae3ae](https://github.com/3ae3ae))
- 修复 ACP Agent 发送配置更新后,更改其模式因 `invalid_params` 失败的问题 ([#8912](https://github.com/getpaseo/paseo/pull/8912),贡献者:[@a-sh](https://github.com/a-sh))
@@ -109,10 +115,13 @@
### 插件
- 新增插件订阅,可将 daemon 的实时事件流式推送到插件面板;插件 RPC 和事件支持二进制(`Uint8Array`)负载 ([#8527](https://github.com/getpaseo/paseo/pull/8527))
- 插件的 Agent 快照新增后台任务数量(`backgroundWork.count`)和 `background` 标记,Agent 钩子新增 `background` 标记 ([#9362](https://github.com/getpaseo/paseo/pull/9362))
- “设置 → 插件”改为通过齿轮按钮打开插件设置,每个设置页面对应一个标签页 ([#8675](https://github.com/getpaseo/paseo/pull/8675))
- 为会打开菜单或浮层的插件 Composer 胶囊按钮新增向上的箭头 ([#8673](https://github.com/getpaseo/paseo/pull/8673))
- daemon 启动时改为并发启动已配置的插件,不再逐个启动 ([#8427](https://github.com/getpaseo/paseo/pull/8427),贡献者:[@DAnaeto](https://github.com/DAnaeto))
- 修复主机的 npm 使用镜像源时,从插件库安装插件因“npm artifact changed since review”失败的问题 ([#8756](https://github.com/getpaseo/paseo/pull/8756),贡献者:[@tlipoca9](https://github.com/tlipoca9))
- 修复 daemon 因无效消息终止插件后,该插件仍留在列表中且未报告退出的问题 ([#9285](https://github.com/getpaseo/paseo/pull/9285),贡献者:[@Brandon168](https://github.com/Brandon168))
- 修复 ACP Provider 插件丢弃 Agent 在提示词响应中报告的 token 用量的问题 ([#9396](https://github.com/getpaseo/paseo/pull/9396),贡献者:[@Myasuka](https://github.com/Myasuka))
- 修复已发布的 `@getpaseo/*` npm 包未声明许可证的问题;现在声明为 Apache-2.0 ([#7816](https://github.com/getpaseo/paseo/pull/7816),贡献者:[@mdlawson](https://github.com/mdlawson)、[@ragokan](https://github.com/ragokan))
- 修复插件更新了自己的某张时间线卡片后,从较早的回复分叉因“This checkpoint changed after it was created”失败的问题 ([#8746](https://github.com/getpaseo/paseo/pull/8746),贡献者:[@monarchmaisuriya](https://github.com/monarchmaisuriya))
- 修复由插件渲染最终回复时,“折叠轮次”让轮次保持展开或折叠到较早回复的问题 ([#8811](https://github.com/getpaseo/paseo/pull/8811),贡献者:[@geoqiao](https://github.com/geoqiao))
+9
View File
@@ -112,6 +112,15 @@ need an artifact. `android:clear` removes the generated Android project.
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
With an emulator or device connected, run the hardware keyboard's window-dispatch regression tests
from `packages/app/android`:
```bash
./gradlew :paseo-hardware-keyboard:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
These check terminal arrow ownership, held-key repeats and modifiers, and ordinary text-field editing.
### Diagnostic logs without ADB
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
+5
View File
@@ -85,6 +85,11 @@ Escape interrupts an agent only in the message input or an unclaimed surface.
A focused terminal excludes that binding. The source leaves terminal Escape alone;
terminal Escape/Ctrl forwarding is a separate feature.
Terminal arrows never pass through the shortcut pipeline. The Android hardware
module claims them while the terminal's hidden input (`nativeID`) owns the focus.
`TextInput.onKeyPress` only sees IME keys, and an arrow that falls through moves
the hidden caret and desyncs the next text change, so the pipeline cannot own them.
## Keys shared with text editing
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
+10 -1
View File
@@ -150,6 +150,15 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
OMP rewind is a branch, not an edit: `branch` writes a new session file whose header names the
previous file as `parentSession` and leaves that previous file untouched. A rewind chain therefore
adds one file to `~/.omp/agent/sessions` per step, and `omp --resume` lists all of them. The
previous file also keeps whatever head it had recorded, which can be an older branch than the one
the user last saw. Treat the provider session file selected by OMP as the authority; do not assume
the newest file or the newest message wins. Import listings hand back a file path, so persist the
resumed session's own identity (`sessionId`) with that path as `nativeHandle`, not the listed
handle as both.
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
@@ -160,7 +169,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. V2 discovers definitions from each bound session's catalog: plugin-contributed tools can be absent from the global manifest, and an allowlist cannot register a missing definition. Registration is location-wide, so session filtering and caller-scoped execution must remain in place. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
@@ -0,0 +1,67 @@
# Pi resumed subagents — #9200
## Scope and provenance
An AI agent ran this QA through Playwright/CDP attached to the **real macOS Electron renderer**, using an isolated desktop-managed daemon. The user authorized the run; this is not a claim that the user personally operated the UI. No production daemon, installed package, or daily host configuration was changed.
Desktop evidence was captured on the modified **0.15.3 development checkout** at base `e260f93cc7b6098f6b175bd0e5c7eb24d3f03cf4`, with Electron 44.2.0, Pi 1.5.0, and installed pi-subagents 0.86.0. The final branch was subsequently fast-forwarded to `d46ee6558c043f9fb377d20f05ff662ce27a31f6` (0.15.4). The adapter and test file stayed byte-for-byte identical. The lockfile changes only workspace version references; third-party dependency entries did not change. Server declarations were rebuilt and the checks below passed on the final base. **These screenshots are not a 0.15.4 release-package test.**
## Before and automated checks
Issue #9200 reports ordinary async spawn appearing in the track while successive successful resumes disappear. The original adapter rejects any `action` and requires top-level `agent`/`task`, which a resume does not supply. A separate file-only transcript deduplication also prevents a new attempt row from following Pi's reused session file.
[Captured command output](validation.txt) contains:
- RED on the original adapter: a successful resume returns no descriptor instead of a running row.
- RED on the intermediate implementation with old file-only deduplication: the next resumed row gets no transcript.
- Final **21/21** tests passing, including consecutive resumes with a real shared temporary JSONL, polling, notice/replay routing, late old-notice isolation, `bg_wait`, failed/management calls, missing launch identity, and the task alias. Existing spawn fixtures still pass.
- Full `npm run format`, `npm run typecheck`, `npm run lint`, and `git diff --check` passing after the base update.
Only the specific adapter test file was run, not the full suite. CI has not been run. There is no old-desktop before screenshot; the reported failure and the two actual RED runs are the before evidence.
## Real desktop steps and observations
1. Built server/app dependencies and launched the existing desktop dev runner with a separate home, Electron userData, Pi configuration/session directory, and async root. The final endpoints were loopback-only: daemon 6768, Metro 8082, and CDP 9223. The renderer's desktop bridge confirmed its own managed daemon/home/server identity. Only the existing pi-subagents extension and a marker-only test agent were enabled; child tasks used `sleep`/`printf`, not workspace edits.
2. Created a Pi parent through the normal Paseo **CLI/API**, asking it to call `subagent({agent:"qa-marker", task, async:true})`. This produced A. The model first enabled the subagent tool, then performed the ordinary spawn. This step was not submitted through the composer.
3. Used the real Electron **composer** to ask Pi for `subagent({action:"resume", id:A, message})`, then resumed B the same way to produce C. No top-level `agent`, `task`, or `async` was supplied to these resume calls. All three finished with their requested markers.
4. Opened the provider-owned track while C ran: it showed `1 working`, the follow-up description, and `qa-marker`. Opening that row showed the reused child conversation and the current Shell call. Each resume had a new run/async directory, while all attempts reused the same session JSONL.
5. While C was still running, clicked **Archive finished subagents**. Completed A/B rows disappeared, while C remained visible as `1 working`.
6. Submitted a nonexistent resume ID through the composer. Pi returned an error and the parent reported “not found”; no new row or async run appeared.
7. The first B screenshot was taken after B finished because screenshot capture waited for fonts. It is not offered as running-state proof. A supplementary real resume from A, B_OBSERVE, was captured as `1 working` and opened successfully. It also finished. The final track contained exactly the four successful runs, not a fifth phantom from the failed resume.
The [selected real tool arguments/results, final statuses, and four completion notices](desktop-observations.json) preserve the test UUIDs. B's `revivedFrom` is A; C's is B. This file contains selected fields from persisted Pi entries/status files, not synthesized launch details. The [dialogue DOM excerpt](dialogue-excerpt.txt) records opening a resumed child. Test-root paths are replaced with `<QA_ROOT>` and the encoded workspace directory with `<QA_WORKSPACE>`; no auth or models configuration is included.
### Screenshots
Unmodified captures, except for the explicitly described dialogue crop. They contain only isolated test prompts, test UUIDs, and app UI; no account, credentials, or user-home paths.
**C running in the native provider-owned track:**
![C running](c-running.png)
**After archiving finished rows, C remains running:**
![Archive keeps running C](archive-keeps-running.png)
**Opened C conversation, current Shell call and parent link:** this is an unaltered crop of the bottom 880×245 pixels of the real child-pane capture, omitting the long temporary session path above it. No state was redrawn.
![C child conversation crop](c-dialogue-crop.png)
**Final four completed rows, with the failed resume reported above:**
![Completed rows and failed resume](completed-and-failed-resume.png)
## Coverage and limits
| Surface | Tested |
| ---------------------------------- | ------------------------------------------------------------------------- |
| macOS Electron development desktop | Yes — real renderer, composer, track, child pane, archive-finished action |
| Pi RPC / Paseo WebSocket / daemon | Yes — real provider calls and persisted tool results/notices |
| Standalone browser web | No |
| iOS / Android | No |
| Windows / Linux desktop | No |
| Packaged release / CI | No |
Each resumed attempt intentionally gets its own row and reads the whole reused child transcript, including earlier history; it does not slice history by attempt. Old-notice isolation is also covered by the automated regression and the earlier real-notice host replay, rather than forcing a delayed notification in the desktop run.
Short-lived duplicate Shell text was observed in the streaming child pane; its cause was not established and it is not claimed fixed here. The earlier direct-SDK smoke's turn-end persistence error did not recur through this normal RPC run. All four test runners exited, the parent settled idle, and the temporary workspace stayed empty. No claims are made about old workflow issues or daemon-restart global adoption.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

@@ -0,0 +1,157 @@
{
"capture": "Selected fields from actual Pi session entries and final status files; omitted unrelated metadata. Test UUIDs unchanged; all test-root absolute paths replaced with <QA_ROOT>.",
"calls": [
{
"timestamp": "2026-11-10T04:08:31.691Z",
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
"arguments": {
"agent": "qa-marker",
"task": "Run sleep 18 then printf PASEO_DESKTOP_A; do not read or write any files",
"async": true
}
},
{
"timestamp": "2026-11-10T04:08:32.010Z",
"toolCallId": "call_dab88a12b4db419586ac9d0eab565ca8|fc_0e57afa06f701795016af298be50c487d0844c674c8a61fa94",
"isError": false,
"details": {
"mode": "single",
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"asyncId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"results": []
}
},
{
"timestamp": "2026-11-10T04:10:22.901Z",
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
"arguments": {
"action": "resume",
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"message": "Run sleep 18 then printf PASEO_DESKTOP_B; do not read or write files"
}
},
{
"timestamp": "2026-11-10T04:10:23.219Z",
"toolCallId": "call_b636f1714b83481ba5b4ab0aee32b239|fc_0e57afa06f701795016af2992d215087d099231229062fa446",
"isError": false,
"details": {
"mode": "single",
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"asyncId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"results": []
}
},
{
"timestamp": "2026-11-10T04:13:36.923Z",
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
"arguments": {
"action": "resume",
"id": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_C; do not read or write files"
}
},
{
"timestamp": "2026-11-10T04:13:37.597Z",
"toolCallId": "call_8e4ef481e0964e25bbdad4defe620e12|fc_0e57afa06f701795016af299ef47c887d0ac84e70bd50ffc5a",
"isError": false,
"details": {
"mode": "single",
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
"asyncId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596",
"results": []
}
},
{
"timestamp": "2026-11-10T04:16:53.099Z",
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
"arguments": {
"action": "resume",
"id": "00000000-0000-0000-0000-000000000000",
"message": "Output PASEO_INVALID"
}
},
{
"timestamp": "2026-11-10T04:16:53.109Z",
"toolCallId": "call_60e8fa68de0b436fad2aaf8cc391dae2|fc_0e57afa06f701795016af29ab2b1b487d09020677eaaf9086e",
"isError": true,
"details": {},
"content": [
{
"type": "text",
"text": "Async run not found for '00000000-0000-0000-0000-000000000000'."
}
]
},
{
"timestamp": "2026-11-10T04:17:07.330Z",
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
"arguments": {
"action": "resume",
"id": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"message": "Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files"
}
},
{
"timestamp": "2026-11-10T04:17:07.668Z",
"toolCallId": "call_e6eb985f16b24308be82922d6a1d7a57|fc_0e57afa06f701795016af29ac17d5c87d0b79c8763512f43da",
"isError": false,
"details": {
"mode": "single",
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
"asyncId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
"asyncDir": "<QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
"results": []
}
}
],
"finalStatuses": [
{
"runId": "01ac3b2e-2ed6-472d-8438-4dfb91ea62f7",
"state": "complete",
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
},
{
"runId": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"state": "complete",
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
},
{
"runId": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"state": "complete",
"revivedFrom": "832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122",
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
},
{
"runId": "f41865ed-0e62-44b5-a825-27ac4f0aa596",
"state": "complete",
"revivedFrom": "8ca757d4-7514-4e87-8eeb-af52e2228ccb",
"sessionFile": "<QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl"
}
],
"completionNotices": [
{
"timestamp": "2026-11-10T04:09:07.245Z",
"customType": "subagent-notify",
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_A\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
},
{
"timestamp": "2026-11-10T04:10:53.423Z",
"customType": "subagent-notify",
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/8ca757d4-7514-4e87-8eeb-af52e2228ccb\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
},
{
"timestamp": "2026-11-10T04:14:37.834Z",
"customType": "subagent-notify",
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_C\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/f41865ed-0e62-44b5-a825-27ac4f0aa596\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
},
{
"timestamp": "2026-11-10T04:17:37.887Z",
"customType": "subagent-notify",
"content": "Background task completed: **qa-marker**\n\nqa-marker:\nPASEO_DESKTOP_B_OBSERVE\n\nRetention-managed async directory: <QA_ROOT>/async/async-subagent-runs/01ac3b2e-2ed6-472d-8438-4dfb91ea62f7\n\nSession file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl\n\nParent action: Read the saved results above and resume the already-authorized parent task, or report completion. If approval is required, explicitly ask the user. Do not silently yield, rerun completed work, or infer new authorization."
}
]
}
@@ -0,0 +1,17 @@
Excerpt of actual Electron renderer DOM captured after opening the resumed child.
Test-root paths replaced with <QA_ROOT>; unrelated earlier history omitted.
Task: You are reviving a previous subagent conversation.
Original run: 832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122
Original agent: qa-marker
Original session file: <QA_ROOT>/pi/sessions/<QA_WORKSPACE>/2026-11-10T04-08-20-423Z_01a1c3a4-80c7-75ad-8526-1844853a0f02/832f02f3-3cf9-4f7b-a8f2-b6cc06c5e122/run-0/session.jsonl
Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child session is still running.
Follow-up:
Run bash sleep 45 then printf PASEO_DESKTOP_B_OBSERVE; do not read or write files
Show more
12:17 PM
QA #9200 — Pi resume A→B→C
Open parent
@@ -0,0 +1,196 @@
Captured command output. Only private absolute paths are replaced with <REPO_ROOT>/<USER_HOME>.
RED runs are intentional regression failures, not final failures.
=== Before fix: resume arguments rejected (expected RED, exit 1) ===
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
RUN v4.1.6 <REPO_ROOT>/packages/server
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚙️ suppress all logs with { quiet: true }
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ suppress all logs with { quiet: true }
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (10 tests | 1 failed) 7ms
× registers a successful resume as a new async attempt without spawn arguments 6ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > registers a successful resume as a new async attempt without spawn arguments
AssertionError: expected undefined to deeply equal [ { type: 'upsert', …(5) } ]
- Expected:
[
{
"description": "Continue",
"id": "resume-b",
"status": "running",
"title": "Subagent",
"toolCallId": "resume-b",
"type": "upsert",
},
]
+ Received:
undefined
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:66:32
64| },
65| });
66| expect(resumed?.subagents).toEqual([
| ^
67| {
68| type: "upsert",
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed (10)
Start at 21:05:21
Duration 474ms (transform 245ms, setup 31ms, import 328ms, tests 7ms, environment 0ms)
=== Intermediate implementation: old file-only transcript deduplication (expected RED, exit 1) ===
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
RUN v4.1.6 <REPO_ROOT>/packages/server
stdout | src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts
[dotenv@17.3.1] injecting env (0) from .env.test -- tip: ⚡️ secrets for agents: https://dotenvx.com/as2
[dotenv@17.3.1] injecting env (0) from ../.env -- tip: ⚙️ override existing env vars with { override: true }
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts (16 tests | 1 failed) 1329ms
× follows consecutive resumed attempts and keeps stale notices on their own rows 1320ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts > pi-subagents adapter > follows consecutive resumed attempts and keeps stale notices on their own rows
AssertionError: expected false to be true // Object.is equality
- Expected
+ Received
- true
+ false
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:78
153| ]);
154| replay.mapToolCall(call);
155| await expect.poll(() => hasChildTimeline(received, `resume-${r…
| ^
156| }
157| const oldNotice = {
Caused by: Error: Matcher did not succeed in time.
❯ src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts:155:9
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed | 6 passed (16)
Start at 21:09:04
Duration 1.85s (transform 247ms, setup 24ms, import 389ms, tests 1.33s, environment 0ms)
=== Final baseline d46ee655: GREEN, exit 0 ===
$ cd packages/server && npx vitest run src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts --bail=1
RUN v4.1.6 <REPO_ROOT>/packages/server
Test Files 1 passed (1)
Tests 21 passed (21)
Start at 13:07:04
Duration 1.97s (transform 256ms, setup 26ms, import 355ms, tests 1.45s, environment 0ms)
=== Final typecheck, exit 0 ===
$ npm run typecheck
> paseo@0.15.4 typecheck
> npm run typecheck --workspaces --if-present
> @getpaseo/builtin-plugins@0.15.4 typecheck
> tsgo -p tsconfig.json --noEmit
> @getpaseo/expo-two-way-audio@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/highlight@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/plugin@0.15.4 typecheck
> tsgo --noEmit && npm run typecheck:examples
> @getpaseo/plugin@0.15.4 typecheck:examples
> npm --prefix ../../plugin-examples/markdown-panel install --ignore-scripts --no-audit --no-fund && tsgo -p tsconfig.examples.json --noEmit
up to date in 433ms
> @getpaseo/protocol@0.15.4 pretypecheck
> npm run generate:validators
> @getpaseo/protocol@0.15.4 generate:validators
> node scripts/generate-validation-aot.mjs
generated src/generated/validation/ws-outbound.aot.ts: envelope + 250 session message validators
> @getpaseo/protocol@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/client@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/server@0.15.4 typecheck
> tsgo -p tsconfig.server.typecheck.json --noEmit
> @getpaseo/app@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/relay@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/website@0.15.4 typecheck
> tsgo --noEmit
> @getpaseo/desktop@0.15.4 typecheck
> tsgo --noEmit -p tsconfig.json
> @getpaseo/cli@0.15.4 typecheck
> tsgo --noEmit
=== Final lint, exit 0 ===
$ npm run lint
> paseo@0.15.4 lint
> oxlint
Found 0 warnings and 0 errors.
Finished in 2.3s on 5442 files with 177 rules using 12 threads.
=== Final full formatter, exit 0; no unrelated changes ===
$ npm run format
> paseo@0.15.4 format
> oxfmt .
Finished in 2684ms on 5928 files using 12 threads.
$ git diff --check
(exit 0; no output)
+1 -1
View File
@@ -1 +1 @@
sha256-vG7v6uq2ADtOILEuRq9q3wTDUENAhRBbzI7UGBsWOqo=
sha256-e6BNeOJvy1uyDe/XW9hdr7NBgJ+t9tlNum5oLNefREI=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/server": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/server": "0.16.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/highlight": "0.16.0-beta.1",
"@getpaseo/plugin": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/highlight": "0.16.0-beta.2",
"@getpaseo/plugin": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -644,6 +644,22 @@ test("copying an assistant selection preserves Markdown structure and links", as
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
);
// Crossing into part of the block keeps the selected code lines as a fenced block.
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
);
// So does starting partway through the block and leaving it.
await selectAssistantAcrossCodeLines(page, "answer", "After code.");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
);
// Selecting a whole line runs off its end into the newline node. A trailing
// newline auto-executes the line when it is pasted into a terminal.
await selectAssistantAcrossCodeLines(page, "const", " ");
@@ -729,13 +745,25 @@ test.describe("on a touch-capable desktop browser", () => {
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
test.use({ hasTouch: true });
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-drag-",
title: "Assistant selection drag",
initialPrompt: "Render three paragraphs.",
initialPrompt: "Render structured text.",
featureValues: {
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
mockAssistantResponse: [
"First paragraph.",
"",
"- List item.",
"",
"> Quote text.",
"",
"```ts",
"const answer = 1;",
"```",
"",
"Third paragraph.",
].join("\n"),
},
});
@@ -760,6 +788,9 @@ test.describe("on a touch-capable desktop browser", () => {
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
expect(selected).toContain("First paragraph.");
expect(selected).toContain("List item.");
expect(selected).toContain("Quote text.");
expect(selected).toContain("const answer = 1;");
expect(selected).toContain("Third paragraph.");
} finally {
await agent.cleanup();
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedSubagent } from "../support/helpers/subagents";
import { submitMessage } from "../support/helpers/composer";
import { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
import {
@@ -918,11 +918,12 @@ test.describe("touch composer tracks", () => {
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
await page.setViewportSize({ width: 200, height: 844 });
const narrowWidths = await Promise.all(
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
for (const [index, width] of wideWidths.entries())
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
// The compact layout mounts a new composer, so measure only after it replaces the wide one.
await expectWorkspaceHeaderVisible(page);
for (const [index, pill] of [tasks, subagents, changes].entries())
await expect
.poll(async () => (await pill.boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(wideWidths[index] - 1);
const track = page.getByTestId("composer-track-scroll");
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
await track.evaluate((node) => node.clientWidth),
@@ -10,6 +10,7 @@ import {
selectComposerDraftRange,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
// The popover has no semantic role; its rows are React Native Pressables.
function fileSuggestions(page: Page) {
@@ -32,31 +33,33 @@ async function sendEmailIntact(page: Page, email: string): Promise<void> {
await expect(page.getByText(email, { exact: true })).toBeVisible();
}
for (const email of [
"user <12345+user@noreply.example.com>",
"alice@example.com",
"noreply+@example.com",
]) {
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-email-",
title: "Email mention regression",
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("complete a workspace file mention with Enter", async () => {
await selectReadmeMention(page);
});
await test.step("send an email without offering or inserting workspace files", async () => {
await sendEmailIntact(page, email);
});
} finally {
await agent.cleanup();
}
test("Enter sends email formats intact and still completes file mentions", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-email-",
title: "Email mention regression",
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
});
}
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
for (const email of [
"user <12345+user@noreply.example.com>",
"alice@example.com",
"noreply+@example.com",
]) {
await test.step(`complete a file mention, then send ${email} intact`, async () => {
await selectReadmeMention(page);
await sendEmailIntact(page, email);
// Start the next draft only after the daemon and visible agent have settled.
const reply = await agent.client.waitForFinish(agent.agentId, 15_000);
expect(reply.status).toBe("idle");
await expectVisibleAgentSurfacesIdle(page);
});
}
} finally {
await agent.cleanup();
}
});
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
page,
@@ -6,6 +6,7 @@ import {
fillComposerDraft,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
function userMessages(page: Page) {
@@ -29,7 +30,9 @@ async function pressInComposer(page: Page, key: string) {
}
test.describe("Composer recall", () => {
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
test("recalls, edits, and sends messages while keeping slash autocomplete usable", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-",
title: "Composer recall",
@@ -37,49 +40,44 @@ test.describe("Composer recall", () => {
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await test.step("walk sent history, edit a recalled draft, and send it", async () => {
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
await session.cleanup();
}
});
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
});
await test.step("recall a slash message without opening its picker, then edit it", async () => {
await expectComposerDraft(page, "");
const reply = await session.client.waitForFinish(session.agentId, 15_000);
expect(reply.status).toBe("idle");
await expectVisibleAgentSurfacesIdle(page);
await sendAndWaitForMessage(page, "/tdd", 4);
test("a recalled slash message does not open the command picker", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-slash-",
title: "Composer recall slash",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "/tdd", 1);
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
});
} finally {
await session.cleanup();
}
@@ -127,8 +127,42 @@ for (const scenario of [
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectAgentCount(1);
});
test(`an edited prompt after failed agent initialization starts the agent in its workspace${scenario.suffix}`, async ({
creation,
startup,
page,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Start the original task.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Start the edited task.");
await creation.expectPromptVisible("Start the edited task.");
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
});
}
test("an edited agent draft starts after its first start failed", async ({
creation,
startup,
page,
}) => {
await creation.openAgentDraft();
await startup.fail();
await creation.submitPrompt("Start the original task.");
await creation.expectStartupFailure();
await creation.submitPrompt("Start the edited task.");
await creation.expectPromptVisible("Start the edited task.");
await creation.expectAgentCount(1);
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
});
for (const entry of [
{
name: "agent draft",
@@ -159,7 +193,9 @@ for (const entry of [
await entry.settled(creation);
await creation.expectCreatedAgentError();
await creation.expectPromptBeforeError(prompt);
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
await page.screenshot({
path: testInfo.outputPath("created-agent-prompt-error.png"),
});
await creation.reloadAgent();
await creation.expectPromptBeforeError(prompt);
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
@@ -0,0 +1,56 @@
import { test, expect } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
readReactRenders,
recordReactRenders,
resetReactRenders,
} from "../support/helpers/react-renders";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
async function createMockAgent(workspace: SeededWorkspace, title: string): Promise<string> {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "e2e-fast-stream",
});
return agent.id;
}
test.describe("Updates to agents in other workspaces", () => {
let open: SeededWorkspace;
let other: SeededWorkspace;
test.afterEach(async () => {
await open?.cleanup();
await other?.cleanup();
});
test("do not re-render the open workspace screen", async ({ page }) => {
open = await seedWorkspace({ repoPrefix: "unrelated-update-open-", title: "Open" });
other = await seedWorkspace({ repoPrefix: "unrelated-update-other-", title: "Other" });
const openAgentId = await createMockAgent(open, "Open agent");
const otherAgentId = await createMockAgent(other, "Other agent");
await recordReactRenders(page);
await openAgentRoute(page, { workspaceId: open.workspaceId, agentId: openAgentId });
await expect(
page.getByTestId(`workspace-tab-agent_${openAgentId}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(2_000);
await resetReactRenders(page);
await other.client.sendAgentMessage(otherAgentId, "update");
await other.client.waitForFinish(otherAgentId, 60_000);
// The favicon follows every agent on the host, so it renders when the other agent runs and
// settles. That confirms the updates reached this page.
await expect
.poll(async () => (await readReactRenders(page)).FaviconStatusSync ?? 0)
.toBeGreaterThan(0);
const renders = await readReactRenders(page);
expect(renders.WorkspaceScreenContent ?? 0).toBe(0);
});
});
@@ -85,6 +85,20 @@ agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
--artifacts-dir /tmp/paseo-model-tablet-qa
```
`model-tablet-profile.android.ad` taps the `+` of a model row in New workspace, asserts the seeded
profile form opens, and asserts that closing the form leaves the remembered model selected. Run it
on the same tablet setup, after `model-tablet.android.ad` or with `MODEL_LABEL` remembered, against a
host with no agent profiles: with a profile saved, the picker opens on its root view, which has no
Back. Set `PROFILE_MODEL_ID` to a different model on the provider's first page:
```sh
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet-profile.android.ad \
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
--env PROVIDER_ID=codex --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" \
--env PROFILE_MODEL_ID="$MODEL_QA_PROFILE_MODEL_ID"
```
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
@@ -0,0 +1,22 @@
# Connected host with MODEL_LABEL selected and no agent profiles, 1600x2560 at density 320. See README.md.
# FORM_URL=paseo://new?serverId=<serverId>; PROFILE_MODEL_ID is another catalog model with no profile.
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
env PROVIDER_ID=codex
open "${APP_ID}" "${FORM_URL}"
wait "label=\"Workspace project\"" 20000
press "id=\"combined-model-selector\""
wait "id=\"combobox-desktop-container\"" 10000
# A remembered model opens its provider's list. Return to the provider page first.
press "label=\"Back\""
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
press "id=\"model-provider-${PROVIDER_ID}\""
wait "id=\"compact-model-list\"" 3000
# The row's profile action sits inside Android's independent scroll viewport.
press "id=\"model-create-profile-${PROVIDER_ID}-${PROFILE_MODEL_ID}\""
wait "text=\"Add agent profile\"" 5000
# Closing the form keeps the remembered model: the tap did not also select the row.
back
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
close
@@ -505,10 +505,14 @@ export async function expectCreateProfileFromModelRow(
page: Page,
input: { provider: string; modelId: string; modelLabel: string },
): Promise<void> {
const action = pickerViewport(page).getByTestId(
`model-create-profile-${input.provider}-${input.modelId}`,
);
// Until its row is hovered the plus is invisible, so a tap on its spot reaches the row.
await expect(action).toHaveCSS("pointer-events", "none");
await modelRow(page, input).hover();
await pickerViewport(page)
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
.click();
await expect(action).toHaveCSS("pointer-events", "auto");
await action.click();
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
+6 -1
View File
@@ -142,6 +142,8 @@ export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
let workspaceId = project.workspaceId;
// Workspaces the scenario opens itself to evict the deck; they are not creations under test.
const evictionWorkspaceIds = new Set<string>();
return {
cleanup: project.cleanup,
async openWorkspaceForm(isolation: "local" | "worktree") {
@@ -232,7 +234,9 @@ export async function createCreationScenario(page: Page) {
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
);
expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id;
@@ -276,6 +280,7 @@ export async function createCreationScenario(page: Page) {
source: { kind: "directory", path: project.repoPath },
});
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
evictionWorkspaceIds.add(result.workspace.id);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
@@ -8,9 +8,12 @@ android {
defaultConfig {
versionCode 1
versionName '0.1.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
}
dependencies {
implementation 'com.facebook.react:react-android'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name="android.app.Activity" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,81 @@
package sh.paseo.keyboard
import android.app.Activity
import android.content.Intent
import android.view.KeyEvent
import android.widget.EditText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/** Exercises the real Android window and editable that receive physical keys. */
@RunWith(AndroidJUnit4::class)
class TerminalArrowTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
@After fun finish() {
instrumentation.runOnMainSync {
activity?.let {
HardwareKeyboard.release(it.window)
it.finish()
}
}
}
@Test fun physicalArrowsDoNotMoveTheTerminalInputCaret() {
withInput(terminal = true) { input, host ->
for (code in listOf(KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN)) {
for (modifiers in listOf(0, KeyEvent.META_SHIFT_ON, KeyEvent.META_ALT_ON, KeyEvent.META_CTRL_ON)) {
input.setSelection(3)
for (repeat in 0..2) {
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, code, repeat, modifiers)))
assertEquals("Physical arrows must leave the hidden input caret at the end", 3, input.selectionStart)
}
assertTrue(host.window.callback.dispatchKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, code, 0, modifiers)))
}
}
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
assertEquals("abcx", input.text.toString())
}
}
@Test fun physicalArrowsStillEditOtherTextFields() {
withInput(terminal = false) { input, host ->
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT))
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_LEFT))
assertEquals(2, input.selectionStart)
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_X))
host.window.callback.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_X))
assertEquals("abxc", input.text.toString())
}
}
private fun withInput(terminal: Boolean, check: (EditText, Activity) -> Unit) {
val host = instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = host
lateinit var input: EditText
instrumentation.runOnMainSync {
input = EditText(host)
if (terminal) input.setTag(com.facebook.react.R.id.view_tag_native_id, "paseo-terminal-input")
host.setContentView(input)
input.setText("abc")
input.requestFocus()
input.setSelection(3)
HardwareKeyboard.observe(host.window)
}
instrumentation.waitForIdleSync()
instrumentation.runOnMainSync {
assertSame("The editable must own window focus before keys are dispatched", input, host.window.currentFocus)
check(input, host)
}
}
}
@@ -10,18 +10,24 @@ import android.view.KeyEvent
import android.view.Window
import android.widget.EditText
import android.view.inputmethod.BaseInputConnection
import com.facebook.react.R
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.bridge.ReactContext
import com.facebook.react.interfaces.ExtraWindowEventListener
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
// Matched against the hidden terminal input's `nativeID`
// (TERMINAL_INPUT_NATIVE_ID in terminal-input.native.tsx).
private const val TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input"
internal object HardwareKeyboard {
var module: PaseoHardwareKeyboardModule? = null
@Volatile var overlayKeys: Set<String> = emptySet()
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
val dispatcher = WindowKeyDispatcher<KeyEvent>()
private val consumedPresses = mutableSetOf<Int>()
private val terminalArrowCodes = setOf("ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight")
fun observe(window: Window) {
if (windows.containsKey(window)) return
@@ -38,7 +44,7 @@ internal object HardwareKeyboard {
consumedPresses.clear()
}
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
fun dispatch(event: KeyEvent, composing: Boolean, terminalFocused: Boolean): Boolean {
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
val code = code(event.keyCode)
@@ -64,8 +70,12 @@ internal object HardwareKeyboard {
))
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
return overlayOwnsKey
// Without this the hidden input consumes the arrows as caret movement and
// its next text change stops being a plain append. Modifiers stay
// forwarded: Alt/Shift+arrow are terminal editing keys.
val terminalOwnsKey = terminalFocused && !composing && code in terminalArrowCodes
if (overlayOwnsKey || terminalOwnsKey) consumedPresses.add(event.keyCode)
return overlayOwnsKey || terminalOwnsKey
}
private fun code(keyCode: Int): String = when (keyCode) {
@@ -107,9 +117,15 @@ internal object HardwareKeyboard {
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val editable = (window.currentFocus as? EditText)?.text
val focus = window.currentFocus
val editable = (focus as? EditText)?.text
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
val terminalFocused = focus?.getTag(R.id.view_tag_native_id) == TERMINAL_INPUT_NATIVE_ID
return HardwareKeyboard.dispatcher.dispatch(
event,
{ HardwareKeyboard.dispatch(event, composing, terminalFocused) },
{ previous.dispatchKeyEvent(event) },
)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -759,6 +759,56 @@ describe("assistant selection copy inside highlighted code", () => {
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
it("keeps line breaks when a quote selection ends partway through code", () => {
const message = mountHighlighted();
const quote = document.createElement("div");
quote.setAttribute("data-paseo-markdown-tag", "blockquote");
quote.textContent = "Quoted context";
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(quote);
const content = copyBetween(
[tokenText(message, "Quoted context"), 0],
[tokenText(message, " (answer)"), 9],
);
expect(content?.plainText).toBe(
"> Quoted context\n\n```typescript\nconst answer = 1;\n if (answer)\n```",
);
});
it("keeps indented code lines inside their list item", () => {
const host = document.createElement("div");
host.innerHTML = [
'<div data-testid="assistant-message">',
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
'<div data-paseo-markdown-tag="li">',
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
'<div><div data-paseo-markdown-tag="p"><span>Item text</span></div>',
highlightedCodeBlock("typescript"),
"</div></div></div></div>",
].join("");
document.body.append(host);
const content = copyBetween([tokenText(host, "1."), 0], [tokenText(host, " (answer)"), 9]);
expect(content?.plainText).toBe(
"1. Item text\n\n ```typescript\n const answer = 1;\n if (answer)\n ```",
);
});
it("keeps line breaks when a selection starts partway through code", () => {
const message = mountHighlighted();
const content = copyBetween(
[tokenText(message, " answer"), 1],
[tokenText(message, "After the block."), "After the block.".length],
);
expect(content?.plainText).toBe(
"```typescript\nanswer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
});
describe("semantic library output", () => {
@@ -81,7 +81,8 @@ turndown.addRule("compactListItem", {
const item = content
.replace(/^\n+/, "")
.replace(/\n+$/, "\n")
.replace(/\n(?=\S)/g, "\n ");
// Every non-empty line, so indented code lines stay inside the item.
.replace(/\n(?=[^\n])/g, "\n ");
const parent = node.parentElement;
if (parent?.nodeName !== "OL") {
return `${options.bulletListMarker} ${item}\n`;
@@ -359,6 +360,9 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
return true;
}
if (isCrossBoundaryCodeElement(range, element, tag)) {
return true;
}
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
if (
(isSelectableSemantic || element.tagName === "A") &&
@@ -386,6 +390,11 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
return hasSelectedAllContents(range, element, tag === "li");
}
function isCrossBoundaryCodeElement(range: Range, element: Element, tag: string | null): boolean {
const isCode = tag === "pre" || (tag === "code" && Boolean(element.closest(CODE_BLOCK_SELECTOR)));
return isCode && !selectionStaysInsideElement(range, element);
}
function selectionStaysInsideElement(range: Range, element: Element): boolean {
return (
range.startContainer !== element &&
@@ -63,10 +63,25 @@ const html = `<!doctype html>
#diagram svg {
display: block;
}
/* Keep Mermaid's temporary render container unscaled when the host mounts this frame
at 0x0 or 240x240 before the diagram's own dimensions are known. */
#staging-clip {
position: fixed;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
#staging {
width: 16384px;
height: 16384px;
}
</style>
</head>
<body>
<div id="diagram"></div>
<div id="staging-clip" aria-hidden="true"><div id="staging"></div></div>
<script>${js}</script>
</body>
</html>`;
@@ -40,6 +40,9 @@ function initializeMermaid(colorScheme: "light" | "dark"): void {
],
flowchart: { htmlLabels: false },
class: { htmlLabels: false },
// A gantt chart takes the width of the element it renders in, which is the oversized staging
// container. Keep it as wide as this frame, which on native is the message column.
gantt: { useWidth: document.body.clientWidth },
});
}
@@ -97,7 +100,15 @@ function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSi
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
try {
initializeMermaid(message.colorScheme);
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
const staging = document.getElementById("staging");
if (!staging) {
throw new Error("Missing Mermaid staging container");
}
const { svg } = await mermaid.render(
`paseo-mermaid-${message.revision}`,
message.source,
staging,
);
if (message.revision !== latestRevision) {
return;
}
File diff suppressed because one or more lines are too long
@@ -29,9 +29,15 @@ function waitForRuntimeMessage(
});
}
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
async function mountRuntime(
size?: { width: number; height: number },
options?: { allowSameOrigin?: boolean },
): Promise<HTMLIFrameElement> {
const frame = document.createElement("iframe");
frame.sandbox.add("allow-scripts");
if (options?.allowSameOrigin) {
frame.sandbox.add("allow-same-origin");
}
if (size) {
frame.style.width = `${size.width}px`;
frame.style.height = `${size.height}px`;
@@ -112,6 +118,103 @@ describe("Mermaid sandbox runtime", () => {
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
});
/**
* MermaidFenceHost renders the first revision inside a 240x240 measuring frame (or 0x0 before
* ResizeObserver settles), then resizes the frame to the reported diagram size without
* re-rendering. Diagrams such as xychart-beta set `width="100%"` and `viewBox="0 0 700 500"`
* before measuring axis text with `getBoundingClientRect()`, so rendering directly in a narrow
* frame shrinks measured glyph boxes and leaves the Y-axis title colliding with tick labels and
* X-axis labels clipped at the bottom edge.
*/
it("preserves unscaled SVG layout when rendered inside a narrow or zero-sized frame", async () => {
const source = [
"xychart-beta",
' title "Throughput by Stage (req/s)"',
' x-axis ["v1-base", "v2-parse", "v3-batch", "v4-cache", "v5-simd", "v6-final"]',
' y-axis "Throughput (req/s)" 92.5 --> 93.6',
" bar [92.7, 92.8, 93.1, 93.2, 93.3, 93.5]",
].join("\n");
const zeroFrame = await mountRuntime({ height: 0, width: 0 }, { allowSameOrigin: true });
const measuringFrame = await mountRuntime(
{ height: 240, width: 240 },
{ allowSameOrigin: true },
);
const fullFrame = await mountRuntime({ height: 500, width: 700 }, { allowSameOrigin: true });
await render(zeroFrame, { revision: 1, source });
await render(measuringFrame, { revision: 1, source });
await render(fullFrame, { revision: 1, source });
for (const frame of [zeroFrame, measuringFrame]) {
frame.style.width = "700px";
frame.style.height = "500px";
const doc = frame.contentDocument;
if (!doc) {
throw new Error("Expected inspectable runtime document");
}
const svgRect = doc.querySelector("#diagram svg")?.getBoundingClientRect();
const yTitleRect = doc.querySelector(".left-axis .title text")?.getBoundingClientRect();
const yLabels = Array.from(doc.querySelectorAll(".left-axis .label text"));
const xLabels = Array.from(doc.querySelectorAll(".bottom-axis .label text"));
expect(svgRect).not.toBeUndefined();
expect(yTitleRect).not.toBeUndefined();
expect(yLabels.length).toBeGreaterThan(0);
expect(xLabels.length).toBeGreaterThan(0);
for (const label of yLabels) {
expect(yTitleRect!.right).toBeLessThan(label.getBoundingClientRect().left);
}
for (const label of xLabels) {
expect(label.getBoundingClientRect().bottom).toBeLessThanOrEqual(svgRect!.bottom);
}
}
function axisOrigin(frame: HTMLIFrameElement): { leftAxisX: number; bottomAxisY: number } {
const doc = frame.contentDocument;
const leftPath = doc?.querySelector(".left-axis .axisl-line path")?.getAttribute("d") ?? "";
const bottomPath =
doc?.querySelector(".bottom-axis .axis-line path")?.getAttribute("d") ?? "";
const leftMatch = leftPath.match(/^M\s+([\d.]+),/);
const bottomMatch = bottomPath.match(/^M\s+[\d.]+,([\d.]+)/);
if (!leftMatch || !bottomMatch) {
throw new Error(`Unexpected axis paths: ${leftPath} | ${bottomPath}`);
}
return {
leftAxisX: Number.parseFloat(leftMatch[1]),
bottomAxisY: Number.parseFloat(bottomMatch[1]),
};
}
const fullOrigin = axisOrigin(fullFrame);
for (const candidate of [measuringFrame, zeroFrame]) {
const origin = axisOrigin(candidate);
expect(origin.leftAxisX).toBeCloseTo(fullOrigin.leftAxisX, 3);
expect(origin.bottomAxisY).toBeCloseTo(fullOrigin.bottomAxisY, 3);
}
});
/**
* A gantt chart takes the width of the element Mermaid renders it in. Rendering inside the large
* offscreen container must keep it as wide as the frame, which on native is the message column.
*/
it("renders a gantt chart as wide as the frame the host gives it", async () => {
const source = [
"gantt",
" title Release plan",
" dateFormat YYYY-MM-DD",
" section Build",
" Design :a1, 2024-01-01, 10d",
" Implement :after a1, 20d",
].join("\n");
const widths = [];
for (const width of [240, 350, 900]) {
const frame = await mountRuntime({ height: 240, width });
widths.push(renderedSize(await render(frame, { revision: 1, source })).width);
}
expect(widths).toEqual([240, 350, 900]);
});
it("coalesces queued input and never reports an obsolete result", async () => {
const frame = await mountRuntime();
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
+64 -25
View File
@@ -106,29 +106,76 @@ function ProviderSettingsAction({
);
}
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
const { t } = useTranslation();
/**
* An icon button that explains itself in a tooltip on desktop hover. Tooltips
* never open on mobile, so native renders the shared gesture-aware pressable
* instead of a plain Pressable: inside the model list's independent scroll
* viewport (Android, wide windows) the native scroll gesture cancels a plain
* Pressable's touches and the button would never fire.
*/
function IconActionButton({
accessibilityLabel,
testID,
onPress,
style,
children,
}: {
accessibilityLabel: string;
testID: string;
onPress: () => void;
style?:
| StyleProp<ViewStyle>
| ((state: PressableStateCallbackType & { hovered?: boolean }) => StyleProp<ViewStyle>);
children: React.ReactNode;
}) {
if (isNative) {
return (
<ModelBrowserPressable
onPress={onPress}
hitSlop={8}
style={style}
accessibilityLabel={accessibilityLabel}
testID={testID}
>
{children}
</ModelBrowserPressable>
);
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPress}
hitSlop={8}
style={iconButtonStyle}
style={style}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.editProfilesLabel")}
testID="model-profiles-edit"
accessibilityLabel={accessibilityLabel}
testID={testID}
>
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
{children}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("modelSelector.editProfilesLabel")}</Text>
<Text style={styles.tooltipText}>{accessibilityLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
const { t } = useTranslation();
return (
<IconActionButton
onPress={onPress}
style={iconButtonStyle}
accessibilityLabel={t("modelSelector.editProfilesLabel")}
testID="model-profiles-edit"
>
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
</IconActionButton>
);
}
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | null>(
null,
);
@@ -637,24 +684,14 @@ function ModelRowProfileAction({
[visible],
);
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPress}
hitSlop={8}
style={pressableStyle}
pointerEvents={visible ? "auto" : "none"}
accessibilityRole="button"
accessibilityLabel={label}
testID={testID}
>
{children}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
<IconActionButton
onPress={onPress}
style={pressableStyle}
accessibilityLabel={label}
testID={testID}
>
{children}
</IconActionButton>
);
}
@@ -1690,8 +1727,10 @@ const styles = StyleSheet.create((theme) => ({
rowIconButtonPressed: {
backgroundColor: theme.colors.surface1,
},
// Hidden in style so every pressable branch that applies the style also stops taking taps.
profileActionHidden: {
opacity: 0,
pointerEvents: "none",
},
emptyState: {
padding: theme.spacing[4],
@@ -51,10 +51,7 @@ import {
TERMINAL_SELECTION_GRIP_SIZE,
} from "../terminal/native-renderer/terminal-selection-controller";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import {
forwardNativeTerminalKey,
type NativeTerminalKey,
} from "../terminal/native-renderer/terminal-key-events";
import { type NativeTerminalKeyEvent } from "../terminal/native-renderer/terminal-key-events";
import {
TerminalInput,
type TerminalInputHandle,
@@ -511,9 +508,9 @@ function NativeTerminalEmulator({
);
const handleNativeTerminalKey = useCallback(
(key: NativeTerminalKey) => {
(event: NativeTerminalKeyEvent) => {
claimActiveTerminalSizeForAction("key");
forwardNativeTerminalKey({ key, onTerminalKey: callbacksRef.current.onTerminalKey });
callbacksRef.current.onTerminalKey?.(event);
},
[claimActiveTerminalSizeForAction],
);
@@ -88,6 +88,18 @@ describe("bottom sheet visibility tracker", () => {
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
});
it("dismisses again when a sheet hidden while opening finishes opening anyway", () => {
const { sheet, tracker } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
// The owner hides the sheet before its open animation reports in; the sheet still lands open.
tracker.syncDesired({ visible: false });
tracker.handleSheetIndexChange(0);
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }, { type: "dismiss" }]);
});
it("waits to present until the sheet controller becomes available", () => {
const { sheet, tracker } = setup();
tracker.syncDesired({ visible: true });
@@ -120,6 +120,11 @@ export function createBottomSheetVisibilityTracker(opts: {
if (phase === "presenting" || phase === "dismissing") {
setPhase("presented");
}
// A dismiss sent before the sheet measured its layout is dropped, and the sheet still
// finishes opening. The owner wants it hidden, so it goes again.
if (!visible && isEnabled !== false) {
dismiss();
}
return;
}
if (phase === "presenting" || phase === "presented") {
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { draftCreationKey } from "./creation-key";
const request = {
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
workspaceId: "workspace-1",
initialPrompt: "Start the original task.",
clientMessageId: "draft-1:initial-message",
};
describe("draftCreationKey", () => {
it("keeps the key for a retry of the same request, whatever its key order", () => {
const reordered = {
clientMessageId: request.clientMessageId,
initialPrompt: request.initialPrompt,
workspaceId: request.workspaceId,
config: { model: "gpt-6", cwd: "/repo", provider: "codex" },
};
expect(draftCreationKey("draft-1", reordered)).toBe(draftCreationKey("draft-1", request));
});
it.each([
{ name: "prompt", edited: { ...request, initialPrompt: "Start the edited task." } },
{ name: "model", edited: { ...request, config: { ...request.config, model: "gpt-6-mini" } } },
])("gives an edited $name a new key on the same draft", ({ edited }) => {
const key = draftCreationKey("draft-1", edited);
expect(key).not.toBe(draftCreationKey("draft-1", request));
expect(key.startsWith("draft-1:")).toBe(true);
});
});
@@ -0,0 +1,29 @@
/**
* The creation key for a draft's request. The daemon keeps one request per key, so retrying the
* same draft resumes its creation, while an edited draft is a new request with its own key.
*/
export function draftCreationKey(draftId: string, request: object): string {
return `${draftId}:${hash(canonicalJson(request))}`;
}
function canonicalJson(value: unknown): string {
return JSON.stringify(value, (_key, entry: unknown) =>
entry && typeof entry === "object" && !Array.isArray(entry)
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)))
: entry,
);
}
// 53-bit cyrb53: distinguishes the edits of one draft without WebCrypto, which needs HTTPS.
function hash(text: string): string {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
h1 = Math.imul(h1 ^ code, 2654435761);
h2 = Math.imul(h2 ^ code, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
}
@@ -47,6 +47,7 @@ import {
} from "@/workspace-tabs/model";
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
import { useSettings } from "@/hooks/use-settings";
import { draftCreationKey } from "@/composer/draft/creation-key";
interface AutoSubmitConfig {
provider: string;
@@ -164,8 +165,7 @@ async function submitDraftCreateRequest(input: {
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const imagesData = await encodeImages(images);
const options = {
idempotencyKey: input.draftId,
const request = {
config,
workspaceId,
initialPrompt: text,
@@ -174,8 +174,10 @@ async function submitDraftCreateRequest(input: {
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
};
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
const result = creation ? await creation.retry(options) : await client.createAgent(options);
const result = await client.createAgent({
...request,
idempotencyKey: draftCreationKey(input.draftId, request),
});
return {
agentId: result.id,
@@ -1,10 +1,11 @@
import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import { useMemo, useCallback, useRef, useSyncExternalStore } from "react";
import equal from "fast-deep-equal";
import { useShallow } from "zustand/shallow";
import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useAllDirectoryDemand } from "./use-directory-demand";
export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string;
@@ -19,6 +20,9 @@ export interface AggregatedAgentsResult {
refreshAll: () => void;
}
const NO_AGENT_MAPS: Record<string, Map<string, Agent> | undefined> = Object.freeze({});
const NO_AGENTS: AggregatedAgent[] = [];
export function useAggregatedAgents(options?: {
includeArchived?: boolean;
demand?: boolean;
@@ -28,19 +32,21 @@ export function useAggregatedAgents(options?: {
const includeArchived = options?.includeArchived ?? false;
const demand = options?.demand ?? true;
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
useEffect(() => {
if (!demand) return;
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [demand, runtime, serverIds]);
useAllDirectoryDemand({ serverIds, enabled: demand });
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
// The caller has said it does not need the directory, so stay off the agent maps entirely:
// every map change otherwise re-renders this hook and rebuilds the whole list for a surface
// that is not on screen (the command center while it is closed).
const sessionAgents = useSessionStore(
useShallow((state) => {
if (!demand) {
return NO_AGENT_MAPS;
}
const result: Record<string, Map<string, Agent> | undefined> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.agents;
@@ -64,6 +70,16 @@ export function useAggregatedAgents(options?: {
const result = useMemo(() => {
// runtimeVersion is referenced so the memo recomputes when runtime state changes.
void runtimeVersion;
if (!demand) {
prevAgentsRef.current = new Map();
prevSortedRef.current = NO_AGENTS;
return {
agents: NO_AGENTS,
isLoading: false,
isInitialLoad: false,
isRevalidating: false,
};
}
const allAgents: AggregatedAgent[] = [];
const serverLabelById = new Map(
daemons.map((daemon) => [daemon.serverId, daemon.label] as const),
@@ -158,7 +174,7 @@ export function useAggregatedAgents(options?: {
isInitialLoad,
isRevalidating,
};
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
}, [daemons, demand, includeArchived, runtime, runtimeVersion, sessionAgents]);
return {
...result,
@@ -14,3 +14,21 @@ export function useDirectoryDemand(serverId: string | null | undefined): void {
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
}
/**
* Holds directory demand for several hosts at once, so a caller that needs the whole fleet
* (the favicon, the aggregated agent list) does not have to subscribe to every agent map to
* keep the directories loading.
*/
export function useAllDirectoryDemand(input: {
serverIds: readonly string[];
enabled: boolean;
}): void {
const { serverIds, enabled } = input;
useEffect(() => {
if (!enabled) return;
const runtime = getHostRuntimeStore();
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
return () => releases.forEach((release) => release());
}, [enabled, serverIds]);
}
+22 -31
View File
@@ -1,12 +1,13 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { useAllDirectoryDemand } from "./use-directory-demand";
import { getDesktopHost } from "@/desktop/host";
import { useWorkspaceStatusesForBadges } from "@/stores/session-store-hooks";
import { deriveMacDockBadgeCountFromWorkspaceStatuses } from "@/utils/desktop-badge-state";
import { createMacDockBadgeCountSelector } from "@/utils/desktop-badge-state";
import { createFaviconStatusSelector, type FaviconStatus } from "@/utils/favicon-status";
import { isNative } from "@/constants/platform";
import { useSessionStore } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light";
/* eslint-disable @typescript-eslint/no-require-imports */
@@ -24,21 +25,6 @@ const FAVICON_IMAGES: Record<ColorScheme, Record<FaviconStatus, { uri: string }
};
/* eslint-enable @typescript-eslint/no-require-imports */
function deriveFaviconStatus(
agents: ReturnType<typeof useAggregatedAgents>["agents"],
): FaviconStatus {
const hasRunning = agents.some((agent) => agent.status === "running");
if (hasRunning) {
return "running";
}
const hasAttention = agents.some((agent) => agent.requiresAttention);
const hasNeedsInput = agents.some((agent) => (agent.pendingPermissionCount ?? 0) > 0);
if (hasAttention || hasNeedsInput) {
return "attention";
}
return "none";
}
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
const image = FAVICON_IMAGES[colorScheme][status];
if (typeof image === "object" && "uri" in image) {
@@ -94,8 +80,15 @@ async function updateMacDockBadge(count?: number) {
}
export function useFaviconStatus() {
const { agents } = useAggregatedAgents({ demand: !isNative });
const workspaceStatuses = useWorkspaceStatusesForBadges();
const daemons = useHosts();
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
// The favicon reads the whole fleet, so it has to keep every host's directory loading even
// though it no longer subscribes to the agent maps.
useAllDirectoryDemand({ serverIds, enabled: !isNative });
const selectFaviconStatus = useMemo(createFaviconStatusSelector, []);
const selectMacDockBadgeCount = useMemo(createMacDockBadgeCountSelector, []);
const status = useSessionStore(selectFaviconStatus);
const dockBadgeCount = useSessionStore(selectMacDockBadgeCount);
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
@@ -112,17 +105,15 @@ export function useFaviconStatus() {
return () => mediaQuery.removeEventListener("change", handler);
}, []);
// Update favicon when agents or color scheme changes
useEffect(() => {
if (isNative) return;
const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme);
}, [status, colorScheme]);
const dockBadgeCount = deriveMacDockBadgeCountFromWorkspaceStatuses(workspaceStatuses);
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}
}, [agents, colorScheme, workspaceStatuses]);
useEffect(() => {
if (isNative) return;
if (dockBadgeCount === lastDockBadgeCountRef.current) return;
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}, [dockBadgeCount]);
}
+2
View File
@@ -8,6 +8,8 @@ export {
subscribeOverlays,
} from "./internal/overlay-dispatch";
export { useKeyboardFocusSurface } from "./internal/use-focus-surface";
export { subscribeHardwareKeyboard } from "./internal/sources/hardware";
export type { HardwareKeyboardEvent } from "./internal/sources/hardware";
export { usesDesktopBindings, supportsConfigurableComposerSendKeys } from "./internal/conventions";
export type { BrowserKeyboardPolicy } from "./internal/sources/electron-browser";
export { useHardwareKeyboardPresent } from "./availability";
@@ -1,8 +1,5 @@
import { useAddHost } from "@/hosts/add-host";
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -126,7 +123,6 @@ import {
} from "./workspace/terminals/state";
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
import { retriedAgentRequest } from "./new-workspace/retry-agent-request";
function useIsNewWorkspaceDraftHandoffActive(input: {
draftId: string | undefined;
@@ -243,7 +239,6 @@ interface SubmitDraftInput {
clearConsumedDraft: () => void;
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
draftKey: string;
@@ -471,13 +466,13 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
images: images?.length ? images : undefined,
attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined,
};
const execute = async (requestedAgent = initialAgent): Promise<AgentSnapshotPayload> => {
const execute = async (): Promise<AgentSnapshotPayload> => {
const { agent } = await ensureWorkspace({
cwd,
prompt: text,
attachments: workspaceNamingAttachments,
withInitialAgent: true,
agent: requestedAgent,
agent: initialAgent,
onEvent: (snapshot) => {
if (!snapshot.workspace || navigated) return;
navigated = true;
@@ -516,11 +511,8 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
if (!agent) throw new Error("Workspace creation returned no agent");
return agent;
};
const agentCreation = {
result: Promise.resolve().then(() => execute()),
retry: (request: CreateAgentRequestOptions) =>
execute(retriedAgentRequest(initialAgent, request, cwd)),
};
// A retry after a failed agent start belongs to the ready workspace's draft, not to this creation.
const agentCreation = { result: Promise.resolve().then(() => execute()) };
await agentCreation.result;
if (outcome === "background") clearConsumedDraft();
return outcome;
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { retriedAgentRequest, type WorkspaceAgentRequest } from "./retry-agent-request";
const initial: WorkspaceAgentRequest = {
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
metadata: { "issues.key": "PAS-1" },
initialPrompt: "first try",
clientMessageId: "draft_1:initial-message",
};
describe("retriedAgentRequest", () => {
it("creates with the metadata the draft resubmitted", () => {
const retried = retriedAgentRequest(
initial,
{
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
initialPrompt: "second try",
metadata: { "issues.key": "PAS-2" },
},
"/repo",
);
expect(retried.metadata).toEqual({ "issues.key": "PAS-2" });
expect(retried.initialPrompt).toBe("second try");
expect(retried.clientMessageId).toBe("draft_1:initial-message");
});
it("creates with no metadata once the draft cleared it", () => {
const retried = retriedAgentRequest(
initial,
{ config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, initialPrompt: "second try" },
"/repo",
);
expect("metadata" in retried).toBe(false);
});
it("keeps the first attempt's directory over the resubmitted config", () => {
const retried = retriedAgentRequest(
initial,
{
config: { provider: "codex", cwd: "/elsewhere", model: "gpt-6-mini" },
initialPrompt: "second try",
},
"/repo",
);
expect(retried.config).toEqual({ provider: "codex", cwd: "/repo", model: "gpt-6-mini" });
});
});
@@ -1,28 +0,0 @@
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
export type WorkspaceAgentRequest = NonNullable<CreateWorkspaceRequestOptions["agent"]>;
/**
* The agent a retried creation asks for: everything the draft resubmitted (config, prompt,
* images, attachments and metadata, cleared included) on the first attempt's identity, so a
* retry creates what the composer shows now, not what it showed when creation first failed.
*/
export function retriedAgentRequest(
initial: WorkspaceAgentRequest,
request: CreateAgentRequestOptions,
cwd: string,
): WorkspaceAgentRequest {
const { metadata: _replaced, ...identity } = initial;
return {
...identity,
config: { ...request.config!, cwd },
initialPrompt: request.initialPrompt ?? "",
clientMessageId: initial.clientMessageId,
images: request.images,
attachments: request.attachments,
...(request.metadata ? { metadata: request.metadata } : {}),
};
}
@@ -17,22 +17,16 @@ import {
selectWorkspaceExists,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceStatusesForBadges,
workspaceEqualityFns,
type WorkspaceStructure,
} from "./selectors";
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
// These are the ONLY supported ways to read workspaces from the session store.
// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object,
// or the sessions dict — it breaks re-render isolation.
export type {
DesktopBadgeWorkspaceStatus,
WorkspaceStructure,
WorkspaceStructureProject,
} from "./selectors";
export type { WorkspaceStructure, WorkspaceStructureProject } from "./selectors";
export function useWorkspace(
serverId: string | null,
@@ -163,11 +157,3 @@ export function useHasWorkspaces(serverId: string | null): boolean {
workspaceEqualityFns.identity,
);
}
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceStatusesForBadges(state),
workspaceEqualityFns.deep,
);
}
@@ -14,7 +14,6 @@ import {
selectWorkspaceDirectory,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceStatusesForBadges,
selectWorkspaceStructureProjects,
workspaceEqualityFns,
type SidebarOrderSnapshot,
@@ -589,36 +588,6 @@ describe("selectHasWorkspaces", () => {
});
});
describe("selectWorkspaceStatusesForBadges", () => {
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" });
initializeWorkspaces([workspaceA, workspaceB]);
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspaceStatusesForBadges(state),
workspaceEqualityFns.deep,
);
const before = tracked.current;
expect(before).toEqual(["done", "attention"]);
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]);
expect(tracked.current).toBe(before);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]);
expect(tracked.current).toBe(before);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]);
expect(tracked.current).not.toBe(before);
expect(tracked.current).toEqual(["failed", "attention"]);
tracked.stop();
});
});
it("a prompt changes only its project's structure and output preserves both project identities", () => {
const a = { ...createWorkspace({ id: "a", projectId: "p" }), createdAt: new Date("2026-01-01") };
const b = { ...createWorkspace({ id: "b", projectId: "p" }), createdAt: new Date("2026-02-01") };
@@ -5,11 +5,9 @@ import {
type WorkspaceStructure,
type WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure";
export interface SessionsSnapshot {
@@ -321,15 +319,3 @@ export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string |
}
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
}
export function selectWorkspaceStatusesForBadges(
state: SessionsSnapshot,
): DesktopBadgeWorkspaceStatus[] {
const statuses: DesktopBadgeWorkspaceStatus[] = [];
for (const session of Object.values(state.sessions)) {
for (const workspace of session.workspaces.values()) {
statuses.push(workspace.status);
}
}
return statuses;
}
@@ -45,27 +45,3 @@ test("the production app consumes a draft once and leaves other drafts pending",
expect(store.getState().consumePending(submission)).toBeNull();
expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]);
});
test("consuming draft presentation retains its running creation through remount and retry", () => {
const store = loadProductionStore();
const result = new Promise<never>(() => {});
const creation = { result, retry: () => result };
const submission = {
serverId: "server",
workspaceId: "workspace",
draftId: "draft-one",
text: "hello",
attachments: [],
cwd: "/repo",
provider: "codex",
clientMessageId: "message",
timestamp: 1,
agentCreation: creation,
};
store.getState().setPending(submission);
store.getState().consumePending(submission);
expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined();
expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation);
store.getState().clearDraftSetup({ draftId: submission.draftId });
expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined();
});
@@ -1,4 +1,3 @@
import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types";
@@ -9,7 +8,6 @@ export interface PendingWorkspaceDraftSubmission {
/** Already-running creation. Mounting the draft only observes its result. */
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
workspaceId: string;
@@ -35,7 +33,6 @@ export interface PendingWorkspaceDraftSetup {
interface WorkspaceDraftSubmissionState {
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
creationByDraftId: Record<string, NonNullable<PendingWorkspaceDraftSubmission["agentCreation"]>>;
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
setDraftSetup: (input: {
draftId: string;
@@ -69,12 +66,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
(set, get) => ({
pendingByDraftId: {},
setupByDraftId: {},
creationByDraftId: {},
setPending: (submission) =>
set((state) => ({
creationByDraftId: submission.agentCreation
? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation }
: state.creationByDraftId,
pendingByDraftId: {
...state.pendingByDraftId,
[submission.draftId]: submission,
@@ -94,10 +87,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
const normalizedDraftId = normalizeDraftId(draftId);
if (!normalizedDraftId) return;
set((state) => {
const creationByDraftId = { ...state.creationByDraftId };
delete creationByDraftId[normalizedDraftId];
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
return { setupByDraftId, creationByDraftId };
return { setupByDraftId };
});
},
consumePending: (input) => {
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
import { shallow } from "zustand/shallow";
import { getAgentTabsNeedingOpenLabel, selectOpenTabAgents } from "./open-tab-labels";
const label = "paseo.open-agent-tab.client-1";
@@ -51,3 +52,54 @@ describe("getAgentTabsNeedingOpenLabel", () => {
).toEqual([]);
});
});
describe("selectOpenTabAgents", () => {
function stateWith(agents: Agent[]) {
return {
sessions: {
srv: { agents: new Map(agents.map((entry) => [entry.id, entry])), agentDetails: new Map() },
},
};
}
it("stays equal when only an agent without an open tab changes", () => {
const open = agent({ id: "open" });
const before = selectOpenTabAgents(stateWith([open, agent({ id: "elsewhere" })]), {
serverId: "srv",
agentIds: ["open"],
});
const after = selectOpenTabAgents(stateWith([open, agent({ id: "elsewhere" })]), {
serverId: "srv",
agentIds: ["open"],
});
expect(shallow(before, after)).toBe(true);
});
it("changes when an agent with an open tab changes", () => {
const elsewhere = agent({ id: "elsewhere" });
const before = selectOpenTabAgents(stateWith([agent({ id: "open" }), elsewhere]), {
serverId: "srv",
agentIds: ["open"],
});
const after = selectOpenTabAgents(stateWith([agent({ id: "open" }), elsewhere]), {
serverId: "srv",
agentIds: ["open"],
});
expect(shallow(before, after)).toBe(false);
});
it("falls back to agent details and returns null for unknown agents", () => {
const detail = agent({ id: "detail" });
const state = {
sessions: {
srv: { agents: new Map<string, Agent>(), agentDetails: new Map([["detail", detail]]) },
},
};
expect(
selectOpenTabAgents(state, { serverId: "srv", agentIds: ["detail", "missing"] }),
).toEqual([detail, null]);
});
});
@@ -23,3 +23,24 @@ export function getAgentTabsNeedingOpenLabel(input: {
}
return [...agentIds];
}
interface OpenTabAgentSource {
sessions: Record<
string,
{ agents: ReadonlyMap<string, Agent>; agentDetails: ReadonlyMap<string, Agent> } | undefined
>;
}
/**
* The agents behind the given open tabs, in tab order. Compared element by element, this changes
* only when one of those agents changes, not on updates to other agents on the host.
*/
export function selectOpenTabAgents(
state: OpenTabAgentSource,
input: { serverId: string; agentIds: readonly string[] },
): (Agent | null)[] {
const session = state.sessions[input.serverId];
return input.agentIds.map(
(agentId) => session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null,
);
}
@@ -1,10 +1,11 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/shallow";
import { useSessionStore } from "@/stores/session-store";
import { getOrCreateClientId } from "@/utils/client-id";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
import { getAgentTabsNeedingOpenLabel, selectOpenTabAgents } from "./open-tab-labels";
function increment(value: number): number {
return value + 1;
@@ -16,9 +17,16 @@ export function useOpenAgentTabLabels(input: {
tabs: WorkspaceTab[];
enabled: boolean;
}): void {
const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null);
const agentDetails = useSessionStore(
(state) => state.sessions[input.serverId]?.agentDetails ?? null,
const openAgentIds = useMemo(
() => input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
[input.tabs],
);
// Only the agents with open tabs can need a label. Subscribing to the whole agent map would
// re-render the workspace screen on every update to any agent on the host.
const openAgents = useSessionStore(
useShallow((state) =>
selectOpenTabAgents(state, { serverId: input.serverId, agentIds: openAgentIds }),
),
);
const pendingAgentIdsRef = useRef(new Set<string>());
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -39,15 +47,13 @@ export function useOpenAgentTabLabels(input: {
return;
}
const openAgentIds = new Set(
input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
);
const agentsById = new Map(openAgentIds.map((agentId, index) => [agentId, openAgents[index]]));
for (const agentId of pendingAgentIdsRef.current) {
if (!openAgentIds.has(agentId)) {
if (!agentsById.has(agentId)) {
pendingAgentIdsRef.current.delete(agentId);
}
}
if (openAgentIds.size === 0) {
if (agentsById.size === 0) {
return;
}
@@ -57,7 +63,7 @@ export function useOpenAgentTabLabels(input: {
const label = getOpenAgentTabLabel(clientId);
const agentIds = getAgentTabsNeedingOpenLabel({
tabs: input.tabs,
getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId),
getAgent: (agentId) => agentsById.get(agentId),
label,
pendingAgentIds: pendingAgentIdsRef.current,
});
@@ -82,5 +88,5 @@ export function useOpenAgentTabLabels(input: {
console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error });
}
})();
}, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]);
}, [input.client, input.enabled, input.tabs, openAgentIds, openAgents, retryVersion]);
}
@@ -6,7 +6,7 @@ import {
TERMINAL_INPUT_CONTEXT_MENU_HIDDEN,
TERMINAL_INPUT_HITBOX_SIZE,
} from "./terminal-input.native";
import { forwardNativeTerminalKey, type NativeTerminalKeyEvent } from "./terminal-key-events";
import { createNativeTerminalKeyEvent, resolveHardwareTerminalKey } from "./terminal-key-events";
describe("native terminal typed input", () => {
function inputData(change: { data: string; shouldClear: boolean }): string {
@@ -149,25 +149,18 @@ describe("native terminal typed input", () => {
});
});
it("forwards native arrow key events to the terminal key path", () => {
const events: NativeTerminalKeyEvent[] = [];
forwardNativeTerminalKey({
key: "ArrowUp",
onTerminalKey: (event) => {
events.push(event);
},
it("identifies hardware arrows by code and keeps their modifiers", () => {
// UIKit leaves `key` as a sentinel for Left/Right, so the code leads.
expect(resolveHardwareTerminalKey({ key: "\uF702", code: "ArrowLeft" })).toBe("ArrowLeft");
expect(resolveHardwareTerminalKey({ key: "ArrowUp", code: "" })).toBe("ArrowUp");
expect(resolveHardwareTerminalKey({ key: "a", code: "KeyA" })).toBeNull();
expect(createNativeTerminalKeyEvent("ArrowDown", { shift: true, alt: true })).toEqual({
key: "ArrowDown",
ctrl: false,
shift: true,
alt: true,
meta: false,
});
expect(events).toEqual([
{
key: "ArrowUp",
ctrl: false,
shift: false,
alt: false,
meta: false,
},
]);
});
it("keeps the hidden input from owning terminal long-press selection", () => {
@@ -1,5 +1,6 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import {
Platform,
StyleSheet,
type NativeSyntheticEvent,
type StyleProp,
@@ -10,10 +11,19 @@ import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { resolveNativeTerminalKey, type NativeTerminalKey } from "./terminal-key-events";
import { hasActiveOverlay, subscribeHardwareKeyboard } from "@/keyboard";
import {
createNativeTerminalKeyEvent,
resolveHardwareTerminalKey,
resolveNativeTerminalKey,
type NativeTerminalKey,
type NativeTerminalKeyEvent,
} from "./terminal-key-events";
export const TERMINAL_INPUT_CONTEXT_MENU_HIDDEN = true;
export const TERMINAL_INPUT_HITBOX_SIZE = 1;
/** Matched by the Android hardware keyboard module to claim arrow keys for this input. */
export const TERMINAL_INPUT_NATIVE_ID = "paseo-terminal-input";
export interface TerminalTextInputChange {
data: string;
@@ -39,7 +49,7 @@ interface TerminalInputProps {
isKeyboardVisible: boolean;
onFocus?: () => void;
onInput?: (data: string) => void;
onTerminalKey?: (key: NativeTerminalKey) => void;
onTerminalKey?: (event: NativeTerminalKeyEvent) => void;
style?: StyleProp<TextStyle>;
}
@@ -187,6 +197,8 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
const inputRef = useRef<EditingTextInputHandle>(null);
const isFocusedRef = useRef(false);
const pendingFocusFrameRef = useRef<number | null>(null);
const onTerminalKeyRef = useRef(onTerminalKey);
onTerminalKeyRef.current = onTerminalKey;
const inputState = useMemo(() => createTerminalTextInputState(), []);
const inputStyle = useMemo(() => [styles.input, style], [style]);
@@ -250,6 +262,27 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
useEffect(() => clearPendingFocus, [clearPendingFocus]);
useEffect(() => {
// Physical arrows arrive through the native key stream, never through
// TextInput.onKeyPress (RN builds that event from the IME connection).
// The native module claims them for this input so the hidden buffer
// cannot move its caret, then re-emits them here for the PTY.
if (Platform.OS !== "android") return;
return subscribeHardwareKeyboard((event) => {
if (!isFocusedRef.current || event.phase !== "down" || event.isComposing) return;
if (hasActiveOverlay()) return;
const key = resolveHardwareTerminalKey(event);
if (!key) return;
onTerminalKeyRef.current?.({
key,
ctrl: event.ctrlKey,
shift: event.shiftKey,
alt: event.altKey,
meta: event.metaKey,
});
});
}, []);
useImperativeHandle(
ref,
() => ({
@@ -293,7 +326,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
const change = inputState.receiveKeyPress(event.nativeEvent.key);
if (change.key) {
onTerminalKey?.(change.key);
onTerminalKey?.(createNativeTerminalKeyEvent(change.key));
}
if (change.data.length > 0) {
onInput?.(change.data);
@@ -321,6 +354,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
blurOnSubmit={false}
importantForAutofill="no"
multiline={true}
nativeID={TERMINAL_INPUT_NATIVE_ID}
onChangeText={handleChangeText}
onBlur={handleBlur}
onFocus={handleFocus}
@@ -22,19 +22,26 @@ export function resolveNativeTerminalKey(key: string): NativeTerminalKey | null
}
}
export function createNativeTerminalKeyEvent(key: NativeTerminalKey): NativeTerminalKeyEvent {
return {
key,
ctrl: false,
shift: false,
alt: false,
meta: false,
};
/**
* Physical keyboards report the DOM `code`. UIKit leaves `key` as a sentinel
* for Left/Right, so the code is the only reliable identifier there.
*/
export function resolveHardwareTerminalKey(input: {
key?: string;
code?: string;
}): NativeTerminalKey | null {
return resolveNativeTerminalKey(input.code ?? "") ?? resolveNativeTerminalKey(input.key ?? "");
}
export function forwardNativeTerminalKey(input: {
key: NativeTerminalKey;
onTerminalKey?: NativeTerminalKeyHandler;
}): void {
input.onTerminalKey?.(createNativeTerminalKeyEvent(input.key));
export function createNativeTerminalKeyEvent(
key: NativeTerminalKey,
modifiers: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {},
): NativeTerminalKeyEvent {
return {
key,
ctrl: modifiers.ctrl ?? false,
shift: modifiers.shift ?? false,
alt: modifiers.alt ?? false,
meta: modifiers.meta ?? false,
};
}
@@ -115,6 +115,26 @@ interface CreateTerminalHostInput {
isMac?: boolean;
}
/** Text typed into xterm's textarea, as the browser reports a typed character. */
function typeText(mounted: MountedTerminal, text: string): void {
mounted.host
.querySelector(".xterm-helper-textarea")
?.dispatchEvent(
new InputEvent("input", { data: text, inputType: "insertText", bubbles: true }),
);
}
/** A native paste of plain text into xterm's textarea. */
function pasteText(mounted: MountedTerminal, text: string): void {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", text);
mounted.host
.querySelector(".xterm-helper-textarea")
?.dispatchEvent(
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
);
}
function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
const root = document.createElement("div");
root.style.width = `${input.width}px`;
@@ -824,6 +844,23 @@ describe("incremental replay lifecycle", () => {
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
});
it("keeps what the user types, but not pastes, while a replay is still parsing", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.runtime.replay({ type: "begin" });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07restored") });
mounted.runtime.replay({ type: "end" });
typeText(mounted, "a");
pasteText(mounted, "echo one\necho two\n");
typeText(mounted, "f");
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
);
expect(mounted.inputs).toEqual(["a", "f"]);
});
it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
const text = "A😀B❤️C👋🏽D👨‍👩‍👧E";
@@ -124,6 +124,7 @@ interface TerminalEmulatorRuntimeDisposables {
clearFitTimeouts: () => void;
removeFontListeners: () => void;
removeFilePasteListener: () => void;
removeUserInputListeners: () => void;
restoreDocumentStyles: () => void;
restoreViewportStyles: () => void;
disposeFitAddon: () => void;
@@ -157,6 +158,10 @@ const isAppleHandheld =
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
// DOM events xterm turns into typed data on its textarea. Pastes stay suppressed during a
// replay: until the snapshot restores bracketed paste mode, a multi-line paste would run
// line by line in the shell.
const USER_INPUT_EVENT_TYPES = ["keydown", "keypress", "input"] as const;
const RESET_TERMINAL_OUTPUT = new Uint8Array([0x1b, 0x63]);
const terminalOutputEncoder = new TextEncoder();
@@ -313,6 +318,8 @@ export class TerminalEmulatorRuntime {
private hasUngatedWrites = false;
private readonly inputModeDecoder = new TextDecoder();
private suppressInput = false;
// The latest user input event the host saw; its eventPhase is NONE once dispatch ends.
private userInputEvent: Event | null = null;
private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
private themeBackgroundElements: HTMLElement[] = [];
@@ -739,8 +746,17 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize({ forceRefresh: true, shouldClaim: false });
// xterm reports what the user types and the replies it generates while parsing
// through the same onData. Replies only come out of parsing a write, never while a
// user input event is dispatching, so typing survives a replay that suppresses them.
const recordUserInputEvent = (event: Event): void => {
this.userInputEvent = event;
};
for (const type of USER_INPUT_EVENT_TYPES) {
input.host.addEventListener(type, recordUserInputEvent, true);
}
const inputDisposable = terminal.onData((data) => {
if (this.suppressInput) {
if (this.suppressInput && !this.isDispatchingUserInput()) {
return;
}
this.callbacks.onInput?.(data);
@@ -844,6 +860,12 @@ export class TerminalEmulatorRuntime {
removeFilePasteListener: () => {
input.host.removeEventListener("paste", filePasteHandler, true);
},
removeUserInputListeners: () => {
for (const type of USER_INPUT_EVENT_TYPES) {
input.host.removeEventListener(type, recordUserInputEvent, true);
}
this.userInputEvent = null;
},
restoreDocumentStyles,
restoreViewportStyles,
disposeFitAddon: () => {
@@ -873,6 +895,7 @@ export class TerminalEmulatorRuntime {
disposables.clearFitTimeouts();
disposables.removeFontListeners();
disposables.removeFilePasteListener();
disposables.removeUserInputListeners();
disposables.disposeFitAddon();
disposables.disposeWebglAddon();
disposables.disposeTerminal();
@@ -913,6 +936,10 @@ export class TerminalEmulatorRuntime {
this.terminal?.paste(text);
}
private isDispatchingUserInput(): boolean {
return (this.userInputEvent?.eventPhase ?? Event.NONE) !== Event.NONE;
}
private pasteClipboard(terminal: Pick<Terminal, "paste">): void {
const text = this.clipboard.readText().catch(() => "");
const onFilePaste = this.callbacks.onFilePaste;
@@ -1,9 +1,39 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
deriveMacDockBadgeCountFromWorkspaceStatuses,
createMacDockBadgeCountSelector,
isWorkspaceActionableForDesktopBadge,
selectMacDockBadgeCount,
} from "./desktop-badge-state";
function workspace(id: string, status: WorkspaceDescriptor["status"]): WorkspaceDescriptor {
return {
createdAt: null,
lastUserMessageAt: null,
id,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: id,
status,
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
};
}
function stateWith(...workspaces: WorkspaceDescriptor[]) {
return {
sessions: {
srv_a: { workspaces: new Map(workspaces.map((entry) => [entry.id, entry])) },
},
};
}
describe("desktop-badge-state", () => {
it("treats attention-requiring workspace statuses as actionable", () => {
expect(isWorkspaceActionableForDesktopBadge("attention")).toBe(true);
@@ -17,18 +47,40 @@ describe("desktop-badge-state", () => {
});
it("returns undefined when no visible workspaces need attention", () => {
expect(deriveMacDockBadgeCountFromWorkspaceStatuses(["done", "running"])).toBeUndefined();
expect(
selectMacDockBadgeCount(stateWith(workspace("a", "done"), workspace("b", "running"))),
).toBeUndefined();
});
it("counts only actionable visible workspaces", () => {
expect(
deriveMacDockBadgeCountFromWorkspaceStatuses([
"done",
"attention",
"running",
"needs_input",
"failed",
]),
).toBe(3);
it("counts only actionable visible workspaces across every host", () => {
const state = {
sessions: {
srv_a: {
workspaces: new Map([
["a", workspace("a", "done")],
["b", workspace("b", "attention")],
]),
},
srv_b: {
workspaces: new Map([
["c", workspace("c", "running")],
["d", workspace("d", "needs_input")],
["e", workspace("e", "failed")],
]),
},
},
};
expect(selectMacDockBadgeCount(state)).toBe(3);
});
it("recounts only when a workspace map is replaced", () => {
const select = createMacDockBadgeCountSelector();
const state = stateWith(workspace("a", "attention"));
expect(select(state)).toBe(1);
state.sessions.srv_a.workspaces.set("b", workspace("b", "failed"));
expect(select({ sessions: { ...state.sessions } })).toBe(1);
expect(select(stateWith(workspace("a", "attention"), workspace("b", "failed")))).toBe(2);
});
});
+31 -4
View File
@@ -1,14 +1,41 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { memoizeOnSessionMaps } from "./memoize-on-session-maps";
export type DesktopBadgeWorkspaceStatus = WorkspaceDescriptor["status"];
interface WorkspaceStatusSession {
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
}
interface WorkspaceStatusSource {
sessions: Record<string, WorkspaceStatusSession | undefined>;
}
export function isWorkspaceActionableForDesktopBadge(status: DesktopBadgeWorkspaceStatus): boolean {
return status === "attention" || status === "needs_input" || status === "failed";
}
export function deriveMacDockBadgeCountFromWorkspaceStatuses(
statuses: readonly DesktopBadgeWorkspaceStatus[],
): number | undefined {
const actionableCount = statuses.filter(isWorkspaceActionableForDesktopBadge).length;
/**
* The macOS dock badge only needs a count, so count here instead of materialising every
* workspace status and comparing the whole list on every store write.
*/
export function selectMacDockBadgeCount(state: WorkspaceStatusSource): number | undefined {
let actionableCount = 0;
for (const session of Object.values(state.sessions)) {
if (!session) continue;
for (const workspace of session.workspaces.values()) {
if (isWorkspaceActionableForDesktopBadge(workspace.status)) actionableCount += 1;
}
}
return actionableCount > 0 ? actionableCount : undefined;
}
/** A session-store selector for the dock badge count that recounts only when a workspace map changes. */
export function createMacDockBadgeCountSelector(): (
state: WorkspaceStatusSource,
) => number | undefined {
return memoizeOnSessionMaps<WorkspaceStatusSession, WorkspaceStatusSource, number | undefined>(
(session) => session.workspaces,
selectMacDockBadgeCount,
);
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { createFaviconStatusSelector, deriveFaviconStatus } from "./favicon-status";
function agent(input: Partial<Agent> & { id: string }): Agent {
return {
status: "idle",
background: false,
archivedAt: null,
requiresAttention: false,
pendingPermissions: [],
...input,
} as Agent;
}
function stateWith(...agents: Agent[]) {
return { sessions: { srv: { agents: new Map(agents.map((entry) => [entry.id, entry])) } } };
}
describe("deriveFaviconStatus", () => {
it("is running when any visible agent runs", () => {
expect(
deriveFaviconStatus(
stateWith(
agent({ id: "a", requiresAttention: true }),
agent({ id: "b", status: "running" }),
),
),
).toBe("running");
});
it("needs attention for attention or pending permissions", () => {
expect(deriveFaviconStatus(stateWith(agent({ id: "a", requiresAttention: true })))).toBe(
"attention",
);
expect(
deriveFaviconStatus(
stateWith(agent({ id: "a", pendingPermissions: [{}] as Agent["pendingPermissions"] })),
),
).toBe("attention");
});
it("ignores background and archived agents", () => {
expect(
deriveFaviconStatus(
stateWith(
agent({ id: "a", status: "running", background: true }),
agent({ id: "b", requiresAttention: true, archivedAt: new Date() }),
),
),
).toBe("none");
});
});
describe("createFaviconStatusSelector", () => {
it("reuses its result while the agent maps are unchanged", () => {
const select = createFaviconStatusSelector();
const state = stateWith(agent({ id: "a", status: "running" }));
expect(select(state)).toBe("running");
// Same agent map, mutated behind the store's back: the selector must not rescan.
state.sessions.srv.agents.set("a", agent({ id: "a" }));
expect(select({ sessions: { ...state.sessions } })).toBe("running");
});
it("rescans when an agent map is replaced", () => {
const select = createFaviconStatusSelector();
expect(select(stateWith(agent({ id: "a", status: "running" })))).toBe("running");
expect(select(stateWith(agent({ id: "a" })))).toBe("none");
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { Agent } from "@/stores/session-store";
import { memoizeOnSessionMaps } from "./memoize-on-session-maps";
export type FaviconStatus = "none" | "running" | "attention";
interface FaviconAgentSession {
agents: ReadonlyMap<string, Agent>;
}
interface FaviconAgentSource {
sessions: Record<string, FaviconAgentSession | undefined>;
}
/**
* The favicon is the only always-mounted consumer of the whole agent directory, so derive its
* three states here instead of building the aggregated agent list: the list allocates a
* projection per agent, deep-compares it and sorts every agent on the host on each update.
*/
export function deriveFaviconStatus(state: FaviconAgentSource): FaviconStatus {
let hasAttention = false;
let hasNeedsInput = false;
for (const session of Object.values(state.sessions)) {
if (!session) continue;
for (const agent of session.agents.values()) {
if (agent.background || agent.archivedAt) continue;
if (agent.status === "running") return "running";
if (agent.requiresAttention) hasAttention = true;
if (agent.pendingPermissions.length > 0) hasNeedsInput = true;
}
}
return hasAttention || hasNeedsInput ? "attention" : "none";
}
/** A session-store selector for the favicon status that rescans only when an agent map changes. */
export function createFaviconStatusSelector(): (state: FaviconAgentSource) => FaviconStatus {
return memoizeOnSessionMaps<FaviconAgentSession, FaviconAgentSource, FaviconStatus>(
(session) => session.agents,
deriveFaviconStatus,
);
}
@@ -0,0 +1,31 @@
interface SessionsSource<TSession> {
sessions: Record<string, TSession | undefined>;
}
/**
* Wraps a whole-store selector so it reruns only when one of the per-session maps it reads has
* changed. Session-store writes that leave those maps alone (timeline updates, drafts) then cost
* a reference check per host instead of a walk over every entry.
*/
export function memoizeOnSessionMaps<TSession, TState extends SessionsSource<TSession>, TResult>(
pickMap: (session: TSession) => unknown,
derive: (state: TState) => TResult,
): (state: TState) => TResult {
let previousMaps: unknown[] | null = null;
let previousResult: TResult;
return (state) => {
const maps = Object.values(state.sessions).map((session) =>
session ? pickMap(session) : undefined,
);
if (
previousMaps &&
previousMaps.length === maps.length &&
previousMaps.every((map, index) => map === maps[index])
) {
return previousResult;
}
previousMaps = maps;
previousResult = derive(state);
return previousResult;
};
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"license": "Apache-2.0",
"bin": {
@@ -29,9 +29,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/server": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/server": "0.16.0-beta.2",
"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.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo client SDK package",
"license": "Apache-2.0",
"files": [
@@ -49,8 +49,8 @@
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
},
"dependencies": {
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"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.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"license": "Apache-2.0",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo plugin SDK package",
"license": "Apache-2.0",
"files": [
@@ -78,8 +78,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -89,8 +89,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -47,6 +47,7 @@ import {
type ProviderSessionConfig,
type ProviderSetting,
type ProviderTimelineItem,
type ProviderUsage,
} from "../provider.js";
const ADAPTER_CAPABILITIES = [
@@ -395,6 +396,7 @@ class AcpRuntime {
private notificationLane: Promise<void> = Promise.resolve();
private promptLane: Promise<void> = Promise.resolve();
private activePrompt: { turnId: string; settled: Promise<void> } | null = null;
private lastUsage: ProviderUsage = {};
static async start(options: StartRuntimeOptions): Promise<AcpRuntime> {
let child: ChildProcessWithoutNullStreams | null = null;
@@ -623,6 +625,20 @@ class AcpRuntime {
).then(
async (response): Promise<void> => {
await this.drainNotifications();
if (response.usage) {
this.lastUsage = {
...this.lastUsage,
inputTokens: response.usage.inputTokens,
outputTokens: response.usage.outputTokens,
cachedInputTokens: response.usage.cachedReadTokens ?? undefined,
};
this.emit({
type: "session.usage",
sessionId: this.options.boundarySessionId,
turnId,
usage: this.lastUsage,
});
}
const state = response.stopReason === "cancelled" ? "canceled" : "completed";
this.fallbackChunkIds.clear();
this.terminalizeTransientItems(state);
@@ -1066,14 +1082,16 @@ class AcpRuntime {
return;
}
if (update.sessionUpdate === "usage_update") {
this.lastUsage = {
...this.lastUsage,
contextWindowUsedTokens: update.used,
contextWindowMaxTokens: update.size,
totalCostUsd: update.cost?.currency === "USD" ? update.cost.amount : undefined,
};
this.emit({
type: "session.usage",
sessionId: this.options.boundarySessionId,
usage: {
contextWindowUsedTokens: update.used,
contextWindowMaxTokens: update.size,
totalCostUsd: update.cost?.currency === "USD" ? update.cost.amount : undefined,
},
usage: this.lastUsage,
});
return;
}
@@ -1509,7 +1527,9 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem {
name: snapshot.name ?? snapshot.title,
detail,
};
if (status === "failed") return { ...base, status, error: snapshot.output };
if (status === "failed") {
return { ...base, status, error: snapshot.output ?? { message: "Tool failed" } };
}
return { ...base, status, error: null };
}
+165
View File
@@ -9,6 +9,7 @@ import {
} from "@agentclientprotocol/sdk";
import type { ProviderConnection, ProviderEvent, ProviderInput } from "./provider.js";
import { afterEach, describe, expect, it } from "vitest";
import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages";
import { runAcpProvider, type AcpStreamMessage } from "./acp.js";
const temporaryDirectories: string[] = [];
@@ -792,6 +793,170 @@ lines.on("line", (line) => {
await connection.close();
});
it.each([
{ counts: null, context: false, expected: [] },
{
counts: { totalTokens: 0, inputTokens: 0, outputTokens: 0 },
context: false,
expected: [
{
turnId: "acp:first",
usage: { inputTokens: 0, outputTokens: 0, cachedInputTokens: undefined },
},
],
},
{
counts: { totalTokens: 18971, inputTokens: 12152, outputTokens: 78, cachedReadTokens: 6741 },
context: true,
expected: [
{
turnId: undefined,
usage: { contextWindowUsedTokens: 42, contextWindowMaxTokens: 100, totalCostUsd: 0.25 },
},
{
turnId: "acp:first",
usage: {
inputTokens: 12152,
outputTokens: 78,
cachedInputTokens: 6741,
contextWindowUsedTokens: 42,
contextWindowMaxTokens: 100,
totalCostUsd: 0.25,
},
},
],
},
])(
"forwards prompt-result usage $counts while preserving context updates: $context",
async ({ counts, context, expected }) => {
const harness = connectorHarness({
handleRequest(instance, message) {
if (message.method !== "session/prompt") return false;
if (context)
instance.notify("session/update", {
sessionId: "connector-session",
update: {
sessionUpdate: "usage_update",
used: 42,
size: 100,
cost: { amount: 0.25, currency: "USD" },
},
});
instance.respond(message, {
stopReason: "end_turn",
...(counts ? { usage: counts } : {}),
});
return true;
},
});
const registration = runAcpProvider({
id: "usage-acp",
label: "Usage ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
try {
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "first",
delivery: "auto",
input: { type: "message", content: [{ type: "text", text: "hello" }] },
},
});
await waitForEvent(
events,
(event) => event.type === "session.turn" && event.state === "completed",
);
const usage = events.flatMap((event) =>
event.type === "session.usage" ? [{ turnId: event.turnId, usage: event.usage }] : [],
);
expect(usage).toEqual(expected);
const lastUsage = events.findLastIndex((event) => event.type === "session.usage");
const completed = events.findIndex(
(event) => event.type === "session.turn" && event.state === "completed",
);
expect(lastUsage).toBeLessThan(completed);
} finally {
await connection.close();
}
},
);
it.each([
{ status: "failed", output: undefined, error: { message: "Tool failed" } },
{ status: "failed", output: null, error: { message: "Tool failed" } },
{
status: "failed",
output: { message: "File not found" },
error: { message: "File not found" },
},
{ status: "failed", output: "Permission denied", error: "Permission denied" },
{ status: "completed", output: null, error: null },
] as const)(
"keeps a $status ACP tool with output $output valid on the history wire",
async ({ status, output, error }) => {
const harness = connectorHarness({
promptUpdates: () => [
{
sessionUpdate: "tool_call",
toolCallId: "read-1",
title: "Read file",
kind: "read",
status: "in_progress",
},
{ sessionUpdate: "tool_call_update", toolCallId: "read-1", status, rawOutput: output },
],
});
const registration = runAcpProvider({
id: "tool-history-acp",
label: "Tool history ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
try {
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "first",
delivery: "auto",
input: { type: "message", content: [{ type: "text", text: "Read a file" }] },
},
});
await waitForEvent(
events,
(event) => event.type === "session.turn" && event.state === "completed",
);
const rows = events.flatMap((event) =>
event.type === "timeline.item" && event.item.id === "read-1" ? [event.item] : [],
);
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(AgentTimelineItemPayloadSchema.safeParse(row).success).toBe(true);
}
expect(rows.at(-1)).toMatchObject({ type: "tool_call", status, error });
} finally {
await connection.close();
}
},
);
it("serializes configuration mutations", async () => {
let releaseMutation: (() => void) | null = null;
const configOptions = [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo shared protocol schemas and wire types",
"license": "Apache-2.0",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo relay for bridging daemon and client connections",
"license": "Apache-2.0",
"files": [
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"description": "Paseo backend server",
"license": "Apache-2.0",
"files": [
@@ -109,11 +109,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.16.0-beta.1",
"@getpaseo/highlight": "0.16.0-beta.1",
"@getpaseo/plugin": "0.16.0-beta.1",
"@getpaseo/protocol": "0.16.0-beta.1",
"@getpaseo/relay": "0.16.0-beta.1",
"@getpaseo/client": "0.16.0-beta.2",
"@getpaseo/highlight": "0.16.0-beta.2",
"@getpaseo/plugin": "0.16.0-beta.2",
"@getpaseo/protocol": "0.16.0-beta.2",
"@getpaseo/relay": "0.16.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1529,6 +1529,110 @@ test("does not replace a newer foreground turn after unavailable steer fallback
}
});
test("concurrent interrupting sends each start their own turn in order", async () => {
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
const { manager, agentId, workdir } = await startAndSteerThroughManager(session, "interrupt");
try {
const prompts = ["first interrupt", "second interrupt", "third interrupt"];
await Promise.all(
prompts.map((prompt) =>
startAgentRun(manager, agentId, prompt, logger, {
replaceRunning: true,
activeTurnBehavior: "interrupt",
runOptions: { clientMessageId: prompt },
}),
),
);
await manager.waitForAgentRunStart(agentId);
expect(session.startPrompts.slice(-3)).toEqual(prompts);
expect(manager.getAgent(agentId)?.activeForegroundTurnId).toBe(
`active-turn-${session.startCount}`,
);
} finally {
await manager.closeAgent(agentId);
rmSync(workdir, { recursive: true, force: true });
}
});
test("Stop can cancel startup while an interrupting send waits behind it", async () => {
const entered = deferred<void>();
const release = deferred<void>();
class SlowStartSession extends SteeringTestSession {
starting = false;
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "slow startup") {
this.starting = true;
entered.resolve();
await release.promise;
this.starting = false;
}
return super.startTurn(prompt);
}
override async interrupt(): Promise<void> {
if (this.starting) release.resolve();
await super.interrupt();
}
}
const session = new SlowStartSession({ provider: "codex", cwd: process.cwd() });
const { manager, agentId, workdir } = await startAndSteerThroughManager(session, "interrupt");
try {
await startAgentRun(manager, agentId, "slow startup", logger, { replaceRunning: true });
await entered.promise;
const replacement = startAgentRun(manager, agentId, "queued replacement", logger, {
replaceRunning: true,
});
void replacement.catch(() => undefined);
const stop = manager.cancelAgentRun(agentId);
await vi.waitFor(() => expect(session.interruptCount).toBe(3), { timeout: 1000 });
await stop;
await replacement;
await manager.waitForAgentRunStart(agentId);
expect(session.startPrompts.slice(-2)).toEqual(["slow startup", "queued replacement"]);
} finally {
release.resolve();
await manager.closeAgent(agentId);
rmSync(workdir, { recursive: true, force: true });
}
});
test("an interrupting send replaces a turn whose start never finishes", async () => {
class StalledStartSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "stalled start") {
this.startPrompts.push(prompt);
return new Promise(() => {});
}
return super.startTurn(prompt);
}
}
const session = new StalledStartSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stalled-start-"));
const client = new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})();
const manager = new AgentManager({
clients: { codex: client },
logger,
rescueTimeouts: { interruptSessionMs: 10 },
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
await startAgentRun(manager, agent.id, "stalled start", logger, { replaceRunning: true });
await startAgentRun(manager, agent.id, "recovery", logger, {
replaceRunning: true,
activeTurnBehavior: "interrupt",
});
expect(session.startPrompts).toEqual(["stalled start", "recovery"]);
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("steers a tracked autonomous turn without creating a replacement run", async () => {
const session = new SteeringTestSession({ provider: "claude", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-autonomous-steer-"));
@@ -844,6 +844,7 @@ export class AgentManager {
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
private readonly foregroundMutationTails = new Map<string, Promise<void>>();
private readonly replacementMutationTails = new Map<string, Promise<void>>();
private readonly runs = new AgentRunState();
private readonly subscribers = new Set<SubscriptionRecord>();
private readonly idFactory: () => string;
@@ -2112,6 +2113,7 @@ export class AgentManager {
agent.inFlightPermissionResponses.size === 0 &&
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
!this.foregroundMutationTails.has(agent.id) &&
!this.replacementMutationTails.has(agent.id) &&
!this.sessionEventTails.has(agent.id) &&
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
);
@@ -3414,31 +3416,8 @@ export class AgentManager {
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<AsyncGenerator<AgentStreamEvent>> {
const snapshot = this.requireAgent(agentId);
if (
snapshot.lifecycle !== "running" &&
!snapshot.activeForegroundTurnId &&
!this.runs.hasRun(agentId)
) {
return this.streamAgent(agentId, prompt, options);
}
const agent = this.requireSessionAgent(agentId);
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
try {
await this.cancelAgentRunBefore(agentId, "replace");
return this.streamAgent(agentId, prompt, options);
} catch (error) {
const latest = this.agents.get(agentId);
if (latest) {
latest.pendingReplacement = false;
}
throw error;
}
this.requireSessionAgent(agentId);
return this.replaceRunInForegroundLane(agentId, prompt, options);
}
async steerAgentRun(
@@ -3562,18 +3541,26 @@ export class AgentManager {
}
private async runForegroundMutation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.foregroundMutationTails.get(agentId) ?? Promise.resolve();
return this.runQueuedMutation(this.foregroundMutationTails, agentId, operation);
}
private async runQueuedMutation<T>(
tails: Map<string, Promise<void>>,
agentId: string,
operation: () => Promise<T>,
): Promise<T> {
const previous = tails.get(agentId) ?? Promise.resolve();
const run = previous.catch(() => undefined).then(operation);
const tail = run.then(
() => undefined,
() => undefined,
);
this.foregroundMutationTails.set(agentId, tail);
tails.set(agentId, tail);
try {
return await run;
} finally {
if (this.foregroundMutationTails.get(agentId) === tail) {
this.foregroundMutationTails.delete(agentId);
if (tails.get(agentId) === tail) {
tails.delete(agentId);
}
}
}
@@ -3585,20 +3572,81 @@ export class AgentManager {
options?: AgentRunOptions,
): Promise<AsyncGenerator<AgentStreamEvent>> {
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
return this.replaceRunInForegroundLane(agent.id, prompt, options, (live) =>
this.assertSteerAdmissionOwnsTurn(live, expectedTurnId),
);
}
try {
await this.cancelAgentRunBefore(agent.id, "replace");
return this.streamAgent(agent.id, prompt, options);
} catch (error) {
const latest = this.agents.get(agent.id);
if (latest) {
latest.pendingReplacement = false;
/**
* Cancels the current run and reserves its replacement in one foreground mutation, so a
* concurrent replacement or Stop cannot land between the cancel and the new run's start.
*/
private async replaceRunInForegroundLane(
agentId: string,
prompt: AgentPromptInput,
options: AgentRunOptions | undefined,
assertAdmission?: (agent: ActiveManagedAgent) => void,
): Promise<AsyncGenerator<AgentStreamEvent>> {
// Replacements queue in arrival order; each one holds the foreground lane only while
// it cancels and reserves, so Stop is never stuck behind a replacement that is waiting.
return this.runQueuedMutation(this.replacementMutationTails, agentId, () =>
this.replaceRunWhenAdmitted(agentId, prompt, options, assertAdmission),
);
}
private async replaceRunWhenAdmitted(
agentId: string,
prompt: AgentPromptInput,
options: AgentRunOptions | undefined,
assertAdmission?: (agent: ActiveManagedAgent) => void,
): Promise<AsyncGenerator<AgentStreamEvent>> {
let stalledStart: PendingForegroundRun | null = null;
for (;;) {
const admission = await this.runForegroundMutation(agentId, async () => {
// Resolve the agent inside the lane: a reload while this send waited replaces it.
const agent = this.requireSessionAgent(agentId);
// A run that is still starting has no turn to interrupt yet. Canceling it would drop
// its prompt, so wait for it outside the lane, where Stop can still reach it. A start
// that outlasts the rescue timeout is canceled like any other run.
const starting = this.runs.getPendingRun(agent.id);
if (starting && starting.start.status !== "started" && starting !== stalledStart) {
return { status: "starting" as const, run: starting };
}
assertAdmission?.(agent);
if (!this.hasInFlightRun(agent.id)) {
return {
status: "reserved" as const,
iterator: this.streamAgent(agent.id, prompt, options),
};
}
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
try {
const result = await this.cancelAgentRunNow(agent.id);
if (result.status === "refused") {
throw new AgentRunCancellationError(agent.id, "replace");
}
return {
status: "reserved" as const,
iterator: this.streamAgent(agent.id, prompt, options),
};
} catch (error) {
agent.pendingReplacement = false;
throw error;
}
});
if (admission.status === "reserved") {
return admission.iterator;
}
const started = await this.waitWithTimeout({
operation: Promise.race([admission.run.turnStartedPromise, admission.run.settledPromise]),
timeoutMs: this.rescueTimeouts.interruptSessionMs,
});
if (started === "timed_out") {
stalledStart = admission.run;
}
throw error;
}
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type {
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
ImportProviderSessionContext,
} from "./agent-sdk-types.js";
import { importSessionFromPersistence } from "./provider-session-import.js";
const SESSION_FILE = "/Users/dev/.omp/agent/sessions/project/2026-01-01_session-id.jsonl";
const SESSION_ID = "session-id";
function config(cwd: string): AgentSessionConfig {
return { provider: "omp", cwd };
}
function context(cwd: string): ImportProviderSessionContext {
return { config: config(cwd), storedConfig: config(cwd) };
}
function fakeSession(described: AgentPersistenceHandle | null): AgentSession {
return {
async *streamHistory() {},
describePersistence: () => described,
} as unknown as AgentSession;
}
describe("importSessionFromPersistence", () => {
it("persists the resumed session identity instead of the listed file handle", async () => {
const cwd = "/Users/dev/code/project";
const imported = await importSessionFromPersistence({
provider: "omp",
request: { providerHandleId: SESSION_FILE, cwd },
context: context(cwd),
resumeSession: async () =>
fakeSession({
provider: "omp",
sessionId: SESSION_ID,
nativeHandle: SESSION_FILE,
metadata: { cwd, model: "resumed-model" },
}),
});
expect(imported.persistence).toMatchObject({
provider: "omp",
sessionId: SESSION_ID,
nativeHandle: SESSION_FILE,
metadata: { cwd, provider: "omp", model: "resumed-model" },
});
});
it("keeps the requested handle when the provider describes no identity", async () => {
const cwd = "/Users/dev/code/project";
const imported = await importSessionFromPersistence({
provider: "omp",
request: { providerHandleId: SESSION_FILE, cwd },
context: context(cwd),
resumeSession: async () => fakeSession(null),
});
expect(imported.persistence).toMatchObject({
provider: "omp",
sessionId: SESSION_FILE,
nativeHandle: SESSION_FILE,
});
});
});
@@ -38,12 +38,32 @@ export async function importSessionFromPersistence(input: {
return {
session,
config: storedConfig,
persistence,
persistence: canonicalImportPersistence(persistence, session),
timeline: history.timeline,
providerSubagentEvents: history.providerSubagentEvents,
};
}
/**
* Import handles identify a session by whatever the provider lists, which is not
* always the session id: OMP lists session files. Persist the resumed session's own
* identity instead, so records, dedupe and resume all agree, while the provider's
* native handle still resolves the session.
*/
function canonicalImportPersistence(
requested: AgentPersistenceHandle,
session: ImportedProviderSession["session"],
): AgentPersistenceHandle {
const described = session.describePersistence();
if (!described?.sessionId) return requested;
return {
...described,
provider: requested.provider,
nativeHandle: described.nativeHandle ?? requested.nativeHandle,
metadata: { ...requested.metadata, ...described.metadata },
};
}
function buildImportPersistenceHandle(
provider: AgentProvider,
input: ImportProviderSessionInput,
@@ -147,7 +147,7 @@ describe("Codex thread history", () => {
appServer.assertNoErrors();
});
test("a page too big to send shows a warning row and stops loading", async () => {
test("a page too big to send retries smaller pages, then warns at the item that cannot fit", async () => {
const answers = Array.from({ length: 120 }, (_, index) =>
agentItem(`agent-${index}`, `Answer ${index}`),
);
@@ -163,11 +163,12 @@ describe("Codex thread history", () => {
);
const loaded = [
{ type: "user_message", text: "One", messageId: "user-1" },
...answers.slice(0, 99).map((answer) => ({
...answers.map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
{ type: "user_message", text: "Two", messageId: "user-2" },
{
type: "notification",
level: "warning",
@@ -176,6 +177,12 @@ describe("Codex thread history", () => {
];
try {
expect(timelineItems(await readHistory(session))).toEqual(loaded);
// Each oversized page retries the same cursor at half the size until it fits.
expect(
historyRequests(appServer)
.filter((request) => request.method === "thread/items/list")
.map((request) => (request.params as { limit: number }).limit),
).toEqual([100, 100, 50, 25, 12, 12, 6, 6, 3, 3, 1, 1]);
// The oversized reply failed only its own request; the session still answers.
expect(timelineItems(await readHistory(session))).toEqual(loaded);
} finally {
@@ -124,15 +124,28 @@ async function* readPaginatedHistory(
): AsyncGenerator<CodexHistoryRow> {
const seenCursors = new Set<string>();
let cursor: string | undefined;
let limit = PAGE_SIZE;
for (;;) {
const page = CodexItemsPageSchema.parse(
await requestHistory(
client,
"thread/items/list",
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
let response: unknown;
try {
response = await client.request("thread/items/list", {
threadId,
limit,
sortDirection: "asc",
cursor,
});
} catch (error) {
if (!(error instanceof CodexAppServerResponseTooLargeError)) throw error;
// Image-heavy items can push a full page past the line cap; retry the same cursor smaller.
if (limit > 1) {
limit = Math.max(1, Math.floor(limit / 2));
continue;
}
throw new CodexThreadHistoryTooLargeError(
"The rest of this Codex thread's history is too large to load.",
),
);
);
}
const page = CodexItemsPageSchema.parse(response);
for (const row of page.data) {
yield {
item: row.item,
@@ -77,6 +77,157 @@ function readPluginOptions(env: Record<string, string>): {
}
describe("OpenCodeBridge", () => {
test("discovers only the requested session's catalog and retains global discovery", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-discovery-"));
temporaryDirectories.push(paseoHome);
const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() });
const catalog = createCatalog();
bridge.setManifestCatalog(catalog);
await bridge.start();
const unbind = bridge.bindSession({ sessionId: "bound", env: {}, tools: catalog });
bridge.bindSession({ sessionId: "disabled", env: {} });
try {
const { baseUrl, token } = bridge.sharedV2Connection();
async function discover(query: string) {
const response = await fetch(`${baseUrl}/_internal/opencode/tools${query}`, {
headers: { Authorization: `Bearer ${token}` },
});
return { status: response.status, body: await response.json() };
}
const expected = {
status: 200,
body: {
tools: [
{
name: "echo_context",
title: "Echo context",
description: "Returns the supplied value.",
inputSchema: serializePaseoToolInputParameters(catalog.getTool("echo_context")!),
},
],
},
};
expect(await discover("")).toEqual(expected);
expect(await discover("?sessionID=bound")).toEqual(expected);
expect(await discover("?sessionID=disabled")).toEqual({ status: 200, body: { tools: [] } });
const missing = {
status: 404,
body: { error: "OpenCode session is not bound to a Paseo agent" },
};
expect(await discover("?sessionID=unknown")).toEqual(missing);
expect(await discover("?sessionID=")).toEqual(missing);
unbind();
expect(await discover("?sessionID=bound")).toEqual(missing);
} finally {
await bridge.close();
}
});
test("registers and executes session-only tools without exposing them to other sessions", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-session-tools-"));
temporaryDirectories.push(paseoHome);
const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() });
const builtin = createCatalog();
const pluginTool = {
name: "plugin_action",
description: "Run a session-specific plugin action.",
inputSchema: { value: z.string() },
};
const tools = new Map([...builtin.tools, [pluginTool.name, pluginTool] as const]);
const calls: unknown[] = [];
const author: PaseoToolCatalog = {
tools,
getTool: (name) => tools.get(name),
executeTool: async (name, input, context) => {
if (name !== pluginTool.name) return builtin.executeTool(name, input, context);
calls.push(input);
return { content: [{ type: "text", text: "Plugin action complete" }] };
},
};
bridge.setManifestCatalog(builtin);
await bridge.start();
bridge.bindSession({ sessionId: "author/one", env: {}, tools: author });
bridge.bindSession({ sessionId: "other", env: {}, tools: builtin });
bridge.bindSession({ sessionId: "disabled", env: {} });
try {
const registered = new Map<string, V2TestTool>();
const hooks: Array<(input: V2TestContext) => Promise<void>> = [];
const dispose = await setupBoundV2Plugin(
bridge,
{
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
registered.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async ({ sessionID }) => ({
parentID: sessionID === "child" ? "author/one" : undefined,
}),
hook: async (_name, hook) => {
hooks.push(hook);
return { dispose: async () => undefined };
},
},
},
["author/one", "other", "disabled"],
);
try {
expect([...registered.keys()]).toEqual([
"paseo_structured_output",
"paseo_echo_context",
`paseo_${pluginTool.name}`,
]);
const submit = registered.get(`paseo_${pluginTool.name}`)!;
expect(submit.input).toEqual(serializePaseoToolInputParameters(pluginTool));
const visibleTools = [
["author/one", ["paseo_echo_context", `paseo_${pluginTool.name}`, "native"]],
["child", ["paseo_echo_context", `paseo_${pluginTool.name}`, "native"]],
["other", ["paseo_echo_context", "native"]],
["disabled", ["native"]],
["unrelated", ["native"]],
] as const;
for (const [sessionID, expected] of visibleTools) {
const input = {
sessionID,
tools: Object.fromEntries([...registered.keys(), "native"].map((name) => [name, {}])),
};
for (const hook of hooks) await hook(input);
expect(Object.keys(input.tools)).toEqual(expected);
}
await expect(
submit.execute({ value: "revision" }, { sessionID: "author/one" }),
).resolves.toMatchObject({ content: [{ type: "text", text: "Plugin action complete" }] });
await expect(
submit.execute({ value: "child revision" }, { sessionID: "child" }),
).resolves.toMatchObject({ content: [{ type: "text", text: "Plugin action complete" }] });
await expect(submit.execute({}, { sessionID: "other" })).rejects.toThrow(
"Paseo tool not found",
);
await expect(submit.execute({}, { sessionID: "disabled" })).rejects.toThrow(
"Paseo tools are disabled",
);
await expect(submit.execute({}, { sessionID: "unrelated" })).rejects.toThrow("not bound");
expect(calls).toEqual([{ value: "revision" }, { value: "child revision" }]);
await expect(
registered
.get("paseo_echo_context")!
.execute({ value: "builtin" }, { sessionID: "other" }),
).resolves.toMatchObject({ content: [{ type: "text", text: "builtin" }] });
} finally {
await dispose();
}
} finally {
await bridge.close();
}
});
test("advertises agent-scoped schemas that execute through both bridge plugins", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-manifest-"));
temporaryDirectories.push(paseoHome);
@@ -166,7 +166,9 @@ export class OpenCodeBridge {
}
const url = new URL(request.url ?? "/", this.requireBaseUrl());
if (request.method === "GET" && url.pathname === `${INTERNAL_PREFIX}/tools`) {
sendJson(response, 200, { tools: this.serializeManifest() });
sendJson(response, 200, {
tools: this.serializeManifest(url.searchParams.get("sessionID")),
});
return;
}
@@ -228,8 +230,15 @@ export class OpenCodeBridge {
}
}
private serializeManifest(): Array<Record<string, unknown>> {
const catalog = this.manifestCatalog;
private serializeManifest(sessionId: string | null): Array<Record<string, unknown>> {
let catalog: Pick<PaseoToolCatalog, "tools"> | null | undefined = this.manifestCatalog;
if (sessionId !== null) {
const binding = this.sessions.get(sessionId);
if (!binding) {
throw new OpenCodeBridgeClientError(404, "OpenCode session is not bound to a Paseo agent");
}
catalog = binding.tools;
}
if (!catalog) return [];
return [...catalog.tools.values()].map((tool) => {
const definition: Record<string, unknown> = {
@@ -38,7 +38,10 @@ export async function registerSharedBridge(context) {
return null;
}
async function install(binding) {
const manifest = await request(binding, "/tools");
const manifest = await request(
binding,
`/tools?sessionID=${encodeURIComponent(binding.sessionID)}`,
);
for (const definition of manifest.tools) await installDefinition(definition);
}
async function installDefinition(definition) {
@@ -27,7 +27,8 @@ test("a shared plugin binds Paseo sessions without involving unrelated sessions"
const bridge = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
const tool = { name: "echo", description: "echo", inputSchema: { value: z.string() } };
const tools = new Map([[tool.name, tool]]);
bridge.setManifestCatalog({ tools });
// Session-only tools must survive plugin reload without a global definition.
bridge.setManifestCatalog({ tools: new Map() });
await bridge.start();
const unbind = bridge.bindSession({
sessionId: "paseo",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { fileURLToPath } from "node:url";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { mkdtemp, writeFile, appendFile, rm, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createPiExtensionHost } from "../index.js";
@@ -30,12 +30,36 @@ function mapToolDetail(toolCall: PiTrackedToolCall, result: PiToolResult) {
return mapping(toolCall, result)?.detail;
}
function hasChildTimeline(events: AgentStreamEvent[]) {
function hasChildTimeline(events: AgentStreamEvent[], id?: string) {
return events.some(
(event) => event.type === "provider_subagent" && event.event.type === "timeline",
(event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
(!id || event.event.id === id),
);
}
function childAssistantTexts(events: AgentStreamEvent[], id: string) {
return events.flatMap((event) => {
if (
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === id &&
event.event.item.type === "assistant_message"
)
return [event.event.item.text];
return [];
});
}
function lastChildUpsert(events: AgentStreamEvent[], id: string) {
const last = events.findLast(
(event) =>
event.type === "provider_subagent" && event.event.type === "upsert" && event.event.id === id,
);
return last?.type === "provider_subagent" ? last.event : undefined;
}
function hasCompletedChild(events: AgentStreamEvent[]) {
return events.some(
(event) =>
@@ -46,6 +70,244 @@ function hasCompletedChild(events: AgentStreamEvent[]) {
}
describe("pi-subagents adapter", () => {
test("registers a successful resume as a new async attempt without spawn arguments", () => {
const host = createPiExtensionHost();
const resumed = host.mapToolCall({
callId: "resume-b",
toolName: "subagent",
args: { action: "resume", id: "run-a", message: "Continue" },
status: "completed",
result: {
details: {
mode: "single",
runId: "run-b",
asyncId: "run-b",
asyncDir: "/tmp/paseo-resume-b",
results: [],
},
},
});
expect(resumed?.subagents).toEqual([
{
type: "upsert",
id: "resume-b",
title: "Subagent",
description: "Continue",
toolCallId: "resume-b",
status: "running",
},
]);
});
test.each([
{ action: "resume", status: "running" as const, mode: "single" },
{ action: "resume", status: "failed" as const, mode: "single" },
{ action: "resume", status: "completed" as const, mode: "management" },
{ action: "status", status: "completed" as const, mode: "single" },
{ action: "steer", status: "completed" as const, mode: "single" },
])("does not create phantom rows for $action/$status/$mode", ({ action, status, mode }) => {
expect(
createPiExtensionHost().mapToolCall({
callId: "management",
toolName: "subagent",
args: { action, id: "run-a", message: "Continue" },
status,
result: {
details: { mode, asyncId: "run-b", asyncDir: "/tmp/paseo-resume-b", results: [] },
},
}),
).toBeUndefined();
});
test.each([
{ mode: "single", results: [] },
{ mode: "single", asyncId: "run-b", results: [] },
{ mode: "single", asyncDir: "/tmp/paseo-resume-b", results: [] },
{
mode: "single",
asyncId: "run-b",
asyncDir: "/tmp/paseo-resume-b",
results: [{ agent: "scout", exitCode: 0 }],
},
])("does not register a resume without a new async launch: %j", (details) => {
expect(
createPiExtensionHost().mapToolCall({
callId: "not-launched",
toolName: "subagent",
args: { action: "resume", id: "run-a", message: "Continue" },
status: "completed",
result: { details },
}),
).toBeUndefined();
});
test("uses the resume task alias as the follow-up description", () => {
expect(
createPiExtensionHost().mapToolCall({
callId: "resume-task",
toolName: "subagent",
args: { action: "resume", id: "source-prefix", task: "Continue via task" },
status: "completed",
result: {
details: {
mode: "single",
asyncId: "run-b",
asyncDir: "/tmp/paseo-resume-b",
results: [],
},
},
})?.subagents,
).toEqual([
expect.objectContaining({
id: "resume-task",
description: "Continue via task",
status: "running",
}),
]);
});
test("follows consecutive resumed attempts and keeps stale notices on their own rows", async () => {
const root = await mkdtemp(join(tmpdir(), "paseo-pi-resume-"));
const host = createPiExtensionHost();
const replay = createPiExtensionHost();
const received: AgentStreamEvent[] = [];
try {
host.follow((event) => received.push(event));
const initialTexts: string[] = [];
for (const [run, source] of [
["b", "a"],
["c", "b"],
]) {
const dir = join(root, run);
const file = join(root, "shared-child.jsonl");
await mkdir(dir);
await appendFile(
file,
JSON.stringify({
type: "message",
message: { role: "assistant", content: [{ type: "text", text: `Attempt ${run}` }] },
}) + "\n",
);
await writeFile(
join(dir, "status.json"),
JSON.stringify({
state: "running",
revivedFrom: `run-${source}`,
steps: [{ agent: "scout", status: "running", sessionFile: file }],
}),
);
const call = {
callId: `resume-${run}`,
toolName: "subagent",
args: { action: "resume", id: `run-${source}`, message: `Continue ${run}` },
status: "completed" as const,
result: {
details: {
mode: "single",
runId: `run-${run}`,
asyncId: `run-${run}`,
asyncDir: dir,
results: [],
},
},
};
expect(host.mapToolCall(call)?.subagents).toEqual([
expect.objectContaining({ id: `resume-${run}`, status: "running" }),
]);
replay.mapToolCall(call);
initialTexts.push(`Attempt ${run}`);
await expect
.poll(() => childAssistantTexts(received, `resume-${run}`))
.toEqual(initialTexts);
}
const oldNotice = {
role: "custom" as const,
customType: "subagent-notify",
content: `Background task completed: **scout**\nRetention-managed async directory: ${join(root, "b")}\nSession file: ${join(root, "shared-child.jsonl")}`,
};
expect(host.mapCustomMessage(oldNotice)?.subagents).toEqual([
{ type: "upsert", id: "resume-b", status: "completed" },
]);
const oldReplay = replay.mapCustomMessage(oldNotice);
expect(oldReplay?.subagents).toEqual([
{ type: "upsert", id: "resume-b", status: "completed" },
]);
expect(hasChildTimeline(await oldReplay!.hydration)).toBe(true);
expect(lastChildUpsert(received, "resume-c")).toEqual({
type: "upsert",
id: "resume-c",
title: "scout",
status: "running",
});
await appendFile(
join(root, "shared-child.jsonl"),
JSON.stringify({
type: "message",
message: {
role: "assistant",
content: [{ type: "text", text: "Attempt c follow-up" }],
},
}) + "\n",
);
const expectedCTexts = ["Attempt b", "Attempt c", "Attempt c follow-up"];
await expect.poll(() => childAssistantTexts(received, "resume-c")).toEqual(expectedCTexts);
expect(lastChildUpsert(received, "resume-c")).toEqual({
type: "upsert",
id: "resume-c",
title: "scout",
status: "running",
});
await writeFile(
join(root, "c", "status.json"),
JSON.stringify({
state: "complete",
steps: [
{ agent: "scout", status: "complete", sessionFile: join(root, "shared-child.jsonl") },
],
}),
);
await expect
.poll(() => lastChildUpsert(received, "resume-c"))
.toEqual(expect.objectContaining({ status: "completed" }));
const notice = replay.mapCustomMessage({
...oldNotice,
content: `Background task completed: **scout**\nRetention-managed async directory: ${join(root, "c")}\nSession file: ${join(root, "shared-child.jsonl")}`,
});
expect(notice?.subagents).toEqual([{ type: "upsert", id: "resume-c", status: "completed" }]);
const hydration = await notice!.hydration;
expect(childAssistantTexts(hydration, "resume-c")).toEqual(expectedCTexts);
expect(hydration).toEqual(
expectedCTexts.map((text) => ({
type: "provider_subagent",
provider: "pi",
event: {
type: "timeline",
id: "resume-c",
item: expect.objectContaining({ type: "assistant_message", text }),
},
})),
);
expect(
host.mapToolCall({
callId: "wait-c",
toolName: "bg_wait",
args: {},
status: "completed",
result: {
details: {
mode: "wait",
results: [],
completions: [{ runId: "run-c", results: [{ agent: "scout", exitCode: 0 }] }],
},
},
})?.subagents,
).toEqual([expect.objectContaining({ id: "resume-c", status: "completed" })]);
} finally {
host.close();
replay.close();
await rm(root, { recursive: true, force: true });
}
});
test("follows async status and transcript without bg_wait", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-pi-async-"));
const file = join(dir, "child.jsonl");
@@ -10,6 +10,7 @@ const Args = z
task: z.string().trim().min(1).optional(),
async: z.boolean().optional(),
action: z.unknown().optional(),
message: z.string().optional(),
})
.passthrough();
const Row = z
@@ -71,6 +72,13 @@ export const piSubagents: PiExtension = {
createSession: () => {
const callsByRun = new Map<string, string>();
const readSessions = new Set<string>();
// Pi resumes append to the source transcript, but each attempt has its own timeline reader.
const rememberSession = (id: string, file: string) => {
const key = JSON.stringify([id, file]);
if (readSessions.has(key)) return false;
readSessions.add(key);
return true;
};
/** Keyed by async directory, which both status.json and the completion notice carry. */
const asyncRuns = new Map<string, { owner: string; polling: boolean }>();
const rememberAsyncRun = (details: z.infer<typeof Details>, call: PiExtensionToolCall) => {
@@ -99,8 +107,7 @@ export const piSubagents: PiExtension = {
? "failed"
: "completed",
});
if (row.sessionFile && !readSessions.has(row.sessionFile)) {
readSessions.add(row.sessionFile);
if (row.sessionFile && rememberSession(id, row.sessionFile)) {
childSessions.push({ id, file: row.sessionFile });
}
}
@@ -123,10 +130,42 @@ export const piSubagents: PiExtension = {
}
return subagents.length ? { subagents, childSessions } : undefined;
};
const mapResume = (call: PiExtensionToolCall, description?: string) => {
// A resume can reject or only reattach to existing work. Declare a new attempt only once
// Pi confirms it launched an async single run; its new directory owns all later signals.
if (call.status !== "completed") return undefined;
const details = Details.safeParse(
typeof call.result === "object" ? call.result?.details : null,
);
if (
!details.success ||
details.data.mode !== "single" ||
!details.data.asyncId ||
!details.data.asyncDir ||
details.data.results.length !== 0
)
return undefined;
callsByRun.set(details.data.runId ?? details.data.asyncId, call.callId);
rememberAsyncRun(details.data, call);
return {
subagents: [
{
type: "upsert" as const,
id: call.callId,
title: "Subagent",
description,
toolCallId: call.callId,
status: "running" as const,
},
],
};
};
const mapSpawn = (call: PiExtensionToolCall) => {
const args = Args.safeParse(call.args);
if (!args.success || args.data.action !== undefined || !args.data.agent || !args.data.task)
return undefined;
if (!args.success) return undefined;
if (args.data.action === "resume")
return mapResume(call, args.data.message ?? args.data.task);
if (args.data.action !== undefined || !args.data.agent || !args.data.task) return undefined;
const detail = {
type: "sub_agent" as const,
subAgentType: args.data.agent,
@@ -199,8 +238,7 @@ export const piSubagents: PiExtension = {
}
const file = owner ? line.match(/^Session file: (.+)$/)?.[1] : undefined;
if (!owner || !file) continue;
if (!readSessions.has(file)) {
readSessions.add(file);
if (rememberSession(owner, file)) {
childSessions.push({ id: owner, file });
}
owner = undefined;
@@ -225,8 +263,7 @@ export const piSubagents: PiExtension = {
const id = parsed.data.steps.length === 1 ? run.owner : `${run.owner}:${index}`;
const status = asyncChildStatus(parsed.data.state, step.status);
subagents.push({ type: "upsert", id, title: step.agent, status });
if (step.sessionFile && !readSessions.has(step.sessionFile)) {
readSessions.add(step.sessionFile);
if (step.sessionFile && rememberSession(id, step.sessionFile)) {
childSessions.push({ id, file: step.sessionFile });
}
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
@@ -13,6 +13,7 @@ import { WorkspaceScriptPayloadSchema } from "@getpaseo/protocol/messages";
import type { ScriptHealthState } from "./script-health-monitor.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import { readPaseoConfig } from "../utils/worktree.js";
import { readPaseoConfigForEdit, writePaseoConfigForEdit } from "../utils/paseo-config-file.js";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import { createTestLogger } from "../test-utils/test-logger.js";
@@ -471,6 +472,84 @@ describe("script-status-projection", () => {
}
});
it("keeps every workspace's scripts when a listing's paseo.json checks do not finish in time", async () => {
// More workspaces than the daemon kept snapshots for before, each with a config it has read.
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "script-projection-many-")));
const workspaces = Array.from({ length: 300 }, (_, index) => {
const directory = path.join(tempDir, `workspace-${index}`);
mkdirSync(directory);
writeFileSync(
path.join(directory, "paseo.json"),
JSON.stringify({ scripts: { api: { type: "service", command: "npm run dev" } } }),
);
return directory;
});
const logger = createTestLogger();
try {
await Promise.all(
workspaces.map((directory) => readPaseoConfigForProjection(directory, logger)),
);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const listing = Promise.all(
workspaces.map((directory) => readPaseoConfigForProjection(directory, logger)),
);
// The filesystem has not answered any check when the 5 s budget runs out.
vi.advanceTimersByTime(5_000);
const configs = await listing;
expect(configs.filter((config) => config?.scripts?.api === undefined)).toEqual([]);
} finally {
vi.useRealTimers();
rmSync(tempDir, { recursive: true, force: true });
}
});
it("keeps the saved scripts when the first check after an in-app save is late", async () => {
const workspace = createWorkspaceRepo({
paseoConfig: { scripts: { api: { type: "service", command: "npm run dev" } } },
});
const logger = createTestLogger();
try {
const current = await readPaseoConfigForEdit(workspace.repoDir);
if (!current.ok) throw new Error("expected a readable paseo.json");
const saved = await writePaseoConfigForEdit({
repoRoot: workspace.repoDir,
config: { scripts: { web: { type: "service", command: "npm run web" } } },
expectedRevision: current.revision,
});
expect(saved.ok).toBe(true);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const listing = readPaseoConfigForProjection(workspace.repoDir, logger);
vi.advanceTimersByTime(5_000);
expect(Object.keys((await listing)?.scripts ?? {})).toEqual(["web"]);
} finally {
vi.useRealTimers();
workspace.cleanup();
}
});
it("shows an external paseo.json edit on the next listing", async () => {
const workspace = createWorkspaceRepo({
paseoConfig: { scripts: { api: { type: "service", command: "npm run dev" } } },
});
try {
await readPaseoConfigForProjection(workspace.repoDir, createTestLogger());
writeFileSync(
path.join(workspace.repoDir, "paseo.json"),
JSON.stringify({ scripts: { web: { type: "service", command: "npm run web" } } }),
);
const config = await readPaseoConfigForProjection(workspace.repoDir, createTestLogger());
expect(Object.keys(config?.scripts ?? {})).toEqual(["web"]);
} finally {
workspace.cleanup();
}
});
it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", async () => {
const workspaceId = "workspace-null-config";
const workspace = createWorkspaceRepo();
@@ -5,7 +5,8 @@ import type {
WorkspaceScriptPayload,
} from "@getpaseo/protocol/messages";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import { getScriptConfigs, isServiceScript, readPaseoConfig } from "../utils/worktree.js";
import { readPaseoConfigOrLastKnown } from "../utils/paseo-config-file.js";
import { getScriptConfigs, isServiceScript } from "../utils/worktree.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js";
import type { ScriptHealthEntry, ScriptHealthState } from "./script-health-monitor.js";
import type {
@@ -37,7 +38,7 @@ export async function readPaseoConfigForProjection(
workspaceDirectory: string,
logger: Logger,
): Promise<PaseoConfig | null> {
const result = await readPaseoConfig(workspaceDirectory);
const result = await readPaseoConfigOrLastKnown(workspaceDirectory);
if (result.ok) {
return result.config;
}
+56 -9
View File
@@ -114,6 +114,12 @@ interface Snapshot {
runtime: ReadPaseoConfigResult;
}
// How long a caller waits for the filesystem, including time queued behind other roots.
const READ_BUDGET_MS = 5_000;
// Above the roots a busy daemon lists: a listing that visits more roots than this evicts the next one it needs.
const MAX_SNAPSHOTS = 1_024;
const UNAVAILABLE = Symbol("project config unavailable");
/** Owns config admission, snapshots and daemon read/modify/write ordering for every consumer. */
class ProjectConfigOwner {
// A stalled mount retains its slot until the OS returns; never consume the whole fs pool.
@@ -166,17 +172,39 @@ class ProjectConfigOwner {
this.snapshots.delete(root);
this.snapshots.set(root, snapshot);
// Worktrees come and go; retain only a bounded set of reusable snapshots.
if (this.snapshots.size > 256) this.snapshots.delete(this.snapshots.keys().next().value!);
if (this.snapshots.size > MAX_SNAPSHOTS)
this.snapshots.delete(this.snapshots.keys().next().value!);
return snapshot;
}
async read(repoRoot: string): Promise<Snapshot> {
const checked = await this.check(resolve(repoRoot));
if (checked === UNAVAILABLE) throw new Error("Project config unavailable");
return checked;
}
/** A check the filesystem does not answer in time answers the last config read for that root. */
async readOrLastKnown(repoRoot: string): Promise<Snapshot> {
const root = resolve(repoRoot);
return withTimeout(
this.serialized(root, "read", () => this.load(root)),
5_000,
"Project config unavailable",
);
const checked = await this.check(root);
if (checked !== UNAVAILABLE) return checked;
const known = this.snapshots.get(root);
if (!known) throw new Error("Project config unavailable");
return known;
}
private async check(root: string): Promise<Snapshot | typeof UNAVAILABLE> {
let budget: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
this.serialized(root, "read", () => this.load(root)),
new Promise<typeof UNAVAILABLE>((settle) => {
budget = setTimeout(settle, READ_BUDGET_MS, UNAVAILABLE);
}),
]);
} finally {
clearTimeout(budget);
}
}
private async load(root: string): Promise<Snapshot> {
@@ -242,7 +270,7 @@ class ProjectConfigOwner {
async write(input: WritePaseoConfigForEditInput): Promise<WritePaseoConfigForEditResult> {
const root = resolve(input.repoRoot);
return this.serialized(root, "write", async () => {
const { edit: parsed } = await validateConfig(input.config);
const { edit: parsed, runtime } = await validateConfig(input.config);
if (!parsed.success) return { ok: false, error: { code: "invalid_project_config" } };
const content = `${JSON.stringify(parsed.data, null, 2)}\n`;
if (Buffer.byteLength(content) > MAX_PASEO_CONFIG_BYTES) {
@@ -251,7 +279,8 @@ class ProjectConfigOwner {
const tempPath = join(root, `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`);
try {
await writeFile(tempPath, content, { flag: "wx" });
const savedRevision = revision(await stat(tempPath));
const savedStats = await stat(tempPath);
const savedRevision = revision(savedStats);
const currentStats = await inspect(root);
if (currentStats) admit(currentStats);
const currentRevision = currentStats ? revision(currentStats) : null;
@@ -261,7 +290,13 @@ class ProjectConfigOwner {
// Serialized against daemon edits. External writers still have the existing
// optimistic stat/rename race; revision tokens remain mtime + size.
await rename(tempPath, resolvePaseoConfigPath(root));
this.snapshots.delete(root);
// Remember what was saved without another read, so a late check right after the save
// still has a config to show. A rename that changes ctime only costs the next check a reload.
this.remember(root, {
fingerprint: fingerprint(savedStats),
edit: { ok: true, config: parsed.data, revision: savedRevision },
runtime: { ok: true, config: runtime },
});
return { ok: true, config: parsed.data, revision: savedRevision };
} catch {
return { ok: false, error: { code: "write_failed" } };
@@ -289,6 +324,18 @@ export async function readPaseoConfig(repoRoot: string): Promise<ReadPaseoConfig
}
}
/**
* For callers that show a config, such as workspace listings and script status: a slow filesystem
* answers the last config read instead of none. Edits and execution use readPaseoConfig.
*/
export async function readPaseoConfigOrLastKnown(repoRoot: string): Promise<ReadPaseoConfigResult> {
try {
return (await projectConfig.readOrLastKnown(repoRoot)).runtime;
} catch (error) {
return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error };
}
}
export async function readPaseoConfigForEdit(
repoRoot: string,
): Promise<ReadPaseoConfigForEditResult> {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.16.0-beta.1",
"version": "0.16.0-beta.2",
"private": true,
"type": "module",
"scripts": {