Compare commits

...
Author SHA1 Message Date
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
paseo-bot[bot]andwhatamelon aade09d056 Show Codex reconnects and warnings without false recovery on child completion (#9370)
Show Codex reconnect attempts and distinct warnings through existing timeline rows. Keep interrupted root retries running through child activity and settle them on recovery, turn end, session close, or process exit.

Closes #9236

Co-authored-by: whatamelon <39753493+whatamelon@users.noreply.github.com>
2026-11-11 10:10:32 +00:00
paseo-bot[bot] a29e06bf1b test(app): cover terminal context actions in one journey (#9371)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-11 09:34:22 +00:00
Mohamed Boudra a886bfbcdd Give plugins an agent's background work count and a background flag on hooks (#9362)
* feat(plugins): give plugins the agent's sidebar indicator and background flag

Plugin UI re-derived agent state from status and attention fields and could
not see background work, so it showed agents as done while a shell they left
running was still live. Agent snapshots now carry the sidebar's own bucket as
indicator/indicatorLabel, computed by the same function the sidebar uses so
the two cannot drift.

Lifecycle hooks and the agent.open.before request now carry `background`, so a
hook can skip a hidden helper agent without a lookup. A hook cannot change it;
an omitted value means unchanged, like metadata.

`finished` attention still means the turn finished; background work is the
indicator's concern.

* fix(plugins): keep session-open hooks that omit background typechecking

A hook that returns its own object instead of spreading the request has no
background field; the result type now treats it as optional, like metadata,
matching the runtime where an omitted value means unchanged.

* refactor(plugins): give agent snapshots the background work count instead of an indicator

* fix(plugins): keep legacy session-open hooks that omit background typechecking
2026-11-11 15:10:13 +07:00
paseo-bot[bot] 88b46c6781 test(app): cover collapsed pin shortcuts in one journey (#9366)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-11 06:33:29 +00:00
60 changed files with 1886 additions and 220 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))
@@ -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": [
@@ -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,
@@ -146,7 +146,9 @@ async function installPinRpcGate(
}
test.describe("Pin workspace shortcut", () => {
test("dispatches pin once and keeps project creation reachable", async ({ page }) => {
test("pins and unpins across sidebar collapse and keeps project creation reachable", async ({
page,
}) => {
const workspace = await seedWorkspace({
repoPrefix: "pin-project-creation-",
title: "Pinned workspace",
@@ -169,11 +171,27 @@ test.describe("Pin workspace shortcut", () => {
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
});
await test.step("keeps the pinned project available to workspace creation", async () => {
await test.step("unpins the active workspace while the Pinned section is collapsed", async () => {
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
expect(gate.sentCount()).toBe(3);
await page.getByTestId("sidebar-pinned-section-header").click();
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, {
timeout: 10_000,
});
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
expect(gate.sentCount()).toBe(4);
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
});
await test.step("keeps the pinned project available to workspace creation", async () => {
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
expect(gate.sentCount()).toBe(5);
await openNewWorkspaceComposer(page, workspace);
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
});
@@ -203,27 +221,6 @@ test.describe("Pin workspace shortcut", () => {
}
});
test("unpins the active workspace while the Pinned section is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-unpin-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
await page.getByTestId("sidebar-pinned-section-header").click();
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(0, { timeout: 10_000 });
await page.keyboard.press(PIN_SHORTCUT);
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
} finally {
await workspace.cleanup();
}
});
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
@@ -50,73 +50,64 @@ async function readClipboard(page: Page): Promise<string> {
return page.evaluate(() => navigator.clipboard.readText());
}
test.describe("Workspace terminal tab rename", () => {
test("right-click copy terminal id writes the terminal id to the clipboard", async ({
context,
page,
}) => {
test.setTimeout(60_000);
function terminalTab(page: Page, terminalId: string) {
return page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
}
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-copy-id-" });
let terminalId: string | null = null;
async function copyTerminalId(page: Page, terminalId: string): Promise<void> {
const tab = terminalTab(page, terminalId);
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const copy = page.getByRole("menuitem", { name: /^Copy terminal id/ });
await expect(copy).toBeVisible({ timeout: 10_000 });
await copy.click();
}
try {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
terminalId = await waitForCreatedTerminalId(workspace);
async function renameTerminalTab(page: Page, terminalId: string, title: string): Promise<void> {
const tab = terminalTab(page, terminalId);
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const rename = page.getByRole("menuitem", { name: "Rename", exact: true });
await expect(rename).toBeVisible({ timeout: 10_000 });
await rename.click();
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
await expect(tab).toBeVisible({ timeout: 15_000 });
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
const input = renameModalInput(page, modalPrefix);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill(title);
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
}
await tab.click({ button: "right" });
const copyTerminalId = page.getByRole("menuitem", {
name: /^Copy terminal id/,
test("terminal context menu copies its ID and persists a renamed title", async ({
context,
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
let terminalId: string | null = null;
try {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
const createdTerminalId = await waitForCreatedTerminalId(workspace);
terminalId = createdTerminalId;
await test.step("copy the terminal ID from its tab", async () => {
await copyTerminalId(page, createdTerminalId);
await expect.poll(() => readClipboard(page)).toBe(createdTerminalId);
});
await test.step("rename the same terminal and read back its saved title", async () => {
await renameTerminalTab(page, createdTerminalId, "My Renamed Terminal");
await expect(terminalTab(page, createdTerminalId)).toContainText("My Renamed Terminal", {
timeout: 15_000,
});
await expect(copyTerminalId).toBeVisible({ timeout: 10_000 });
await copyTerminalId.click();
await expect.poll(() => readClipboard(page)).toBe(terminalId);
} finally {
await cleanupTerminal(workspace, terminalId);
}
});
test("right-click rename persists the terminal title and updates the tab label", async ({
page,
}) => {
test.setTimeout(60_000);
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
let terminalId: string | null = null;
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
terminalId = await waitForCreatedTerminalId(workspace);
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true });
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
const input = renameModalInput(page, modalPrefix);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill("My Renamed Terminal");
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
await expect
.poll(() => fetchTerminalTitle(workspace, terminalId!))
.poll(() => fetchTerminalTitle(workspace, createdTerminalId))
.toBe("My Renamed Terminal");
} finally {
await cleanupTerminal(workspace, terminalId);
}
});
});
} finally {
await cleanupTerminal(workspace, terminalId);
}
});
+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": {
@@ -1,7 +1,8 @@
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import {
createPluginAgentSnapshot,
createPluginWorkspaceSnapshot,
@@ -109,6 +110,26 @@ describe("sidebar entity snapshots", () => {
).toBe(after);
});
it("carries the background work count the host publishes for the agent", () => {
// Round-trip through the wire payload, the way the app receives each agent update.
const published = (backgroundWork: Agent["backgroundWork"]) =>
createPluginAgentSnapshot(
normalizeAgentSnapshot({ ...projectAgentSnapshot(agent), backgroundWork }, agent.serverId),
workspace.id,
).backgroundWork;
expect(published(undefined)).toEqual({ count: 0 });
expect(published({ count: 2 })).toEqual({ count: 2 });
expect(published({ count: 0 })).toEqual({ count: 0 });
expect(Object.isFrozen(published({ count: 1 }))).toBe(true);
});
it("marks a background agent", () => {
expect(createPluginAgentSnapshot(agent, workspace.id).background).toBe(false);
expect(createPluginAgentSnapshot({ ...agent, background: true }, workspace.id).background).toBe(
true,
);
});
it("reads projects without requiring a workspace", () => {
const project = {
projectId: "empty",
@@ -86,6 +86,8 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
workspaceId,
provider: agent.provider,
status: agent.status,
backgroundWork: Object.freeze({ count: agent.backgroundWork?.count ?? 0 }),
background: agent.background ?? false,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastActivityAt: agent.lastActivityAt.toISOString(),
@@ -51,6 +51,8 @@ const agent: PluginAgentSnapshot = {
workspaceId: workspace.id,
provider: "codex",
status: "running",
backgroundWork: { count: 0 },
background: false,
createdAt: "2026-08-16T10:00:00.000Z",
updatedAt: "2026-08-16T10:00:00.000Z",
lastActivityAt: "2026-08-16T10:00:00.000Z",
+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"
+17
View File
@@ -370,6 +370,17 @@ describe("plugin agent tools SDK boundary", () => {
...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" },
} };
});
server.addHook("agent.open.before", async ({ request }) => {
const background: boolean = request.background;
if (background) return;
// A hook written before background and metadata returns its own object without them.
return {
agentId: request.agentId, workspaceId: request.workspaceId, provider: request.provider,
cwd: request.cwd, reason: request.reason, purpose: request.purpose, title: request.title,
labels: request.labels, tools: request.tools, env: request.env,
mcpServers: request.mcpServers,
};
});
return () => {};
}
`),
@@ -429,6 +440,12 @@ describe("lifecycle hook SDK types", () => {
// @ts-expect-error A prepare handler must return the whole request or undefined.
server.addHook("agent.create.before", async () => ({ invalid: true }));
server.before("agent.session_open", ({ request }) => request);
server.before("agent.session_open", ({ request }) => ({
agentId: request.agentId, workspaceId: request.workspaceId, provider: request.provider,
cwd: request.cwd, reason: request.reason, purpose: request.purpose, title: request.title,
labels: request.labels, metadata: request.metadata, tools: request.tools, env: request.env,
mcpServers: request.mcpServers,
}));
server.on("agent.turn_ended", () => {});
return () => {};
}
+4
View File
@@ -61,6 +61,10 @@ export interface PluginAgentSnapshot {
readonly workspaceId: string;
readonly provider: string;
readonly status: "initializing" | "idle" | "running" | "error" | "closed";
/** Shells and monitors the agent left running after its turn. 0 when none are live. */
readonly backgroundWork: { readonly count: number };
/** A helper hidden from tabs, lists and attention. Fixed at creation. */
readonly background: boolean;
readonly createdAt: string;
readonly updatedAt: string;
readonly lastActivityAt: string;
@@ -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;
}
+98
View File
@@ -792,6 +792,104 @@ 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("serializes configuration mutations", async () => {
let releaseMutation: (() => void) | null = null;
const configOptions = [
+11 -2
View File
@@ -71,6 +71,8 @@ export interface PluginHookAgent {
provider: string;
cwd: string;
title: string | null;
/** A helper hidden from tabs, lists and attention. Fixed at creation. */
readonly background: boolean;
/** Data plugins stored on the agent. Paseo never reads a key. */
metadata: Record<string, JsonValue>;
}
@@ -83,6 +85,8 @@ export interface PluginSessionOpenRequest {
reason: "create" | "resume" | "refresh" | "import";
purpose: "interactive" | "history";
readonly title: string | null;
/** A helper hidden from tabs, lists and attention. Fixed at creation. */
readonly background: boolean;
readonly labels: Readonly<Record<string, string>>;
readonly metadata: Readonly<Record<string, JsonValue>>;
tools: AgentToolDefinition[];
@@ -134,7 +138,9 @@ export interface PluginHookRequests {
/** What a hook may return. An omitted `metadata` stays as it was. */
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
// Read-only fields added after the first SDK stay optional in a result; omitted means unchanged.
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata" | "background"> & {
readonly background?: boolean;
metadata?: Readonly<Record<string, JsonValue>>;
};
};
@@ -184,7 +190,7 @@ export interface PluginLifecycleRegistration {
handler: (
input: { request: PluginBeforeRequests[Name] },
context: PluginHookContext,
) => PluginBeforeRequests[Name] | void | Promise<PluginBeforeRequests[Name] | void>,
) => PluginBeforeResults[Name] | void | Promise<PluginBeforeResults[Name] | void>,
): () => void;
}
@@ -229,6 +235,9 @@ export type PluginLifecycleEvents = {
export type PluginBeforeRequests = {
[Name in keyof typeof legacyRequests]: PluginHookRequests[(typeof legacyRequests)[Name]];
};
type PluginBeforeResults = {
[Name in keyof typeof legacyRequests]: PluginHookRequestResults[(typeof legacyRequests)[Name]];
};
/** @internal SDK adapter for the daemon's hook registry. */
export function createLifecycleRegistration(
+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",
@@ -13225,6 +13225,7 @@ test.each([
provider: "codex",
cwd: workdir,
title: null,
background: false,
metadata: {},
},
turnId: "hook-turn",
@@ -1566,6 +1566,7 @@ export class AgentManager {
purpose: "interactive",
workspaceId: options.workspaceId ?? null,
title: options.initialTitle ?? storedConfig.title ?? null,
background: options.background,
labels: options.labels ?? {},
metadata: options.metadata ?? {},
},
@@ -1700,6 +1701,7 @@ export class AgentManager {
purpose,
workspaceId,
title: record?.title ?? mergedConfig.title ?? null,
background: options?.background,
...agentEntityData(record ?? options),
},
);
@@ -1881,6 +1883,7 @@ export class AgentManager {
purpose: "interactive",
workspaceId: existing.workspaceId,
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
background: existing.background,
labels: existing.labels,
metadata: existing.metadata,
},
@@ -6101,6 +6104,7 @@ export class AgentManager {
purpose: PluginSessionOpenRequest["purpose"];
workspaceId?: string | null;
title?: string | null;
background?: boolean;
labels?: Record<string, string>;
metadata?: AgentMetadata;
},
@@ -6120,6 +6124,7 @@ export class AgentManager {
purpose: opening?.purpose ?? "interactive",
env: { ...env },
title: opening?.title ?? null,
background: opening?.background === true,
...agentEntityData(opening),
tools: [],
mcpServers: {},
@@ -171,7 +171,11 @@ async function startMockResponsesServer(sequence: string[]): Promise<{
};
}
function writeMockCodexConfig(codexHome: string, serverUrl: string): void {
function writeMockCodexConfig(
codexHome: string,
serverUrl: string,
options: { streamMaxRetries?: number } = {},
): void {
writeFileSync(
path.join(codexHome, "config.toml"),
`
@@ -186,7 +190,7 @@ name = "Mock provider for test"
base_url = "${serverUrl}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
stream_max_retries = ${options.streamMaxRetries ?? 0}
`,
);
}
@@ -546,4 +550,78 @@ describe("Codex app-server provider (local e2e)", () => {
},
30_000,
);
test.runIf(isCodexInstalled())(
"shows Codex reconnecting a dropped model stream until the reply arrives",
async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-reconnect-cwd-"));
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-reconnect-home-"));
// A stream that ends before `response.completed` makes Codex reconnect.
const droppedStream = sse([responseCreated("resp-dropped")]);
const mockServer = await startMockResponsesServer([
droppedStream,
droppedStream,
assistantMessageSse("recovered"),
]);
try {
writeMockCodexConfig(codexHome, mockServer.url, { streamMaxRetries: 2 });
const client = new CodexAppServerAgentClient(createTestLogger());
const session = await client.createSession(
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
{ env: { CODEX_HOME: codexHome } },
);
try {
const events: AgentStreamEvent[] = [];
session.subscribe((event) => {
events.push(event);
});
const turnFinished = waitForEvent({
session,
timeoutMs: 20_000,
label: "turn completion",
predicate: (
event,
): event is Extract<
AgentStreamEvent,
{ type: "turn_completed" | "turn_failed" | "turn_canceled" }
> =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
});
await session.startTurn("reply after reconnecting");
expect((await turnFinished).type).toBe("turn_completed");
const retryRows = events.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "codex_retry"
? [event.item]
: [],
);
expect(
retryRows.map(
(row) => `${row.status}: ${row.detail.type === "plain_text" ? row.detail.label : ""}`,
),
).toEqual([
"running: Reconnecting... 1/2",
"running: Reconnecting... 2/2",
"completed: Reconnected",
]);
expect(new Set(retryRows.map((row) => row.callId)).size).toBe(1);
expect(mockServer.requestBodies).toHaveLength(3);
} finally {
await session.close();
}
} finally {
await mockServer.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
}
},
30_000,
);
});
@@ -603,6 +603,58 @@ async function startCompactionTurnTest(): Promise<{
return { appServer, session, events, terminalEvent };
}
async function startStatusNoticeTurnTest(): Promise<{
appServer: FakeCodexAppServer;
session: AgentSession;
events: AgentStreamEvent[];
terminalEvent: Promise<TurnTerminalEvent>;
}> {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
const terminalEvent = new Promise<TurnTerminalEvent>((resolve) => {
session.subscribe((event) => {
const isStatusNotice =
event.type === "timeline" &&
(event.item.type === "notification" ||
(event.item.type === "tool_call" && event.item.name === "codex_retry"));
const isTerminal =
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled";
if (isStatusNotice || isTerminal) {
events.push(event);
}
if (isTerminal) {
resolve(event);
}
});
});
await session.startTurn("exercise status notices");
appServer.startsTurn({ threadId: "thread-1", turnId: "codex-turn-1" });
await new Promise<void>((resolve) => setImmediate(resolve));
return { appServer, session, events, terminalEvent };
}
function describeStatusNotice(event: AgentStreamEvent): string {
if (event.type !== "timeline") {
return event.type;
}
if (event.item.type === "notification") {
return `${event.item.level}: ${event.item.message}`;
}
if (event.item.type === "tool_call" && event.item.detail.type === "plain_text") {
return `${event.item.status}: ${event.item.detail.label}`;
}
return event.item.type;
}
function archivedThreadHandle() {
return {
sessionId: "archived-thread-id",
@@ -6223,6 +6275,229 @@ describe("Codex app-server provider", () => {
]);
});
test("shows Codex stream reconnects as one status line until the reply resumes", async () => {
const { appServer, session, events, terminalEvent } = await startStatusNoticeTurnTest();
try {
for (const attempt of ["2/5", "3/5"]) {
appServer.reportsTurnError({
threadId: "thread-1",
turnId: "codex-turn-1",
message: `Reconnecting... ${attempt}`,
additionalDetails: "stream disconnected before completion: idle timeout waiting for SSE",
willRetry: true,
});
}
// A message steered into the turn while it waits is not the stream coming back.
emitCodexUserMessage(appServer, {
id: "steer",
text: "Still there?",
turnId: "codex-turn-1",
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(events.map(describeStatusNotice)).toEqual([
"running: Reconnecting... 2/5",
"running: Reconnecting... 3/5",
]);
appServer.says({ threadId: "thread-1", itemId: "reply", text: "Back online." });
await new Promise<void>((resolve) => setImmediate(resolve));
appServer.completeTurn();
await terminalEvent;
expect(events.map(describeStatusNotice)).toEqual([
"running: Reconnecting... 2/5",
"running: Reconnecting... 3/5",
"completed: Reconnected",
"turn_completed",
]);
const retryRows = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call" ? [event.item] : [],
);
expect(new Set(retryRows.map((row) => row.callId)).size).toBe(1);
expect(retryRows[1]?.detail).toEqual({
type: "plain_text",
label: "Reconnecting... 3/5",
text: "stream disconnected before completion: idle timeout waiting for SSE",
icon: "sparkles",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("keeps one root reconnect row while a child completes", async () => {
const { appServer, session, events, terminalEvent } = await startStatusNoticeTurnTest();
try {
appServer.reportsTurnError({
threadId: "thread-1",
turnId: "codex-turn-1",
message: "Reconnecting... 1/2",
willRetry: true,
});
// Codex reports a child's completion on the root thread, independently
// of whether the root model stream has recovered.
appServer.beginsSubAgentActivity({
callId: "subagent-completed-child-turn",
threadId: "child-thread",
parentThreadId: "thread-1",
agentPath: "/root/child",
kind: "completed",
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(events.map(describeStatusNotice)).toEqual(["running: Reconnecting... 1/2"]);
appServer.reportsTurnError({
threadId: "thread-1",
turnId: "codex-turn-1",
message: "Reconnecting... 2/2",
willRetry: true,
});
appServer.says({ threadId: "thread-1", itemId: "reply", text: "Root recovered." });
appServer.completeTurn();
await terminalEvent;
expect(events.map(describeStatusNotice)).toEqual([
"running: Reconnecting... 1/2",
"running: Reconnecting... 2/2",
"completed: Reconnected",
"turn_completed",
]);
const rows = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call" ? [event.item] : [],
);
expect(new Set(rows.map((row) => row.callId)).size).toBe(1);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("fails queued reconnect notices before reporting process disconnection", async () => {
const { appServer, session, events, terminalEvent } = await startStatusNoticeTurnTest();
try {
for (const attempt of ["1/2", "2/2"]) {
appServer.reportsTurnError({
threadId: "thread-1",
turnId: "codex-turn-1",
message: `Reconnecting... ${attempt}`,
willRetry: true,
});
}
// Exit before the session's ordered notification queue has drained.
appServer.disconnect();
await terminalEvent;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(events.map(describeStatusNotice)).toEqual([
"running: Reconnecting... 1/2",
"running: Reconnecting... 2/2",
"failed: Reconnecting... 2/2",
"turn_failed",
]);
const rows = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call" ? [event.item] : [],
);
expect(new Set(rows.map((row) => row.callId)).size).toBe(1);
expect(new Set(events.map((event) => event.turnId)).size).toBe(1);
expect(events[0]?.turnId).toBeTruthy();
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("cancels the reconnect row before closing its session", async () => {
const { appServer, session, events } = await startStatusNoticeTurnTest();
try {
appServer.reportsTurnError({
threadId: "thread-1",
turnId: "codex-turn-1",
message: "Reconnecting... 1/2",
willRetry: true,
});
await new Promise<void>((resolve) => setImmediate(resolve));
await session.close();
expect(events.map(describeStatusNotice)).toEqual([
"running: Reconnecting... 1/2",
"canceled: Reconnecting... 1/2",
]);
const rows = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call" ? [event.item] : [],
);
expect(new Set(rows.map((row) => row.callId)).size).toBe(1);
// Closing again must not publish another terminal update.
await session.close();
expect(events).toHaveLength(2);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("fails the reconnect status line with the turn when Codex stops retrying", async () => {
const { appServer, session, events, terminalEvent } = await startStatusNoticeTurnTest();
try {
appServer.reportsTurnError({
threadId: "thread-1",
turnId: "codex-turn-1",
message: "Reconnecting... 5/5",
additionalDetails: "stream disconnected before completion: idle timeout waiting for SSE",
willRetry: true,
});
appServer.reportsTurnError({
threadId: "thread-1",
turnId: "codex-turn-1",
message: "stream disconnected before completion: idle timeout waiting for SSE",
willRetry: false,
});
appServer.completeTurn({
status: "failed",
error: { message: "stream disconnected before completion: idle timeout waiting for SSE" },
});
await terminalEvent;
expect(events.map(describeStatusNotice)).toEqual([
"running: Reconnecting... 5/5",
"failed: Reconnecting... 5/5",
"turn_failed",
]);
expect(events[1]).toMatchObject({
item: { error: "stream disconnected before completion: idle timeout waiting for SSE" },
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("shows each Codex warning once as a notice in the thread", async () => {
const { appServer, session, events, terminalEvent } = await startStatusNoticeTurnTest();
const missingMetadata =
"Model metadata for `mock-model` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.";
try {
appServer.warns({ threadId: "thread-1", message: missingMetadata });
appServer.warns({
threadId: null,
message: "Falling back from WebSockets to HTTPS transport.",
});
// Codex repeats the missing-metadata warning on every turn.
appServer.warns({ threadId: "thread-1", message: missingMetadata });
appServer.completeTurn();
await terminalEvent;
expect(events.map(describeStatusNotice)).toEqual([
`warning: ${missingMetadata}`,
"warning: Falling back from WebSockets to HTTPS transport.",
"turn_completed",
]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("does not replace a persisted Codex thread when app-server resume fails", async () => {
const session = createSession({ thinkingOptionId: "medium" });
session.currentThreadId = "archived-thread-id";
@@ -1200,6 +1200,40 @@ export function mapCodexPlanToToolCall(params: {
};
}
interface CodexStreamRetry {
callId: string;
message: string;
details: string | null;
}
type CodexStreamRetryOutcome =
| { status: "running" | "completed" | "canceled" }
| { status: "failed"; error: string };
// Codex reports each model-stream reconnect as an `error` notification with `willRetry: true`.
// One status line per reconnect run shows the latest attempt and settles when the turn moves on.
function mapCodexStreamRetryToStatusLine(
retry: CodexStreamRetry,
outcome: CodexStreamRetryOutcome,
): ToolCallTimelineItem {
const base = {
type: "tool_call" as const,
callId: retry.callId,
name: "codex_retry",
detail: {
type: "plain_text" as const,
label: outcome.status === "completed" ? "Reconnected" : retry.message,
...(retry.details ? { text: retry.details } : {}),
icon: "sparkles" as const,
},
metadata: { synthetic: true, source: "codex_stream_retry" },
};
if (outcome.status === "failed") {
return { ...base, status: "failed", error: outcome.error };
}
return { ...base, status: outcome.status, error: null };
}
function buildPlanPermissionActions(options?: {
includeResumeAction?: boolean;
resumeLabel?: string;
@@ -2323,6 +2357,26 @@ const TurnCompletedNotificationSchema = z
})
.passthrough();
const TurnErrorNotificationSchema = z
.object({
threadId: z.string().optional(),
willRetry: z.boolean(),
error: z
.object({
message: z.string(),
additionalDetails: z.string().nullable().optional(),
})
.passthrough(),
})
.passthrough();
const WarningNotificationSchema = z
.object({
threadId: z.string().nullable().optional(),
message: z.string(),
})
.passthrough();
const TurnPlanUpdatedNotificationSchema = z
.object({
threadId: z.string().optional(),
@@ -2605,6 +2659,14 @@ type ParsedCodexNotification =
errorMessage: string | null;
threadId: string | null;
}
| {
kind: "turn_error";
message: string;
details: string | null;
willRetry: boolean;
threadId: string | null;
}
| { kind: "warning"; message: string; threadId: string | null }
| {
kind: "plan_updated";
plan: Array<{ step: string | null; status: string | null }>;
@@ -2765,6 +2827,36 @@ const CodexNotificationSchema = z.union([
params,
}),
),
z.object({ method: z.literal("error"), params: TurnErrorNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({
kind: "turn_error",
message: params.error.message,
details: params.error.additionalDetails ?? null,
willRetry: params.willRetry,
threadId: params.threadId ?? null,
}),
),
z.object({ method: z.literal("error"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z.object({ method: z.literal("warning"), params: WarningNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({
kind: "warning",
message: params.message,
threadId: params.threadId ?? null,
}),
),
z.object({ method: z.literal("warning"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("turn/plan/updated"), params: TurnPlanUpdatedNotificationSchema })
.transform(
@@ -3558,6 +3650,9 @@ export class CodexAppServerAgentSession implements AgentSession {
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
private pendingToolCallDetails = new Map<string, ToolCallTimelineItem["detail"]>();
private pendingAssistantMessageBoundary = false;
// The root turn's model-stream reconnect run that has not yet resumed or ended.
private activeStreamRetry: CodexStreamRetry | null = null;
private readonly shownWarningMessages = new Set<string>();
private terminalCommandByProcessId = new Map<string, string>();
// The root thread's still-running command process ids, keyed by the native turn that
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
@@ -3684,6 +3779,8 @@ export class CodexAppServerAgentSession implements AgentSession {
}
async connect(): Promise<void> {
// A replacement process must not inherit the previous process's queued events.
if (this.connectionState === "disconnected") await this.pendingNotifications;
if (this.closed) {
throw this.createClosedError();
}
@@ -3731,7 +3828,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.client = client;
client.setUnexpectedTerminationHandler((error) => {
this.handleUnexpectedTermination(error);
this.pendingNotifications = this.queueUnexpectedTermination(error);
});
client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers();
@@ -3807,12 +3904,22 @@ export class CodexAppServerAgentSession implements AgentSession {
};
}
private async queueUnexpectedTermination(error: Error): Promise<void> {
this.connectionState = "disconnected";
this.background.stop();
// Finish notifications already received from this process before ending
// their turn, so queued retry notices cannot reopen a failed retry row.
await this.pendingNotifications;
if (!this.closed) this.handleUnexpectedTermination(error);
}
private handleUnexpectedTermination(error: Error): void {
this.connectionState = "disconnected";
this.background.stop();
const hasActiveRootTurn = this.activeForegroundTurnId !== null || this.currentTurnId !== null;
this.clearPendingPermissions({ preservePlanApprovals: !hasActiveRootTurn });
if (hasActiveRootTurn) {
this.settleStreamRetry({ status: "failed", error: error.message });
this.emitEvent({
type: "turn_failed",
provider: CODEX_PROVIDER,
@@ -5396,6 +5503,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.clearPendingPermissions();
this.pendingToolCallDetails.clear();
this.pendingSubAgentNotificationsByThreadId.clear();
this.settleStreamRetry({ status: "canceled" });
this.subscribers.clear();
this.startupEvents = null;
this.activeForegroundTurnId = null;
@@ -5823,6 +5931,19 @@ export class CodexAppServerAgentSession implements AgentSession {
await this.dispatchSubAgentNotification(parsed, route.callId);
return;
}
// Root model output means the stream is back. Steering messages and asynchronous
// child activity arrive independently of the root stream, so neither proves recovery.
const startedItemType =
parsed.kind === "item_started" ? normalizeCodexThreadItemType(parsed.item.type) : null;
if (
parsed.kind === "agent_message_delta" ||
parsed.kind === "reasoning_delta" ||
(parsed.kind === "item_started" &&
startedItemType !== "userMessage" &&
startedItemType !== "subAgentActivity")
) {
this.settleStreamRetry({ status: "completed" });
}
if (
parsed.kind === "turn_completed" ||
parsed.kind === "exec_command_started" ||
@@ -5895,6 +6016,12 @@ export class CodexAppServerAgentSession implements AgentSession {
case "turn_completed":
this.handleTurnCompletedNotification(parsed);
return;
case "turn_error":
this.handleTurnErrorNotification(parsed);
return;
case "warning":
this.handleWarningNotification(parsed);
return;
case "plan_updated":
this.handlePlanUpdatedNotification(parsed);
return;
@@ -6533,15 +6660,15 @@ export class CodexAppServerAgentSession implements AgentSession {
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
}
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",
provider: CODEX_PROVIDER,
error: parsed.errorMessage ?? "Codex turn failed",
});
const error = parsed.errorMessage ?? "Codex turn failed";
this.settleStreamRetry({ status: "failed", error });
this.emitEvent({ type: "turn_failed", provider: CODEX_PROVIDER, error });
} else if (parsed.status === "interrupted") {
this.settleStreamRetry({ status: "canceled" });
this.dismissInterruptedAsyncQuestions();
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else {
this.settleStreamRetry({ status: "completed" });
if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
}
@@ -6560,6 +6687,53 @@ export class CodexAppServerAgentSession implements AgentSession {
this.resetTurnTrackingState();
}
private handleTurnErrorNotification(
parsed: Extract<ParsedCodexNotification, { kind: "turn_error" }>,
): void {
// A final error is followed by `turn/completed`, which reports it as the turn's failure.
if (!parsed.willRetry) {
return;
}
this.activeStreamRetry = {
callId: this.activeStreamRetry?.callId ?? `codex-retry:${randomUUID()}`,
message: parsed.message,
details: parsed.details,
};
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexStreamRetryToStatusLine(this.activeStreamRetry, { status: "running" }),
});
}
private handleWarningNotification(
parsed: Extract<ParsedCodexNotification, { kind: "warning" }>,
): void {
// Codex repeats some warnings on every turn, such as missing metadata for a custom model.
if (this.shownWarningMessages.has(parsed.message)) {
return;
}
this.shownWarningMessages.add(parsed.message);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "notification", level: "warning", message: parsed.message },
});
}
private settleStreamRetry(outcome: CodexStreamRetryOutcome): void {
const retry = this.activeStreamRetry;
if (!retry) {
return;
}
this.activeStreamRetry = null;
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexStreamRetryToStatusLine(retry, outcome),
});
}
private resetTurnTrackingState(): void {
this.latestPlanResult = null;
this.emittedItemStartedIds.clear();
@@ -14,7 +14,7 @@ interface FakeSubAgentActivity {
callId: string;
threadId: string;
agentPath: string;
kind: "started" | "interacted" | "interrupted";
kind: "started" | "interacted" | "interrupted" | "completed";
parentThreadId?: string;
}
interface FakeLegacyCommand {
@@ -79,6 +79,14 @@ export interface FakeCodexAppServer {
status?: "completed" | "failed" | "interrupted";
error?: { message: string } | null;
}): void;
reportsTurnError(params: {
threadId: string;
turnId: string;
message: string;
additionalDetails?: string;
willRetry: boolean;
}): void;
warns(params: { threadId: string | null; message: string }): void;
startsSubAgent(params: {
callId: string;
threadId: string;
@@ -437,6 +445,21 @@ export function createFakeCodexAppServer(
})}\n`,
);
},
reportsTurnError(params) {
writeNotification("error", {
threadId: params.threadId,
turnId: params.turnId,
willRetry: params.willRetry,
error: {
message: params.message,
codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: null } },
additionalDetails: params.additionalDetails ?? null,
},
});
},
warns(params) {
writeNotification("warning", { threadId: params.threadId, message: params.message });
},
startsSubAgent(params) {
writeSubAgentActivity("item/completed", { ...params, kind: "started" });
},
@@ -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 });
}
}
@@ -323,6 +323,88 @@ export default function contribute(server) {
}
}, 60_000);
test("hooks tell a background agent from a normal one", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-background-hooks-"));
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: "background-hooks",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
export default function contribute(server) {
const log = (hook, agentId, background) =>
console.log(JSON.stringify({ hook, agentId, background }));
server.addHook("agent.open.before", async ({ request }) => {
log("agent.open.before", request.agentId, request.background);
return { ...request, background: request.background };
});
for (const hook of ["agent.create.after", "agent.turn.before", "agent.turn.after", "agent.archive.after"]) {
server.addHook(hook, async ({ agent }) => log(hook, agent.id, agent.background));
}
return () => {};
}
`,
);
await client.connect();
await client.fetchAgents({ subscribe: {} });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
const workspace = await client.createWorkspace({
source: { kind: "directory", path: directory },
});
const workspaceId = workspace.workspace!.id;
const helper = await client.createAgent({
provider: "claude",
cwd: directory,
workspaceId,
background: true,
});
const visible = await client.createAgent({ provider: "claude", cwd: directory, workspaceId });
expect(helper.background).toBe(true);
for (const agent of [helper, visible]) {
await client.sendMessage(agent.id, "hello");
await client.waitForFinish(agent.id, 10_000);
await client.archiveAgent(agent.id);
}
const hooks = [
"agent.open.before",
"agent.create.after",
"agent.turn.before",
"agent.turn.after",
"agent.archive.after",
];
const seen = (logs: { message: string }[], agentId: string) =>
logs
.filter((entry) => entry.message.startsWith('{"hook":'))
.map((entry) => JSON.parse(entry.message))
.filter((entry) => entry.agentId === agentId)
.map(({ hook, background }) => ({ hook, background }));
await expect
.poll(async () => {
const logs = await client.getPluginLogs("background-hooks");
return {
helper: new Set(seen(logs, helper.id).map((entry) => JSON.stringify(entry))),
visible: new Set(seen(logs, visible.id).map((entry) => JSON.stringify(entry))),
};
})
.toEqual({
helper: new Set(hooks.map((hook) => JSON.stringify({ hook, background: true }))),
visible: new Set(hooks.map((hook) => JSON.stringify({ hook, background: false }))),
});
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
test("metadata set at creation and through setMetadata survives a daemon restart", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-metadata-restart-"));
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-cwd-"));
@@ -160,7 +160,7 @@ test("a workspace create hook that omits metadata passes on what an earlier hook
).resolves.toMatchObject({ metadata: {} });
});
test("a session-open hook that returns its own object without metadata leaves metadata unchanged", async () => {
test("a session-open hook that returns its own object without metadata or background leaves them unchanged", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.addHook("agent.open.before", async ({ request }) => ({
agentId: request.agentId,
@@ -184,6 +184,7 @@ test("a session-open hook that returns its own object without metadata leaves me
provider: "codex",
cwd: "/project",
title: null,
background: true,
labels: {},
metadata: { "linear.issue": { id: "ENG-1" } },
tools: [],
@@ -196,11 +197,12 @@ test("a session-open hook that returns its own object without metadata leaves me
);
expect(result).toMatchObject({
env: { HOOK: "1" },
background: true,
metadata: { "linear.issue": { id: "ENG-1" } },
});
});
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
test.each([{ title: "Changed" }, { labels: { changed: "true" } }, { background: true }])(
"session-open targeting metadata is read-only: %j",
async (patch) => {
const hooks = new PluginHookHandlers(() => {});
@@ -215,6 +217,7 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
provider: "codex",
cwd: "/project",
title: "Original",
background: false,
labels: {},
metadata: {},
tools: [],
@@ -103,6 +103,8 @@ const beforeSchemas = {
reason: z.enum(["create", "resume", "refresh", "import"]),
purpose: z.enum(["interactive", "history"]),
title: z.string().nullable(),
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
background: z.boolean().optional(),
labels: z.record(z.string(), z.string()),
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
@@ -166,6 +168,7 @@ export function describeHookAgent(agent: {
provider: string;
cwd: string;
title?: string | null;
background?: boolean;
labels: Record<string, string>;
metadata: Record<string, JsonValue>;
}): PluginHookAgent {
@@ -176,6 +179,7 @@ export function describeHookAgent(agent: {
provider: agent.provider,
cwd: agent.cwd,
title: agent.title ?? null,
background: agent.background === true,
metadata: agent.metadata,
};
}
@@ -219,6 +223,24 @@ export function publishAgentStream(
}
}
type SessionOpenRequest = z.infer<(typeof beforeSchemas)["agent.open.before"]>;
// Omitted `background` and `metadata` mean unchanged, so a hook returning its own object keeps working.
function changesSessionOpenTarget(previous: SessionOpenRequest, next: SessionOpenRequest): boolean {
return (
previous.agentId !== next.agentId ||
previous.workspaceId !== next.workspaceId ||
previous.provider !== next.provider ||
previous.cwd !== next.cwd ||
previous.reason !== next.reason ||
previous.purpose !== next.purpose ||
previous.title !== next.title ||
(next.background !== undefined && previous.background !== next.background) ||
!isDeepStrictEqual(previous.labels, next.labels) ||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
);
}
export async function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
name: Name,
input: PluginWireBeforeRequests[Name],
@@ -228,20 +250,10 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
if (name === "agent.open.before") {
const previous = beforeSchemas["agent.open.before"].parse(input);
const next = beforeSchemas["agent.open.before"].parse(result);
if (
previous.agentId !== next.agentId ||
previous.workspaceId !== next.workspaceId ||
previous.provider !== next.provider ||
previous.cwd !== next.cwd ||
previous.reason !== next.reason ||
previous.purpose !== next.purpose ||
previous.title !== next.title ||
!isDeepStrictEqual(previous.labels, next.labels) ||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
) {
if (changesSessionOpenTarget(previous, next)) {
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
}
return { ...result, metadata: previous.metadata };
return { ...result, background: previous.background, metadata: previous.metadata };
}
if (name === "agent.create.before") {
const previous = beforeSchemas["agent.create.before"].parse(input);
@@ -169,6 +169,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
provider: z.string(),
cwd: z.string(),
title: z.string().nullable(),
background: z.boolean(),
metadata: z.record(z.string(), z.custom<JsonValue>()),
})
.strict(),
+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": {
@@ -6,7 +6,7 @@ Sets saved Codex options through `server.addHook("agent.create.before", async ..
- Sets `config.providerOptions.approval_policy` to `on-request`.
- Preserves other configuration and provider options.
Adds the `company` MCP server to Codex launches through
Adds the `company` MCP server to Codex launches, skipping background agents, through
`server.addHook("agent.open.before", async ...)`. The server is never saved in the agent's configuration;
existing saved MCP servers are preserved. Existing agents receive the server through the
[session reopen behavior](../../public-docs/plugins/hooks.md#attach-tools-to-agents).
@@ -20,7 +20,8 @@ export default function contribute(server: PluginServerContext) {
});
server.addHook("agent.open.before", async ({ request }) => {
if (request.provider !== "codex") {
// Background agents are short-lived helpers; keep the company tools for the agents users open.
if (request.provider !== "codex" || request.background) {
return;
}
+1 -1
View File
@@ -2,7 +2,7 @@
Small callbacks using the existing SDK:
- Send `Try again.` when the latest output contains `out of credits`.
- Send `Try again.` when the latest output contains `out of credits`, except to background agents.
- Decline a shell request matching `rm -rf`; approve exactly `git status`; leave other requests alone.
- Inject `PASEO_HOOK_CREATE_EXAMPLE=created` on creation and `PASEO_HOOK_OPEN_EXAMPLE=opened` on each session opening.
- Change requested Codex agents to Claude Haiku before creation.
@@ -18,7 +18,8 @@ export default function contribute(server: PluginServerContext) {
});
server.addHook("agent.turn.after", async (event, context) => {
if (event.outcome.kind === "canceled") {
// A background agent reports to the agent that created it, which decides whether to retry.
if (event.outcome.kind === "canceled" || event.agent.background) {
return;
}
const agent = context.paseo.agents.ref(event.agent.id);
@@ -4,13 +4,21 @@ import { useAgents, type PluginSidebarViewProps } from "@getpaseo/plugin/client"
import { AgentRow, Heading } from "./rows";
export function agentTask(agent: PluginAgentSnapshot) {
if (agent.attentionReason === "permission") return "Respond";
if (agent.requiresAttention || agent.status === "error") return "Review";
if (agent.status === "running" || agent.status === "initializing") return "Wait";
if (agent.status === "error") return "Review";
// An agent whose turn ended still works while shells or monitors it started are live.
if (
agent.status === "running" ||
agent.status === "initializing" ||
agent.backgroundWork.count > 0
)
return "Wait";
if (agent.requiresAttention) return "Review";
return "Start";
}
function Task({ title, view }: { title: string; view: PluginSidebarViewProps }) {
const agents = useAgents({
where: (item) => agentTask(item) === title,
// Background agents are helpers the user never opens; leave them out like the sidebar does.
where: (item) => !item.background && agentTask(item) === title,
orderBy: ["lastActivityAt", "desc"],
});
return (
@@ -91,7 +91,12 @@ export function AgentRow({
() => ({ color: view.theme.colors.foreground, fontSize: 12 }),
[view.theme],
);
const detail = useMemo(
() => ({ color: view.theme.colors.foregroundMuted, fontSize: 11 }),
[view.theme],
);
if (!agent) return null;
const tasks = agent.backgroundWork.count;
return (
<Pressable
accessibilityRole="button"
@@ -102,6 +107,11 @@ export function AgentRow({
<Text numberOfLines={1} style={style}>
{agent.title ?? agent.provider}
</Text>
{tasks > 0 ? (
<Text numberOfLines={1} style={detail}>
{tasks === 1 ? "1 background task" : `${tasks} background tasks`}
</Text>
) : null}
</Pressable>
);
}
+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": {
+19 -1
View File
@@ -76,6 +76,7 @@ interface PluginHookAgent {
provider: string;
cwd: string;
title: string | null;
readonly background: boolean;
metadata: Record<string, JsonValue>;
}
@@ -96,6 +97,7 @@ type PluginTurnOutcome =
| Field | Shape / meaning |
| ------------ | -------------------------------------------------------------------------------------- |
| `background` | `true` for a [background agent](#background-agents) |
| `turnId` | Provider-reported `string` or `null`; unique across resumed sessions |
| `timeline` | Deprecated, always `[]`; fetch history with the SDK. |
| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` |
@@ -303,6 +305,7 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
"provider": "codex",
"cwd": "/projects/shop",
"title": "Workspace notes",
"background": false,
"labels": { "notes/enabled": "true" },
"metadata": { "linear.issue": { "id": "ENG-123" } },
"tools": [],
@@ -317,6 +320,7 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
| ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | String or `null` |
| `title` | Read-only string or `null`; creation title on create, stored title on resume. |
| `background` | Read-only `true` for a [background agent](#background-agents). Fixed at creation. |
| `labels` | Read-only string map; creation labels on create, stored labels on resume. |
| `metadata` | Read-only JSON map; the [metadata](#metadata) stored on the agent. |
| `tools` | Editable `AgentToolDefinition[]`, local to this plugin. See [Attach tools to agents](#attach-tools-to-agents). |
@@ -350,7 +354,7 @@ the placeholder URL with your MCP endpoint:
```ts
server.addHook("agent.open.before", async ({ request }) => {
if (request.provider !== "codex") return;
if (request.provider !== "codex" || request.background) return;
return {
...request,
@@ -426,6 +430,7 @@ Select agents using any of the session-open request fields:
| Provider | `request.provider === "codex"` |
| Workspace | `request.workspaceId === "workspace-456"` |
| Individual agent | `request.agentId === chosenAgentId` |
| Background agent | `request.background` |
`request.tools` starts empty for each plugin. Each callback sees the tools returned by earlier
callbacks in that plugin; replace the array to add or remove tools. Returning `undefined` preserves
@@ -623,8 +628,21 @@ server.addHook("agent.permission.after", async ({ agent, requestId, resolution }
});
```
## Background agents
Agent hooks exclude the daemon's private disposable helpers. Background agents and the agents in background workspaces use the same hooks.
A [background agent](/docs/sdk/agents#run-a-helper-as-a-background-agent) is a helper hidden from tabs, lists and attention. Every hook's `agent.background` and the `agent.open.before` request's `background` are `true` for one, so you can skip it without a lookup:
```ts
server.addHook("agent.turn.after", async ({ agent }) => {
if (agent.background) return; // A helper reports to the agent that created it.
await notifyTeam(agent);
});
```
`background` is fixed when the agent is created and a hook cannot change it. An agent created in a background workspace without `background: true` reads `false`. Hosts older than 0.16.0 omit the field, so it reads as `undefined`.
## Ordering and returned values
```text
+19 -1
View File
@@ -74,6 +74,7 @@ interface PluginHookAgent {
provider: string;
cwd: string;
title: string | null;
readonly background: boolean;
metadata: Record<string, JsonValue>;
}
@@ -94,6 +95,7 @@ type PluginTurnOutcome =
| 字段 | 结构 / 含义 |
| ------------ | ---------------------------------------------------------------------------------------- |
| `background` | [后台 Agent](#background-agents) 为 `true` |
| `turnId` | Provider 报告的 `string` 或 `null`;在会话恢复后仍保持唯一 |
| `timeline` | 已弃用,始终为 `[]`;通过 SDK 获取历史。 |
| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` |
@@ -299,6 +301,7 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
"provider": "codex",
"cwd": "/projects/shop",
"title": "Workspace notes",
"background": false,
"labels": { "notes/enabled": "true" },
"metadata": { "linear.issue": { "id": "ENG-123" } },
"tools": [],
@@ -313,6 +316,7 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
| ------------- | -------------------------------------------------------------------------------------------------------- |
| `workspaceId` | 字符串或 `null` |
| `title` | 只读字符串或 `null`;创建时使用创建请求的标题,恢复时使用已保存的标题。 |
| `background` | 只读;[后台 Agent](#background-agents) 为 `true`。在创建时确定。 |
| `labels` | 只读的字符串映射;创建时使用创建请求的标签,恢复时使用已保存的标签。 |
| `metadata` | 只读的 JSON 映射;Agent 上已保存的[元数据](#metadata)。 |
| `tools` | 可编辑的 `AgentToolDefinition[]`,仅包含此插件的工具。参阅[为 Agent 附加工具](#attach-tools-to-agents)。 |
@@ -344,7 +348,7 @@ server.addHook("agent.open.before", async ({ request }) => {
```ts
server.addHook("agent.open.before", async ({ request }) => {
if (request.provider !== "codex") return;
if (request.provider !== "codex" || request.background) return;
return {
...request,
@@ -418,6 +422,7 @@ const agent = await paseo.agents.create({
| Provider | `request.provider === "codex"` |
| 工作区 | `request.workspaceId === "workspace-456"` |
| 单个 Agent | `request.agentId === chosenAgentId` |
| 后台 Agent | `request.background` |
每个插件的 `request.tools` 都从空数组开始。同一插件中的后续回调会看到前面回调返回的工具;
替换数组即可添加或移除工具。返回 `undefined` 会保留请求。`purpose: "history"` 时会忽略工具。
@@ -606,8 +611,21 @@ server.addHook("agent.permission.after", async ({ agent, requestId, resolution }
});
```
## 后台 Agent {#background-agents}
Agent 钩子不包括 daemon 的私有临时辅助 Agent。后台 Agent 和后台工作区中的 Agent 使用相同钩子。
[后台 Agent](/docs/sdk/agents#run-a-helper-as-a-background-agent) 是不出现在标签页、列表和提醒中的辅助 Agent。对后台 Agent,每个钩子的 `agent.background` 以及 `agent.open.before` 请求的 `background` 都为 `true`,无需额外查询即可跳过它:
```ts
server.addHook("agent.turn.after", async ({ agent }) => {
if (agent.background) return; // A helper reports to the agent that created it.
await notifyTeam(agent);
});
```
`background` 在创建 Agent 时确定,钩子无法修改。在后台工作区中创建但没有传 `background: true` 的 Agent 读取到 `false`。早于 0.16.0 的主机不发送该字段,读取结果为 `undefined`。
## 顺序与返回值 {#ordering-and-returned-values}
```text
+23
View File
@@ -1572,6 +1572,8 @@ Agent snapshot fields:
| `workspaceId` | `string` |
| `provider` | `string` |
| `status` | `"initializing" \| "idle" \| "running" \| "error" \| "closed"` |
| `backgroundWork` | `{ count: number }` |
| `background` | `boolean` |
| `createdAt` | ISO timestamp |
| `updatedAt` | ISO timestamp |
| `lastActivityAt` | ISO timestamp |
@@ -1585,6 +1587,27 @@ Agent snapshot fields:
| `parentAgentId` | `string \| null` |
| `labels` | `Record<string, string>` |
`backgroundWork.count` is the number of shells and monitors the agent left running after its turn,
and `0` when none are live. An agent can be `idle` with `attentionReason: "finished"` while that
work still runs, so combine the fields the way your plugin needs:
```tsx
const busy = useAgent(
agentId,
(agent) => agent.status === "running" || agent.backgroundWork.count > 0,
);
if (busy === null) return null; // The agent is unavailable.
```
To list or stop the work itself, use
[`paseo.agents.ref(id).backgroundWork`](/docs/sdk/reference#agent-handle).
`background` is `true` for a [background agent](/docs/sdk/agents#run-a-helper-as-a-background-agent),
a helper hidden from tabs, lists and attention. It is fixed when the agent is created. An agent in a
background workspace reads `false` unless it was created as a background agent; read the
workspace's own `background` for that. `backgroundWork` and `background` require Paseo 0.16.0 or
later.
Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC providers, and the render error boundary. A restored tab whose plugin, panel, context, workspace, or agent is unavailable stays open with an unavailable message instead of crashing the workspace.
## File panels
+16
View File
@@ -1331,6 +1331,8 @@ Agent 快照字段:
| `workspaceId` | `string` |
| `provider` | `string` |
| `status` | `"initializing" \| "idle" \| "running" \| "error" \| "closed"` |
| `backgroundWork` | `{ count: number }` |
| `background` | `boolean` |
| `createdAt` | ISO 时间戳 |
| `updatedAt` | ISO 时间戳 |
| `lastActivityAt` | ISO 时间戳 |
@@ -1344,6 +1346,20 @@ Agent 快照字段:
| `parentAgentId` | `string \| null` |
| `labels` | `Record<string, string>` |
`backgroundWork.count` 是 Agent 在回合结束后仍在运行的 shell 和监视器数量,没有时为 `0`。Agent 可能处于 `idle` 且 `attentionReason: "finished"`,同时这些工作仍在运行,因此请按插件的需要组合这些字段:
```tsx
const busy = useAgent(
agentId,
(agent) => agent.status === "running" || agent.backgroundWork.count > 0,
);
if (busy === null) return null; // Agent 不可用。
```
要列出或停止这些工作本身,使用 [`paseo.agents.ref(id).backgroundWork`](/docs/sdk/reference#agent-handle)。
[后台 Agent](/docs/sdk/agents#run-a-helper-as-a-background-agent) 的 `background` 为 `true`,它是不出现在标签页、列表和提醒中的辅助 Agent,在创建时确定。后台工作区中的 Agent 除非本身以后台 Agent 创建,否则 `background` 为 `false`;要判断工作区,读取工作区自己的 `background`。`backgroundWork` 和 `background` 需要 Paseo 0.16.0 或更高版本。
标签页的焦点、拆分、关闭、持久化、查询状态、API/RPC Provider 以及渲染错误边界都由 Paseo 负责。如果恢复的标签页所对应的插件、面板、上下文、工作区或 Agent 不可用,该标签页会保持打开并显示“不可用”消息,而不会让工作区崩溃。
## 文件面板 {#file-panels}
+2 -1
View File
@@ -110,7 +110,8 @@ Existing workspace panels can continue passing a host-local string ID to `useWor
Workspace snapshots include `project`, `pinnedAt`, `labels`, `background`, `branch`,
`lastPromptAt`, `parentAgent` and `createdAt` (null on older hosts). Use `indicator` and the
localized `indicatorLabel` to display the status Paseo shows. `status` remains the raw daemon
status. Agent snapshots include their workspace and parent refs. Project snapshots provide
status. Agent snapshots include their workspace and parent refs, `background`, and
`backgroundWork.count`. Project snapshots provide
`displayName`, `rootPath`, `kind` and `iconRevision`.
## List terminals
+1 -1
View File
@@ -86,7 +86,7 @@ const selected = useWorkspace(ref, selector, equals);
没有选择器时,自定义相等比较函数接收快照;有选择器时接收选择结果。现有工作区面板仍可向 `useWorkspace` 和 `useAgent` 传入主机本地的字符串 ID。跨主机视图应使用引用。`useHosts()` 保留现有的 `serverId`、`label` 和连接 `status` 字段。
工作区快照包含 `project`、`pinnedAt`、`labels`、`background`、`branch`、`lastPromptAt`、`parentAgent` 和 `createdAt`(旧主机上为 null)。使用 `indicator` 和本地化的 `indicatorLabel` 显示 Paseo 的状态。`status` 保持 daemon 原始状态。Agent 快照包含工作区和父 Agent 引用。项目快照提供 `displayName`、`rootPath`、`kind` 和 `iconRevision`。
工作区快照包含 `project`、`pinnedAt`、`labels`、`background`、`branch`、`lastPromptAt`、`parentAgent` 和 `createdAt`(旧主机上为 null)。使用 `indicator` 和本地化的 `indicatorLabel` 显示 Paseo 的状态。`status` 保持 daemon 原始状态。Agent 快照包含工作区和父 Agent 引用、`background` 和 `backgroundWork.count`。项目快照提供 `displayName`、`rootPath`、`kind` 和 `iconRevision`。
## 列出终端 {#list-terminals}