Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 0e1e85e323 feat(server): collect runtimes after one idle hour with optional provider vetoes
Keep callers resident while local or remote finish notification intent remains outstanding. Daemon-owned work determines baseline eligibility; provider checks add optional vetoes. Own the idle clock and sweep lifecycle in one cleanup module while the manager retains guarded closure.
2026-10-24 08:05:51 +02:00
Mohamed Boudra 74d2c46f9b docs: translate idle runtime cleanup configuration and provider contract 2026-10-24 07:13:05 +02:00
Mohamed Boudra 89e533875c feat(server): release idle agent runtimes with provider safety checks
Require provider permission before releasing resumable runtimes so foreground idleness does not discard tracked background work. Preserve the saved conversation and serialize prompt admission with runtime closure; allow hosts to disable collection.
2026-10-24 07:05:02 +02:00
Mohamed Boudra 686588c44b feat(app): restore manual workspace sorting (#7771)
Keep manual workspace order separate from automatic sorting so surviving pre-beta arrangements and subsequent drags remain available across mode changes. Created stays the default; data already discarded by the beta cannot be reconstructed.
2026-10-24 09:06:05 +07:00
paseo-bot[bot] 48623801a1 fix(app): keep tablet sidebar controls above system navigation (#7768)
The wide sidebar reserved only the top safe area, leaving its footer under Android navigation UI. Apply the existing bottom inset to the same interior and add a native geometry regression check.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-24 03:01:19 +02:00
Stephen ZhouandClaude Opus 5.5 632a7f8859 fix(app): keep live workspace rows when a late creation snapshot arrives (#7758)
A workspace.create.response carries the descriptor captured at provision
time but is sent after the initial agent starts. A pin or title change made
in between reaches the app first as a live update; the late response then
replaced that row with the older snapshot, so a new pin vanished from the
sidebar until the next directory update.

Creation and progress snapshots now only seed rows the replica has not
received; existing rows stay owned by directory updates. Archive rollback
still restores a row that was removed.

Closes #5447

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:05:57 +02:00
paseo-bot[bot] 48d2c9ec36 Keep restored Claude background notifications from appearing twice (#7737)
* fix(claude): restore each background task notification once

Queued and delivered copies of one completion had different synthetic tool identities. Prefer their shared task ID so existing timeline projection folds them into one notification.

* fix(claude): preserve separate notifications from one task

A task can emit several Monitor events, including identical text. Assign replay identity per notification body and occurrence, pairing queue and delivery records before timeline projection instead of merging every event by task ID.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-23 23:54:08 +02:00
paseo-bot[bot]andgrueneisfabian ca894d49a5 fix(voice): keep prefetched sentences available for playback (#7756)
Unread speech bodies lost their owning HTTP response while earlier sentences played. Garbage collection then cancelled those bodies and the playback manager skipped their audio. Consume each response inside the provider before returning its audio stream, matching the existing complete-clip playback.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: grueneisfabian <174708694+grueneisfabian@users.noreply.github.com>
2026-10-23 23:01:02 +02:00
Ni Xiaoandnistream d6fa231894 fix(plugin): restore ACP history replayed during session load (#7717)
ACP agents replay a loaded session's session/update notifications before
session/load resolves, but AcpRuntime.open registered nativeSessionId only
after the response, so the session filter dropped every replayed update and
restored sessions opened with an empty timeline.

Register the native id before the request, drain the notification lane once
the response arrives so the replay completes before session.opened, and
follow the open request's history contract: replayed updates rebuild the
timeline under history "replay" and are dropped under history "skip".
Replayed user_message_chunk updates now restore user messages through the
chunk fallback scheme, so multi-chunk messages keep their boundaries; live
user chunks stay dropped because admitPrompt already echoes the prompt.

Co-authored-by: nistream <nistream@users.noreply.github.com>
2026-10-23 18:05:54 +02:00
paseo-bot[bot] fed19f3d29 fix(app): identify Android DNS connection failures (#7701)
Recognize explicit name-resolution failures before TLS keywords in hostnames, while preserving certificate-not-found handling and raw details.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-23 14:57:19 +02:00
paseo-ai[bot] 74dc764f0d fix: update lockfile signatures and Nix hash [skip ci] 2026-10-23 12:38:01 +00:00
Mohamed Boudra 30d8cb0447 chore(release): cut 0.13.0-beta.2 2026-10-23 14:28:15 +02:00
Mohamed Boudra d91d61c6eb docs: update changelog for 0.13.0-beta.2 2026-10-23 14:23:00 +02:00
109 changed files with 3118 additions and 504 deletions
+24 -12
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.13.0-beta.1 - 2026-10-22
## 0.13.0-beta.2 - 2026-10-23
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
@@ -14,18 +14,25 @@ paseo host add 'relay://...' --label laptop
### Added
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Added support for foldable phones such as iPhone Duo, which switch to the wide layout when unfolded ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
- Added connected hosts, so agents can create, prompt and monitor agents on other machines and see them in the Subagents track ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- Added paseo.sh in Simplified Chinese at [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added All changes to the Changes pane as the default, showing committed and uncommitted work since the branch base in one diff ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- Added Implement with Auto mode to Claude plan approvals when Auto was on before Plan ([#7453](https://github.com/getpaseo/paseo/pull/7453))
- Added pasting images and dropping files into terminals, so agent TUIs such as Claude Code and Codex can read them, including on remote hosts ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
### Improved
- Changed sidebar workspaces to sort by Created, with Last prompt under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690))
- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
- Changed the Host field on New workspace to always show, with Add host at the end of its menu ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- Replaced the file tab's Preview/Source toggle with a switcher between the file's panels ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Changed Explorer to use the same tabs and launcher on phones, tablets and desktop ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693))
- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639))
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
- Stopped sending workspace setup output to apps that are not showing that workspace's Setup panel ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
@@ -33,28 +40,33 @@ paseo host add 'relay://...' --label laptop
- Reduced app startup work by loading only the selected language's translations ([#7410](https://github.com/getpaseo/paseo/pull/7410) by [@colonelpanic8](https://github.com/colonelpanic8))
- Removed repeated workspace layout saves when nothing changed ([#7358](https://github.com/getpaseo/paseo/pull/7358) by [@colonelpanic8](https://github.com/colonelpanic8))
### Deprecated
- Deprecated plugin panel `context` and flat `agentId` props, and `addWorkspacePanel`'s `context` field (including the agent registration alias). Removal after 2027-04-23; use `params.agentId` and `addAgentPanel`.
### Changed
- Changed beta file panel props to `workspaceId`, read-only `params`, `revision`, `file`, and `tab` with persisted per-tab state. Removed beta-only `location`, `PluginFileLocation`, and `PluginFileTab`.
### Plugins
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- Added [file panels](https://paseo.sh/docs/plugins/reference#file-panels), so a plugin can open matching files in the file tab, such as a PDF viewer or a rich Markdown editor ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
- Added `useTabState` for per-tab panel state that survives reloads, and `addAgentPanel`; workspace, agent and file panels share the `workspaceId`, `params`, `revision` and `tab` props ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- Added [plugin translations](https://paseo.sh/docs/plugins/reference#runtime-translations), so plugin content and labels follow the app's language ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### Fixed
- Fixed agents showing as running after a daemon restart ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- Fixed parent agents never hearing from `notifyOnFinish` children after a daemon restart ([#7683](https://github.com/getpaseo/paseo/pull/7683))
- Fixed opening a large Claude session showing an empty chat or crashing the daemon ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
- Fixed Import session crashing the daemon with large Claude histories ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
- Fixed hosts staying in Error when the direct connection drops instead of switching to a saved relay connection ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed tapping a notification opening a stale workspace on iOS, and Android reopening the notification's agent on every app reopen ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
- Fixed sidebar PR status and checks disappearing after the app reconnects ([#7638](https://github.com/getpaseo/paseo/pull/7638))
- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608))
- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688))
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
- Fixed relative links in Markdown preview resolving from the workspace root instead of the document's folder ([#7538](https://github.com/getpaseo/paseo/pull/7538))
+24 -4
View File
@@ -1,6 +1,6 @@
# 更新日志
## 0.13.0-beta.1 - 2026-10-22
## 0.13.0-beta.2 - 2026-10-23
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
@@ -14,18 +14,25 @@ paseo host add 'relay://...' --label laptop
### 新增
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 新增对 iPhone Duo 等折叠屏手机的支持,展开后切换到宽屏布局 ([#7424](https://github.com/getpaseo/paseo/pull/7424), [#7705](https://github.com/getpaseo/paseo/pull/7705))
- 新增已连接主机,Agent 可以在其他机器上创建、提示和监控 Agent,并在 Subagents 中查看它们 ([#7370](https://github.com/getpaseo/paseo/pull/7370), [#7593](https://github.com/getpaseo/paseo/pull/7593))
- 新增简体中文版 paseo.sh,地址为 [paseo.sh/zh-CN](https://paseo.sh/zh-CN) ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Changes 窗格新增 All changes 并设为默认,在一个 diff 中显示自分支基点以来已提交和未提交的工作 ([#7576](https://github.com/getpaseo/paseo/pull/7576))
- 如果进入 Plan 前开启了 Auto,Claude 计划审批新增 Implement with Auto mode ([#7453](https://github.com/getpaseo/paseo/pull/7453))
- 新增在终端中粘贴图片和拖放文件,Claude Code、Codex 等 Agent TUI 可以读取它们,远程主机同样适用 ([#3795](https://github.com/getpaseo/paseo/pull/3795) by [@gerardbalaoro](https://github.com/gerardbalaoro))
### 改进
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”;工作区不再支持拖动排序 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
- 新建工作区中的 Host 字段现在始终显示,其菜单末尾为 Add host ([#7580](https://github.com/getpaseo/paseo/pull/7580))
- 文件标签页的 Preview/Source 切换改为在该文件的各个面板之间切换 ([#7411](https://github.com/getpaseo/paseo/pull/7411))
- Explorer 在手机、平板和桌面上使用相同的标签页和启动器 ([#7424](https://github.com/getpaseo/paseo/pull/7424))
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693))
- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639))
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
- 不再向未显示该工作区 Setup 面板的应用发送工作区的 Setup 输出 ([#7407](https://github.com/getpaseo/paseo/pull/7407), [#7473](https://github.com/getpaseo/paseo/pull/7473))
@@ -35,18 +42,31 @@ paseo host add 'relay://...' --label laptop
### 插件
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366))
- 新增[文件面板](https://paseo.sh/zh-CN/docs/plugins/reference#file-panels),插件可以在文件标签页中打开匹配的文件,例如 PDF 查看器或富文本 Markdown 编辑器 ([#7411](https://github.com/getpaseo/paseo/pull/7411), [#7366](https://github.com/getpaseo/paseo/pull/7366), [#7614](https://github.com/getpaseo/paseo/pull/7614))
- 新增 `useTabState`,用于保存每个标签页的面板状态并在重新加载后恢复,并新增 `addAgentPanel`;工作区、Agent 和文件面板共用 `workspaceId`、`params`、`revision` 和 `tab` 属性 ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- 新增[插件翻译](https://paseo.sh/zh-CN/docs/plugins/reference#runtime-translations),插件内容和标签会跟随应用的语言 ([#7478](https://github.com/getpaseo/paseo/pull/7478))
- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### 修复
- 修复了 daemon 重启后 Agent 仍显示为运行中的问题 ([#7573](https://github.com/getpaseo/paseo/pull/7573))
- 修复了 daemon 重启后父 Agent 收不到 `notifyOnFinish` 子 Agent 通知的问题 ([#7683](https://github.com/getpaseo/paseo/pull/7683))
- 修复了打开大型 Claude 会话时聊天为空或 daemon 崩溃的问题 ([#7431](https://github.com/getpaseo/paseo/pull/7431) by [@ZouYusheng](https://github.com/ZouYusheng))
- 修复了 Claude 历史记录较大时导入会话导致 daemon 崩溃的问题 ([#7626](https://github.com/getpaseo/paseo/pull/7626) by [@lanej](https://github.com/lanej))
- 修复了直连断开后主机停留在错误状态、不切换到已保存的 relay 连接的问题 ([#7616](https://github.com/getpaseo/paseo/pull/7616) by [@3ae3ae](https://github.com/3ae3ae))
- 修复了 iOS 上点击通知打开过期工作区,以及 Android 每次重新打开应用都会再次打开通知对应 Agent 的问题 ([#7612](https://github.com/getpaseo/paseo/pull/7612) by [@kassamkhoja](https://github.com/kassamkhoja))
- 修复了应用重新连接后侧边栏中的 PR 状态和检查消失的问题 ([#7638](https://github.com/getpaseo/paseo/pull/7638))
- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681))
- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608))
- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688))
- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
- 修复了 Markdown 预览中的相对链接从工作区根目录而不是文档所在文件夹解析的问题 ([#7538](https://github.com/getpaseo/paseo/pull/7538))
+44 -2
View File
@@ -25,8 +25,50 @@ Reload releases the old runtime before resuming its durable session: an idle pro
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
Idle resumable runtimes are collected after one hour without activity, checked once a minute.
Collection releases the process and preserves the agent record, conversation, relationships, and
provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in
host config to disable automatic collection; this setting takes effect at daemon startup.
Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working
managed or native descendants prevent collection. Outstanding local or remote finish subscriptions
also protect their caller, including registration, recovery and notification dispatch acknowledgment.
An idle managed child can still own background work.
The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false,
errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider
check, the baseline decides collection after the same one-hour idle period. Once protected work
finishes, a fresh idle hour starts. There is no deadline that overrides a veto.
The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime.
AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown.
Finish subscriptions expose caller protection through their public capability; the collector never
reads their watches or durable records.
The collector rechecks daemon state after asynchronous probes and event draining, in the existing
close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving
after closure starts waits for release and resumes the saved session.
Provider checks follow the signals their runtimes expose:
- Claude uses its process-local background roster and pending completion notifications. A fresh
process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must
demonstrate support by emitting one. Unknown versions stay resident until then.
- Codex queries background terminals for loaded threads, including native children, and protects
pending provider requests. App servers without the terminal-list RPC stay resident.
- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and
inbox inventories. The shared server remains leased while a session vetoes collection.
- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native
subagent signals also protect their parent through the daemon's subagent store.
- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`.
These protect jobs with unsuppressed completion delivery, pending deliveries, queued result
injection, and admitted or scheduled continuations that can re-wake the session. Older OMP
runtimes without either field stay resident. These signals are not an inventory of arbitrary
processes.
- ACP has no provider safety check, so its adapters use baseline-only collection.
These checks do not discover arbitrary detached processes or work hidden by third-party
extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
+5
View File
@@ -498,6 +498,11 @@ Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
Providers add an optional veto to daemon baseline cleanup through the per-session
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
the plugin bridge transports that query through the existing provider request/event protocol.
Agent refresh closes the current provider session and opens it again with current configuration and
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
+1 -1
View File
@@ -1 +1 @@
sha256-iy1sFWI+odVTFG4jvNcJfv3xGAcQrbNkNQ+gtmInNCU=
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -35695,7 +35695,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37961,12 +37961,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/server": "0.13.0-beta.1",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/server": "0.13.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38213,10 +38213,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/relay": "0.13.0-beta.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -38228,7 +38228,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38472,7 +38472,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -42124,7 +42124,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -42153,15 +42153,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -42171,8 +42171,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -42194,7 +42194,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42221,7 +42221,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -42442,16 +42442,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/highlight": "0.13.0-beta.1",
"@getpaseo/plugin": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/highlight": "0.13.0-beta.2",
"@getpaseo/plugin": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/relay": "0.13.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -43372,7 +43372,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -44982,7 +44982,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,81 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
function userMessages(page: Page) {
return page.getByTestId("user-message");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await submitMessage(page, text);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
async function pressInComposer(page: Page, key: string) {
await composerLocator(page).press(key);
}
test.describe("Composer recall", () => {
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-",
title: "Composer recall",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "fix the auth bug", 1);
await sendAndWaitForMessage(page, "add tests", 2);
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "fix the auth bug");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "add tests");
await pressInComposer(page, "ArrowDown");
await expectComposerDraft(page, "");
await pressInComposer(page, "ArrowUp");
await typeIntoFocusedComposer(page, " for login");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "Enter");
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
await session.cleanup();
}
});
test("a recalled slash message does not open the command picker", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-recall-slash-",
title: "Composer recall slash",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await sendAndWaitForMessage(page, "/tdd", 1);
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "/tdd");
await expect(popover).toHaveCount(0);
await typeIntoFocusedComposer(page, "x");
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
} finally {
await session.cleanup();
}
});
});
@@ -0,0 +1,100 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
delayBrowserAgentCreatedStatus,
openNewWorkspaceComposer,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// The creation response carries the descriptor captured when the workspace was provisioned. With an
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
// late response must not put the old, unpinned row back.
function pinnedRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
let createdId: string | undefined;
await expect
.poll(
async () => {
const { entries } = await seeded.client.fetchWorkspaces({
filter: { projectId: seeded.projectId },
});
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
return createdId;
},
{ timeout: 30_000 },
)
.toBeTruthy();
return createdId!;
}
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
await page.evaluate((rowTestId) => {
const state = { missingFrames: 0, stopped: false };
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
const sample = () => {
if (state.stopped) return;
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, testId);
return () =>
page.evaluate(() => {
const state = (
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
).__pinnedRowWatch;
state.stopped = true;
return state.missingFrames;
});
}
test.describe("New workspace live pin", () => {
test("keeps a pin made before the creation response arrives", async ({ page }) => {
test.setTimeout(120_000);
const creation = await delayBrowserAgentCreatedStatus(page);
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, seeded);
await submitNewWorkspacePrompt(page, "Pin me while I start");
await creation.waitForCreateRequest();
// The response is held here, carrying the descriptor from before the pin.
await creation.waitForDelayedCreatedStatus();
const workspaceId = await findCreatedWorkspaceId(seeded);
const rowTestId = pinnedRowTestId(workspaceId);
await seeded.client.setWorkspacePinned(workspaceId, true);
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
const stopWatching = await watchPinnedRow(page, rowTestId);
creation.release();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
// Leave time for the released response to be merged and rendered.
await page.waitForTimeout(1_000);
expect(await stopWatching()).toBe(0);
await expect(pinnedRow).toBeVisible();
} finally {
creation.release();
await seeded.cleanup();
}
});
});
@@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
}
});
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: /^Sort/ }).click();
await page.getByRole("menuitem", { name, exact: true }).click();
@@ -362,3 +362,60 @@ test("compact groups retain active overflow and reveal the current ordered rows"
await fixture.seed.cleanup();
}
});
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
await page.evaluate(
({ projectKey: scope, workspaceKeys: order }) => {
localStorage.setItem(
"sidebar-project-workspace-order",
JSON.stringify({
version: 1,
state: {
projectOrder: [scope],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { [scope]: order },
},
}),
);
},
{ projectKey, workspaceKeys },
);
await reconnectSidebar(page);
}
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
page,
}) => {
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
try {
const second = await seed.client.createWorkspace({
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
title: "New manual workspace",
});
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await selectWorkspaceSort(page, "Created");
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await selectWorkspaceSort(page, "Last prompt");
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await reconnectSidebar(page);
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
await selectWorkspaceSort(page, "Created");
await reconnectSidebar(page);
await selectWorkspaceSort(page, "Manual");
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
} finally {
await seed.cleanup();
}
});
@@ -72,6 +72,9 @@ async function expectSortingRequiresHostUpdate(page: Page) {
).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
await page.getByLabel("Display preferences", { exact: true }).click();
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
await page.keyboard.press("Escape");
}
@@ -0,0 +1,40 @@
// Open a connected host with the wide sidebar visible, keyboard closed, and
// three-button navigation enabled on an Android tablet. Run against a fresh build:
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , session, serial] = process.argv;
assert(session && serial, "Pass the agent-device session and adb serial");
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
assert(navBar, "A visible bottom navigation bar is required");
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
const navigationTop = Number(navBar[1]);
const { data } = JSON.parse(
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
encoding: "utf8",
timeout: 60_000,
}),
);
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
assert.equal(
focusedApp,
data.appBundleId,
"Snapshot must belong to the focused app on this device",
);
for (const id of [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
]) {
const button = data.nodes.find((node) => node.identifier === id);
assert(button?.rect, `${id} must be visible above the system navigation bar`);
const bottom = button.rect.y + button.rect.height;
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -28,6 +28,23 @@ function failure(message: string) {
}
describe("direct connection failure copy", () => {
it.each([
"paseo-qa-nonexistent.invalid",
"ssl-paseo-qa.invalid",
"tls-paseo-qa.invalid",
"certificate-paseo-qa.invalid",
])(
"identifies Android DNS failures for %s without treating the hostname as TLS evidence",
(host) => {
const message = `Unable to resolve host "${host}": No address associated with hostname`;
expect(failure(message)).toEqual({
title: "Failed: tcp://example.test:443?ssl=true",
detail: labels.hostNotFound,
raw: `transport error — ${message}`,
});
},
);
it("identifies Android certificate validation errors without calling them DNS failures", () => {
const message =
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
@@ -43,6 +60,7 @@ describe("direct connection failure copy", () => {
["SSL handshake failed", labels.tlsError],
["TLS handshake failed", labels.tlsError],
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
["getaddrinfo ENOTFOUND ssl.example.test", labels.hostNotFound],
["Host not found", labels.hostNotFound],
["connect ECONNREFUSED", labels.refused],
["connection timed out", labels.timedOut],
@@ -83,6 +83,8 @@ export function buildConnectionFailureCopy(input: {
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (rawLower.includes("unable to resolve host") || rawLower.includes("enotfound")) {
detail = labels.hostNotFound;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("certpathvalidator") ||
@@ -91,7 +93,7 @@ export function buildConnectionFailureCopy(input: {
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
} else if (rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
+5 -2
View File
@@ -719,8 +719,11 @@ function DesktopSidebar({
[active, resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
() => [
styles.desktopSidebarBorder,
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
],
[insetsTop, insets.bottom],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
@@ -1550,6 +1550,11 @@ function ProjectBlock({
shortcutIndexByWorkspaceKey,
onToggleCollapsed,
onWorkspacePress,
parentGestureRef,
onWorkspaceReorder,
useNestable,
dragGestureHostActive,
manualSort,
onWorktreeCreated,
drag,
isDragging,
@@ -1571,6 +1576,11 @@ function ProjectBlock({
shortcutIndexByWorkspaceKey: Map<string, number>;
onToggleCollapsed: (projectViewKey: string) => void;
onWorkspacePress?: () => void;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
useNestable: boolean;
dragGestureHostActive?: boolean;
manualSort: boolean;
onWorktreeCreated?: (workspaceId: string) => void;
drag: () => void;
isDragging: boolean;
@@ -1624,7 +1634,14 @@ function ProjectBlock({
});
const renderWorkspaceRow = useCallback(
(item: SidebarWorkspacePlacement) => {
(
item: SidebarWorkspacePlacement,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
) => {
return (
<MemoWorkspaceRowItem
key={item.workspaceKey}
@@ -1640,6 +1657,9 @@ function ProjectBlock({
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
);
},
@@ -1658,6 +1678,29 @@ function ProjectBlock({
],
);
const renderWorkspace = useCallback(
({
item,
drag: workspaceDrag,
isActive,
dragHandleProps: workspaceDragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return renderWorkspaceRow(item, {
drag: workspaceDrag,
isDragging: isActive,
dragHandleProps: workspaceDragHandleProps,
});
},
[renderWorkspaceRow],
);
const handleWorkspaceDragEnd = useCallback(
(workspaces: SidebarWorkspacePlacement[]) => {
onWorkspaceReorder(project.viewKey, workspaces);
},
[onWorkspaceReorder, project.viewKey],
);
const toast = useToast();
const { t } = useTranslation();
const [isRemovingProject, setIsRemovingProject] = useState(false);
@@ -1723,12 +1766,29 @@ function ProjectBlock({
if (project.workspaces.length > 0) {
projectChildren = (
<>
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer}
>
{visibleWorkspaces.map(renderWorkspaceRow)}
</View>
{manualSort ? (
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
) : (
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer}
>
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
</View>
)}
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
@@ -1803,6 +1863,11 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.manualSort === next.manualSort &&
previous.onWorktreeCreated === next.onWorktreeCreated &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
@@ -2112,6 +2177,9 @@ function ProjectModeList({
(state) => state.togglePinnedCollapsed,
);
const manualSort = useSidebarViewStore((state) => state.workspaceSort === "manual");
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
@@ -2211,6 +2279,30 @@ function ProjectModeList({
[getProjectOrder, setProjectOrder],
);
const handleWorkspaceReorder = useCallback(
(projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey);
if (
!hasVisibleOrderChanged({
currentOrder: currentWorkspaceOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys,
})
) {
return;
}
setWorkspaceOrder(
projectViewKey,
mergeWithRemainder({
currentOrder: currentWorkspaceOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys,
}),
);
},
[getWorkspaceOrder, setWorkspaceOrder],
);
const handleWorktreeCreated = useCallback((workspaceId: string) => {
setCreatingWorkspaceIds((current) => {
const next = new Set(current);
@@ -2259,6 +2351,11 @@ function ProjectModeList({
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onToggleCollapsed={onToggleProjectCollapsed}
onWorkspacePress={onWorkspacePress}
manualSort={manualSort}
parentGestureRef={parentGestureRef}
onWorkspaceReorder={handleWorkspaceReorder}
useNestable={platformIsNative}
dragGestureHostActive={dragGestureHostActive}
onWorktreeCreated={handleWorktreeCreated}
drag={dragState.drag}
isDragging={dragState.isDragging}
@@ -2276,6 +2373,10 @@ function ProjectModeList({
collapsedProjectKeys,
activeWorkspaceSelection,
handleWorktreeCreated,
manualSort,
parentGestureRef,
dragGestureHostActive,
handleWorkspaceReorder,
hostBadgeByServerId,
supportsMultiplicityByServerId,
supportsPinningByServerId,
@@ -244,10 +244,8 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
definitions.push({
id: "sort",
title: t("sidebar.display.sort.label"),
content: workspaceSortSupported ? (
<SortPage preferences={preferences} />
) : (
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
content: (
<SortPage preferences={preferences} automaticSortSupported={workspaceSortSupported} />
),
});
}
@@ -325,7 +323,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
<MenuSubTrigger
id="sort"
value={
workspaceSortSupported
workspaceSortSupported || preferences.workspaceSort === "manual"
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
: undefined
}
@@ -399,19 +397,38 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
);
}
function SortPage({ preferences }: { preferences: Preferences }): ReactElement {
function SortPage({
preferences,
automaticSortSupported,
}: {
preferences: Preferences;
automaticSortSupported: boolean;
}): ReactElement {
const { t } = useTranslation();
const { setWorkspaceSort } = preferences;
const selectManual = useCallback(() => setWorkspaceSort("manual"), [setWorkspaceSort]);
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
return (
<>
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
{t("sidebar.display.sort.created")}
</MenuItem>
<MenuItem selected={preferences.workspaceSort === "lastPrompt"} onSelect={selectLastPrompt}>
{t("sidebar.display.sort.lastPrompt")}
<MenuItem selected={preferences.workspaceSort === "manual"} onSelect={selectManual}>
{t("sidebar.display.sort.manual")}
</MenuItem>
{automaticSortSupported ? (
<>
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
{t("sidebar.display.sort.created")}
</MenuItem>
<MenuItem
selected={preferences.workspaceSort === "lastPrompt"}
onSelect={selectLastPrompt}
>
{t("sidebar.display.sort.lastPrompt")}
</MenuItem>
</>
) : (
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
)}
</>
);
}
+7 -27
View File
@@ -55,11 +55,7 @@ import {
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import {
selectAgentSentMessages,
selectAgentTurnPresentation,
useSessionStore,
} from "@/stores/session-store";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
@@ -122,7 +118,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
@@ -1000,7 +995,6 @@ interface ComposerAutocompleteHandle {
function ComposerAutocompleteBinding({
text,
cursor,
recall,
configuration,
inputRef,
anchorRef,
@@ -1009,7 +1003,6 @@ function ComposerAutocompleteBinding({
}: {
text: ComposerTextSource;
cursor: StoreApi<number>;
recall: ComposerRecall;
configuration: Omit<
Parameters<typeof useAgentAutocomplete>[0],
"userInput" | "cursorIndex" | "onAutocompleteApplied"
@@ -1027,11 +1020,9 @@ function ComposerAutocompleteBinding({
cursorIndex: Math.min(cursorIndex, userInput.length),
onAutocompleteApplied: () => inputRef.current?.focus(),
});
// A recalled message is sent as is; its leading `/command` must not open the picker.
const isShown = show && !recall.isShowing(userInput);
const handleKeyPress = autocomplete.onKeyPress;
useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [
isShown,
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
show,
handleKeyPress,
]);
const selectOption = autocomplete.onSelectOption;
@@ -1041,7 +1032,7 @@ function ComposerAutocompleteBinding({
);
return (
<ComposerAutocomplete
visible={autocomplete.isVisible && isShown}
visible={autocomplete.isVisible && show}
anchorRef={anchorRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
@@ -1371,7 +1362,6 @@ function ComposerContentImpl({
);
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
const autocompleteRef = useRef<ComposerAutocompleteHandle>(null);
const recall = useMemo(() => new ComposerRecall(), []);
const [isProcessing, setIsProcessing] = useState(false);
const [pendingFiles, setPendingFiles] = useState<PendingFileAttachment[]>([]);
const nextPendingFileId = useRef(0);
@@ -1991,19 +1981,10 @@ function ComposerContentImpl({
const hasSendableContent = hasText || selectedAttachments.length > 0;
// An open autocomplete owns the arrows; otherwise they may recall sent messages.
// Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback(
(event: ComposerKeyPressEvent) => {
if (autocompleteRef.current?.onKeyPress(event)) return true;
const recalledText = recall.handleKeyPress(event, () =>
selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId),
);
if (recalledText === null) return false;
event.preventDefault();
replaceUserInput(recalledText);
return true;
},
[agentId, recall, replaceUserInput, serverId],
(event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false,
[],
);
const cancelButtonStyle = useMemo(
@@ -2442,7 +2423,6 @@ function ComposerContentImpl({
<ComposerAutocompleteBinding
text={textSource}
cursor={cursor}
recall={recall}
inputRef={messageInputRef}
anchorRef={messageInputContainerRef}
show={mode.showAutocomplete && !isFullscreen}
+2 -6
View File
@@ -110,8 +110,6 @@ export interface ComposerInputSnapshot {
export interface ComposerKeyPressEvent {
key: string;
/** Shift, Alt, Ctrl, or Meta was held. */
hasModifier: boolean;
preventDefault: () => void;
input: ComposerInputSnapshot;
}
@@ -212,7 +210,6 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
metaKey?: boolean;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
isComposing?: boolean;
keyCode?: number;
@@ -414,18 +411,17 @@ function handleDesktopKeyPressImpl(
): void {
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent;
if (ctx.onKeyPressCallback) {
const handled = ctx.onKeyPressCallback({
key: event.nativeEvent.key,
hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey),
preventDefault: () => event.preventDefault(),
input: ctx.input,
});
if (handled) return;
}
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
if (event.nativeEvent.key !== "Enter") return;
if (!ctx.submitOnEnter) return;
if (shiftKey) return;
-109
View File
@@ -1,109 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ComposerKeyPressEvent } from "@/composer/input/input";
import { ComposerRecall } from "@/composer/recall";
const SENT = ["add tests", "fix the auth bug"];
function press(
key: string,
text: string,
options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {},
): ComposerKeyPressEvent {
const caret = options.caret ?? text.length;
return {
key,
hasModifier: options.hasModifier ?? false,
preventDefault: () => undefined,
input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } },
};
}
/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */
function composerWithRecall(sent: readonly string[] = SENT) {
const recall = new ComposerRecall();
let text = "";
return {
recall,
get text() {
return text;
},
type(next: string) {
text = next;
},
press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) {
const result = recall.handleKeyPress(press(key, text, options), () => sent);
if (result !== null) text = result;
return result;
},
};
}
describe("ComposerRecall", () => {
it("walks back through sent messages and forward to an empty composer", () => {
const composer = composerWithRecall();
expect(composer.press("ArrowUp")).toBe("add tests");
expect(composer.press("ArrowUp")).toBe("fix the auth bug");
expect(composer.press("ArrowDown")).toBe("add tests");
expect(composer.press("ArrowDown")).toBe("");
expect(composer.recall.isShowing(composer.text)).toBe(false);
});
it("leaves the caret to the input past the oldest message", () => {
const composer = composerWithRecall();
composer.press("ArrowUp");
composer.press("ArrowUp");
expect(composer.press("ArrowUp")).toBeNull();
expect(composer.text).toBe("fix the auth bug");
});
it("only starts from an empty composer", () => {
const composer = composerWithRecall();
composer.type("half written");
expect(composer.press("ArrowUp")).toBeNull();
expect(composer.press("ArrowDown")).toBeNull();
});
it("ignores Down from an empty composer and Up with no history", () => {
expect(composerWithRecall().press("ArrowDown")).toBeNull();
expect(composerWithRecall([]).press("ArrowUp")).toBeNull();
});
it("hands the arrows back to the caret once the recalled message is edited", () => {
const composer = composerWithRecall();
composer.press("ArrowUp");
composer.type("add tests for login");
expect(composer.recall.isShowing(composer.text)).toBe(false);
expect(composer.press("ArrowUp")).toBeNull();
expect(composer.press("ArrowDown")).toBeNull();
});
it("moves between lines of a multi-line message before changing message", () => {
const composer = composerWithRecall(["first line\nsecond line", "older"]);
composer.press("ArrowUp");
expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull();
expect(composer.press("ArrowUp", { caret: 3 })).toBe("older");
expect(composer.press("ArrowDown")).toBe("first line\nsecond line");
expect(composer.press("ArrowDown", { caret: 3 })).toBeNull();
expect(composer.press("ArrowDown")).toBe("");
});
it("ignores modified arrows, other keys, and a selection", () => {
const composer = composerWithRecall();
expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull();
expect(composer.press("Enter")).toBeNull();
composer.press("ArrowUp");
const recall = composer.recall;
expect(
recall.handleKeyPress(
press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }),
() => SENT,
),
).toBeNull();
});
});
-68
View File
@@ -1,68 +0,0 @@
import type { ComposerKeyPressEvent } from "@/composer/input/input";
interface RecalledMessage {
index: number;
text: string;
}
/**
* Walks the messages already sent to an agent from an empty composer, like a
* shell's Up/Down history. A message stays recalled only while the composer
* still holds its exact text; the first edit hands the arrows back to the caret.
*/
export class ComposerRecall {
private recalled: RecalledMessage | null = null;
/** True while the composer shows a recalled message the user has not edited. */
isShowing(text: string): boolean {
return this.recalled !== null && this.recalled.text === text;
}
/**
* Returns the text the composer should show for this key press, or null to
* leave the key to the input. `readSentMessages` is newest first and is only
* read for keys recall acts on.
*/
handleKeyPress(
event: ComposerKeyPressEvent,
readSentMessages: () => readonly string[],
): string | null {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return null;
if (event.hasModifier) return null;
const { text, selection } = event.input;
if (selection.start !== selection.end) return null;
const recalled = this.isShowing(text) ? this.recalled : null;
if (!recalled) {
this.recalled = null;
if (event.key !== "ArrowUp" || text.length > 0) return null;
return this.show(readSentMessages(), 0);
}
if (event.key === "ArrowUp") {
if (!isCaretOnFirstLine(text, selection.start)) return null;
return this.show(readSentMessages(), recalled.index + 1);
}
if (!isCaretOnLastLine(text, selection.start)) return null;
if (recalled.index === 0) {
this.recalled = null;
return "";
}
return this.show(readSentMessages(), recalled.index - 1);
}
private show(messages: readonly string[], index: number): string | null {
const text = messages[index];
if (text === undefined) return null;
this.recalled = { index, text };
return text;
}
}
function isCaretOnFirstLine(text: string, caret: number): boolean {
return !text.slice(0, caret).includes("\n");
}
function isCaretOnLastLine(text: string, caret: number): boolean {
return !text.slice(caret).includes("\n");
}
@@ -617,7 +617,7 @@ describe("computeSidebarOrderUpdates", () => {
persistedProjectOrder: ["stale-project"],
});
expect(updates).toEqual({ projectOrder: null });
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
});
it("appends unseen projects without changing saved project order", () => {
@@ -979,3 +979,20 @@ it("retains unrelated projects and keyed row placements when one project reorder
expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]);
expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]);
});
it("prepends newly discovered manual workspaces without dropping filtered saved keys", () => {
const projects = [sidebarProject({ projectKey: "p", workspaceKeys: ["new", "b", "a"] })];
expect(
computeSidebarOrderUpdates({
projects,
persistedProjectOrder: ["p"],
getWorkspaceOrder: () => ["srv:hidden", "srv:a", "srv:b"],
}),
).toEqual({
projectOrder: null,
workspaceOrders: [{ projectViewKey: "p", order: ["srv:new", "srv:hidden", "srv:a", "srv:b"] }],
});
expect(
computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["p"] }).workspaceOrders,
).toEqual([]);
});
@@ -535,21 +535,57 @@ export function appendMissingOrderKeys(input: {
return [...input.currentOrder, ...missingKeys];
}
export function prependMissingOrderKeys(input: {
currentOrder: string[];
visibleKeys: string[];
}): string[] {
if (input.visibleKeys.length === 0) {
return input.currentOrder;
}
const existingKeys = new Set(input.currentOrder);
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
if (missingKeys.length === 0) {
return input.currentOrder;
}
return [...missingKeys, ...input.currentOrder];
}
export interface SidebarOrderUpdates {
projectOrder: string[] | null;
workspaceOrders: Array<{ projectViewKey: string; order: string[] }>;
}
export function computeSidebarOrderUpdates(input: {
projects: SidebarProjectEntry[];
persistedProjectOrder: string[];
getWorkspaceOrder?: (projectViewKey: string) => string[];
}): SidebarOrderUpdates {
if (input.projects.length === 0) {
return { projectOrder: null, workspaceOrders: [] };
}
const nextProjectOrder = appendMissingOrderKeys({
currentOrder: input.persistedProjectOrder,
visibleKeys: input.projects.map((project) => project.viewKey),
});
return {
projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder,
};
const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder;
const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = [];
for (const project of input.projects) {
if (!input.getWorkspaceOrder) break;
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey);
const nextWorkspaceOrder = prependMissingOrderKeys({
currentOrder: persistedWorkspaceOrder,
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
});
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder });
}
}
return { projectOrder, workspaceOrders };
}
export interface SidebarLoadingState {
@@ -144,7 +144,10 @@ export function useSidebarWorkspacesList(options?: {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes");
const workspaceSortSupported = [...sortSupport.values()].every(Boolean);
const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined;
const sort =
groupMode === "project" && (workspaceSort === "manual" || workspaceSortSupported)
? workspaceSort
: undefined;
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
@@ -166,12 +169,16 @@ export function useSidebarWorkspacesList(options?: {
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder,
getWorkspaceOrder: sort === "manual" ? orderStore.getWorkspaceOrder : undefined,
});
if (updates.projectOrder) {
orderStore.setProjectOrder(updates.projectOrder);
}
}, [persistedProjectOrder, projects]);
for (const update of updates.workspaceOrders) {
orderStore.setWorkspaceOrder(update.projectViewKey, update.order);
}
}, [persistedProjectOrder, projects, sort]);
const refreshAll = useCallback(() => {
if (!isActive) return;
+1
View File
@@ -1169,6 +1169,7 @@ export const ar: TranslationResources = {
sort: {
label: "الترتيب",
created: "تاريخ الإنشاء",
manual: "يدوي",
lastPrompt: "آخر طلب",
updateHost: "حدّث المضيفين لترتيب مساحات العمل",
},
+1
View File
@@ -1177,6 +1177,7 @@ export const en = {
sort: {
label: "Sort",
created: "Created",
manual: "Manual",
lastPrompt: "Last prompt",
updateHost: "Update hosts to sort workspaces",
},
+1
View File
@@ -1205,6 +1205,7 @@ export const es: TranslationResources = {
sort: {
label: "Orden",
created: "Creación",
manual: "Manual",
lastPrompt: "Último prompt",
updateHost: "Actualiza los hosts para ordenar los espacios de trabajo",
},
+1
View File
@@ -1198,6 +1198,7 @@ export const fr: TranslationResources = {
sort: {
label: "Tri",
created: "Création",
manual: "Manuel",
lastPrompt: "Dernier prompt",
updateHost: "Mettez les hôtes à jour pour trier les espaces de travail",
},
+1
View File
@@ -1182,6 +1182,7 @@ export const ja: TranslationResources = {
sort: {
label: "並び順",
created: "作成日時",
manual: "手動",
lastPrompt: "最後のプロンプト",
updateHost: "ワークスペースを並べ替えるにはホストを更新してください",
},
+1
View File
@@ -1176,6 +1176,7 @@ export const ko: TranslationResources = {
sort: {
label: "정렬",
created: "생성일",
manual: "수동",
lastPrompt: "마지막 프롬프트",
updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요",
},
+1
View File
@@ -1195,6 +1195,7 @@ export const ptBR: TranslationResources = {
sort: {
label: "Ordenação",
created: "Criação",
manual: "Manual",
lastPrompt: "Último prompt",
updateHost: "Atualize os hosts para ordenar os espaços de trabalho",
},
+1
View File
@@ -1186,6 +1186,7 @@ export const ru: TranslationResources = {
sort: {
label: "Сортировка",
created: "Дата создания",
manual: "Вручную",
lastPrompt: "Последний запрос",
updateHost: "Обновите хосты для сортировки рабочих пространств",
},
+1
View File
@@ -1161,6 +1161,7 @@ export const zhCN: TranslationResources = {
sort: {
label: "排序",
created: "创建时间",
manual: "手动",
lastPrompt: "最近提示词",
updateHost: "请更新主机以排序工作区",
},
@@ -30,7 +30,7 @@ interface WorkspaceStructureSession {
workspaces: Iterable<WorkspaceDescriptor>;
}
export type WorkspaceSort = "created" | "lastPrompt";
export type WorkspaceSort = "manual" | "created" | "lastPrompt";
interface WorkspaceSortItem {
workspaceName: string;
@@ -266,3 +266,115 @@ it("setup metadata from directory snapshots and transitions resets failure surfa
expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(false);
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
});
it("keeps a live workspace update when an older creation snapshot arrives", () => {
const serverId = "workspace-create-after-pin";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const initial = workspace("new");
const pinned = {
...initial,
pinnedAt: "2026-09-26T00:00:00.000Z",
title: "Updated title",
};
try {
replica.applyDelta({ kind: "upsert", workspace: pinned });
const accepted = useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id);
expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(initial)])).toEqual([]);
expect(replica.snapshot().workspaces.get(initial.id)).toEqual(
normalizeWorkspaceDescriptor(pinned),
);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toBe(
accepted,
);
} finally {
store.clearSession(serverId);
}
});
it("seeds missing workspaces before live updates and in a mixed creation batch", () => {
const serverId = "workspace-create-before-pin";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const initial = normalizeWorkspaceDescriptor(workspace("new"));
const second = normalizeWorkspaceDescriptor(workspace("second"));
const pinned = { ...workspace(initial.id), pinnedAt: "2026-09-26T00:00:00.000Z" };
try {
expect(replica.acceptWorkspaces([initial])).toEqual([
{ kind: "workspace", type: "upsert", id: initial.id, value: initial },
]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
initial,
);
replica.applyDelta({ kind: "upsert", workspace: pinned });
expect(replica.acceptWorkspaces([initial, second])).toEqual([
{ kind: "workspace", type: "upsert", id: second.id, value: second },
]);
expect([...useSessionStore.getState().sessions[serverId]!.workspaces.values()]).toEqual([
normalizeWorkspaceDescriptor(pinned),
second,
]);
} finally {
store.clearSession(serverId);
}
});
it("keeps a manual unpin when a stale pinned snapshot arrives", () => {
const serverId = "workspace-manual-unpin";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const pinned = { ...workspace("new"), pinnedAt: "2026-09-26T00:00:00.000Z" };
const unpinned = { ...pinned, pinnedAt: null };
try {
replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)]);
replica.applyDelta({ kind: "upsert", workspace: unpinned });
expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)])).toEqual([]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(pinned.id)).toEqual(
normalizeWorkspaceDescriptor(unpinned),
);
} finally {
store.clearSession(serverId);
}
});
it("restores a missing workspace after an archive failure without replacing a live row", () => {
const serverId = "workspace-archive-rollback";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const initial = normalizeWorkspaceDescriptor(workspace("new"));
try {
replica.acceptWorkspaces([initial]);
markWorkspaceArchivePending({ serverId, workspaceId: initial.id });
replica.removeWorkspaceSnapshot(initial.id);
expect(replica.acceptWorkspaces([initial])).toEqual([]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(initial.id)).toBe(false);
clearWorkspaceArchivePending({ serverId, workspaceId: initial.id });
expect(replica.acceptWorkspaces([initial])).toEqual([
{ kind: "workspace", type: "upsert", id: initial.id, value: initial },
]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
initial,
);
const updated = { ...workspace(initial.id), title: "Updated during archive" };
replica.applyDelta({ kind: "upsert", workspace: updated });
expect(replica.acceptWorkspaces([initial])).toEqual([]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
normalizeWorkspaceDescriptor(updated),
);
} finally {
clearWorkspaceArchivePending({ serverId, workspaceId: initial.id });
store.clearSession(serverId);
}
});
@@ -82,7 +82,12 @@ export class WorkspaceDirectoryReplica {
}
acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] {
return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace));
// Creation responses and progress snapshots can arrive after live updates.
// They seed missing rows; the directory owns rows it has already received.
return workspaces.flatMap((workspace) => {
if (this.workspaces.has(workspace.id)) return [];
return this.upsertWorkspace(workspace);
});
}
acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] {
@@ -117,13 +117,17 @@ export function useWorkspaceStructure(
(state) => selectProjectOrder(state),
workspaceEqualityFns.deep,
);
const workspaceOrderByProject = useSidebarOrderStore((state) =>
workspaceSort === "manual" ? state.workspaceOrderByProject : undefined,
);
return useMemo(
() =>
composeWorkspaceStructure({
projects,
projectOrder,
workspaceOrderByProject,
}),
[projectOrder, projects],
[projectOrder, projects, workspaceOrderByProject],
);
}
@@ -640,3 +640,27 @@ it("a prompt changes only its project's structure and output preserves both proj
expect(output[0]).toBe(after[0]);
expect(output[1]).toBe(after[1]);
});
it("applies manual order only when requested without mutating the automatic baseline", () => {
const project = {
viewKey: "p",
projectKey: "p",
projectName: "P",
projectKind: "git" as const,
iconWorkingDir: "/repo",
hosts: [],
workspaceKeys: ["h:new", "h:b", "h:a"],
};
const input = { projects: [project], projectOrder: ["p"] };
expect(
composeWorkspaceStructure({
...input,
workspaceOrderByProject: { p: ["h:filtered", "h:a", "h:b"] },
}).projects[0].workspaceKeys,
).toEqual(["h:new", "h:a", "h:b"]);
expect(composeWorkspaceStructure(input).projects[0].workspaceKeys).toEqual([
"h:new",
"h:b",
"h:a",
]);
});
@@ -254,6 +254,7 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] {
export function composeWorkspaceStructure(input: {
projects: WorkspaceStructureProject[];
projectOrder: readonly string[];
workspaceOrderByProject?: Readonly<Record<string, string[]>>;
}): WorkspaceStructure {
if (input.projects.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE;
@@ -265,7 +266,21 @@ export function composeWorkspaceStructure(input: {
getKey: (project) => project.viewKey,
});
return { projects: orderedProjects };
return {
// Keep the shared automatic projection immutable when applying a sidebar preference.
// eslint-disable-next-line oxc/no-map-spread
projects: orderedProjects.map((project) => {
const storedOrder = input.workspaceOrderByProject?.[project.viewKey];
if (!storedOrder) return project;
const orderedProject = { ...project };
orderedProject.workspaceKeys = applyStoredOrdering({
items: project.workspaceKeys,
storedOrder,
getKey: (key) => key,
});
return orderedProject;
}),
};
}
export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] {
@@ -5,7 +5,6 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeWorkspaceDescriptor,
selectAgentSentMessages,
selectAgentTurnPresentation,
selectAgentTimelineState,
useSessionStore,
@@ -236,25 +235,6 @@ describe("agent timeline state", () => {
).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" });
});
it("lists sent messages newest first, including the live head", () => {
initializeTestSession();
const sent = (id: string, text: string) => ({ ...submittedMessage(id), text });
useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", {
items: [sent("m1", "fix the auth bug"), sent("m2", "add tests"), sent("m3", "add tests")],
head: [sent("m4", " "), sent("m5", "refactor the wrong module")],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
older: "none",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
expect(
selectAgentSentMessages(useSessionStore.getState().sessions["test-server"], "agent-1"),
).toEqual(["refactor the wrong module", "add tests", "fix the auth bug"]);
expect(selectAgentSentMessages(undefined, "agent-1")).toEqual([]);
});
it("preserves older availability when an applied response leaves it unchanged", () => {
initializeTestSession();
const store = useSessionStore.getState();
-20
View File
@@ -332,26 +332,6 @@ export function selectAgentTimelineState(
return items.length > 0 ? { status: "painted", items } : { status: "cold" };
}
/** Text of the loaded user messages, newest first, with back-to-back repeats collapsed. */
export function selectAgentSentMessages(
session: SessionState | undefined,
agentId: string,
): string[] {
if (!session) return [];
const items = [
...(session.agentStreamTail.get(agentId) ?? []),
...(session.agentStreamHead.get(agentId) ?? []),
];
const messages: string[] = [];
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item.kind !== "user_message" || item.text.trim().length === 0) continue;
if (messages[messages.length - 1] === item.text) continue;
messages.push(item.text);
}
return messages;
}
export function selectAgentTurnPresentation(
session: SessionState | undefined,
agentId: string,
@@ -6,7 +6,7 @@ import {
import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store";
describe("migrateSidebarOrderState", () => {
it("discards legacy workspace ordering while retaining project ordering", () => {
it("preserves legacy workspace ordering across hosts", () => {
const migrated = migrateSidebarOrderState({
projectOrderByServerId: {
"host-a": ["project-a"],
@@ -21,6 +21,7 @@ describe("migrateSidebarOrderState", () => {
expect(migrated).toEqual({
projectOrder: ["project-a"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { "project-a": ["host-a:main", "host-a:feature", "host-b:main"] },
});
});
@@ -88,14 +89,18 @@ describe("sidebar order keys that end in whitespace", () => {
});
});
it("preserves project and pinned order while discarding ordinary workspace order", () => {
it("preserves project, pinned and ordinary workspace order", () => {
expect(
migrateSidebarOrderState({
projectOrder: ["b", "a"],
pinnedWorkspaceOrder: ["h:y", "h:x"],
workspaceOrderByProject: { a: ["h:old", "h:new"] },
}),
).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] });
).toEqual({
projectOrder: ["b", "a"],
pinnedWorkspaceOrder: ["h:y", "h:x"],
workspaceOrderByProject: { a: ["h:old", "h:new"] },
});
});
it("preserves whitespace in current project and pin identities during upgrade", () => {
@@ -105,5 +110,37 @@ it("preserves whitespace in current project and pin identities during upgrade",
pinnedWorkspaceOrder: ["host:workspace "],
workspaceOrderByProject: {},
}),
).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] });
).toEqual({
projectOrder: ["project "],
pinnedWorkspaceOrder: ["host:workspace "],
workspaceOrderByProject: {},
});
});
it("retains a pre-beta manual order when upgrading", () => {
expect(
migrateSidebarOrderState({
projectOrder: ["project"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { project: ["host:b", "host:a"] },
}),
).toEqual({
projectOrder: ["project"],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: { project: ["host:b", "host:a"] },
});
});
it("starts with an empty manual order for beta users whose order was discarded", () => {
expect(migrateSidebarOrderState({ projectOrder: ["p"], pinnedWorkspaceOrder: ["h:a"] })).toEqual({
projectOrder: ["p"],
pinnedWorkspaceOrder: ["h:a"],
workspaceOrderByProject: {},
});
});
it("preserves spaces in saved workspace scopes and identities", () => {
expect(
migrateSidebarOrderState({ workspaceOrderByProject: { "project ": ["host:b ", "host:a "] } })
.workspaceOrderByProject,
).toEqual({ "project ": ["host:b ", "host:a "] });
});
+71 -3
View File
@@ -7,10 +7,13 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora
interface SidebarOrderStoreState {
projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
getProjectOrder: () => string[];
setProjectOrder: (keys: string[]) => void;
getPinnedWorkspaceOrder: () => string[];
setPinnedWorkspaceOrder: (keys: string[]) => void;
getWorkspaceOrder: (projectViewKey: string) => string[];
setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void;
}
interface SidebarOrderPersistedState {
@@ -25,12 +28,16 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string()));
const SidebarOrderPersistedStateSchema = z.strictObject({
projectOrder: z.array(z.string()).optional(),
pinnedWorkspaceOrder: z.array(z.string()).optional(),
// COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins.
workspaceOrderByProject: StringArrayRecordSchema.optional(),
projectOrderByServerId: StringArrayRecordSchema.optional(),
workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(),
});
interface SidebarWorkspaceOrderScope {
serverId: string;
projectViewKey: string;
}
// Trims each key. Only for persisted state read at migration time, where a
// stray space is an artifact of an older format rather than part of the key.
function normalizeKeys(keys: string[]): string[] {
@@ -71,13 +78,42 @@ function dedupeKeys(keys: string[]): string[] {
return deduped;
}
function normalizeWorkspaceOrderByProject(
workspaceOrderByProject: Record<string, string[]> | undefined,
): Record<string, string[]> {
const normalized: Record<string, string[]> = {};
for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) {
const scope = projectViewKey;
if (!scope.trim()) continue;
normalized[scope] = dedupeKeys(order);
}
return normalized;
}
function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null {
const separatorIndex = scopeKey.indexOf("::");
if (separatorIndex < 0) return null;
const serverId = scopeKey.slice(0, separatorIndex).trim();
const projectViewKey = scopeKey.slice(separatorIndex + 2).trim();
if (!serverId || !projectViewKey) return null;
return { serverId, projectViewKey };
}
function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null {
const workspaceKey = rawWorkspaceKey.trim();
if (!workspaceKey) return null;
const serverPrefix = `${serverId}:`;
return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`;
}
export function migrateSidebarOrderState(persistedState: unknown): {
projectOrder: string[];
pinnedWorkspaceOrder: string[];
workspaceOrderByProject: Record<string, string[]>;
} {
const result = SidebarOrderPersistedStateSchema.safeParse(persistedState);
if (!result.success) {
return { projectOrder: [], pinnedWorkspaceOrder: [] };
return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} };
}
const state: SidebarOrderPersistedState = result.data;
@@ -91,9 +127,26 @@ export function migrateSidebarOrderState(persistedState: unknown): {
}
}
const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject);
for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) {
const scope = extractWorkspaceOrderScope(scopeKey);
if (!scope) continue;
const existing = workspaceOrderByProject[scope.projectViewKey] ?? [];
const merged = [...existing];
const seen = new Set(merged);
for (const key of order) {
const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key);
if (!workspaceKey || seen.has(workspaceKey)) continue;
seen.add(workspaceKey);
merged.push(workspaceKey);
}
workspaceOrderByProject[scope.projectViewKey] = merged;
}
return {
projectOrder,
pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []),
workspaceOrderByProject,
};
}
@@ -102,6 +155,7 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
(set, get) => ({
projectOrder: [],
pinnedWorkspaceOrder: [],
workspaceOrderByProject: {},
getProjectOrder: () => get().projectOrder,
setProjectOrder: (keys) => {
set({ projectOrder: dedupeKeys(keys) });
@@ -110,6 +164,19 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
setPinnedWorkspaceOrder: (keys) => {
set({ pinnedWorkspaceOrder: dedupeKeys(keys) });
},
getWorkspaceOrder: (projectViewKey) => {
if (!projectViewKey.trim()) return [];
return get().workspaceOrderByProject[projectViewKey] ?? [];
},
setWorkspaceOrder: (projectViewKey, keys) => {
if (!projectViewKey.trim()) return;
set((state) => ({
workspaceOrderByProject: {
...state.workspaceOrderByProject,
[projectViewKey]: dedupeKeys(keys),
},
}));
},
}),
{
name: "sidebar-project-workspace-order",
@@ -117,8 +184,9 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
partialize: (state) => ({
projectOrder: state.projectOrder,
pinnedWorkspaceOrder: state.pinnedWorkspaceOrder,
workspaceOrderByProject: state.workspaceOrderByProject,
}),
version: 2,
version: 3,
migrate: migrateSidebarOrderState,
},
),
@@ -300,3 +300,7 @@ it("defaults to Created and persists the Last prompt choice across migration", (
migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }),
).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" });
});
it("retains the manual sorting preference on reload", () => {
expect(migrateSidebarViewState({ workspaceSort: "manual" }).workspaceSort).toBe("manual");
});
@@ -91,7 +91,7 @@ const SidebarLabelFilterSchema = z.object({
labels: z.array(z.string()),
});
const SidebarViewPersistedStateSchema = z.strictObject({
workspaceSort: z.enum(["created", "lastPrompt"]).optional(),
workspaceSort: z.enum(["manual", "created", "lastPrompt"]).optional(),
showBackground: z.boolean().optional(),
groupMode: PersistedSidebarGroupModeSchema.optional(),
hostFilters: z.array(z.string()).optional(),
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/server": "0.13.0-beta.1",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/server": "0.13.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.13.0-beta.1",
"version": "0.13.0-beta.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/relay": "0.13.0-beta.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.13.0-beta.1",
"version": "0.13.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.13.0-beta.1",
"version": "0.13.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.13.0-beta.1",
"version": "0.13.0-beta.2",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -72,8 +72,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -83,8 +83,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -374,13 +374,19 @@ class AcpRuntime {
/**
* Fallback ids for streamed chunks that carry no `messageId`, one per
* chunk kind. Consecutive chunks of a kind continue the same timeline
* item; a chunk of the other kind, a tool call, a plan, a user message,
* or a turn boundary ends it (#4699).
* item; a chunk of another kind, a tool call, a plan, or a turn boundary
* ends it (#4699).
*/
private readonly fallbackChunkIds = new Map<
"agent_message_chunk" | "agent_thought_chunk",
"agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
string
>();
/**
* Active while a persisted session loads. ACP agents replay the loaded
* session's updates before session/load resolves; the value follows the
* open request's history contract.
*/
private replayWindow: "replay" | "skip" | null = null;
private closing = false;
private processFailed = false;
private configTransaction = false;
@@ -499,15 +505,23 @@ class AcpRuntime {
};
let response: Pick<NewSessionResponse, "modes" | "configOptions">;
if (nativeSessionId) {
response = await this.call(
this.connection.loadSession({
sessionId: nativeSessionId,
cwd: input.config.cwd,
mcpServers,
_meta: metadata,
}),
);
// ACP agents replay the loaded session's updates before session/load resolves,
// so the native id must be registered first for them to pass the session filter.
this.nativeSessionId = nativeSessionId;
this.replayWindow = input.history === "replay" ? "replay" : "skip";
try {
response = await this.call(
this.connection.loadSession({
sessionId: nativeSessionId,
cwd: input.config.cwd,
mcpServers,
_meta: metadata,
}),
);
await this.drainNotifications();
} finally {
this.replayWindow = null;
}
} else {
const newSession = await this.call(
this.connection.newSession({
@@ -898,6 +912,14 @@ class AcpRuntime {
private sessionUpdate(notification: SessionNotification): void {
if (notification.sessionId !== this.nativeSessionId) return;
if (this.replayWindow === "skip") return;
if (
this.replayWindow === "replay" &&
notification.update.sessionUpdate === "user_message_chunk"
) {
this.reduceUserMessageChunk(notification.update);
return;
}
this.reduceUpdate(notification.update);
}
@@ -911,13 +933,14 @@ class AcpRuntime {
this.fallbackChunkIds.clear();
return;
}
// A chunk of one kind ends the other kind's item, whatever its content
// or id: reasoning, then text, then reasoning again are three items.
// A chunk of one kind ends every other kind's open item, whatever its
// content or id: reasoning, then text, then reasoning again are three items.
this.fallbackChunkIds.delete(
update.sessionUpdate === "agent_message_chunk"
? "agent_thought_chunk"
: "agent_message_chunk",
);
this.fallbackChunkIds.delete("user_message_chunk");
if (update.content.type !== "text") return;
const id = this.resolveChunkId(update.sessionUpdate, update.messageId);
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
@@ -945,8 +968,31 @@ class AcpRuntime {
this.reduceStateUpdate(update);
}
// Historical user messages arrive only as replayed chunks; a live prompt
// already echoes its own user message when it is admitted.
private reduceUserMessageChunk(
update: Extract<SessionUpdate, { sessionUpdate: "user_message_chunk" }>,
): void {
this.fallbackChunkIds.delete("agent_message_chunk");
this.fallbackChunkIds.delete("agent_thought_chunk");
if (update.content.type !== "text") return;
const id = this.resolveChunkId("user_message_chunk", update.messageId);
const text = `${this.messages.get(id) ?? ""}${update.content.text}`;
this.messages.set(id, text);
this.emit({
type: "timeline.item",
sessionId: this.options.boundarySessionId,
item: {
type: "user_message",
id,
text,
...(update.messageId ? { messageId: update.messageId } : {}),
},
});
}
private resolveChunkId(
kind: "agent_message_chunk" | "agent_thought_chunk",
kind: "agent_message_chunk" | "agent_thought_chunk" | "user_message_chunk",
messageId: string | null | undefined,
): string {
if (messageId) {
+184
View File
@@ -1500,3 +1500,187 @@ describe("ACP streamed message boundaries", () => {
},
);
});
function userChunk(text: string, messageId?: string): SessionUpdate {
return {
sessionUpdate: "user_message_chunk",
content: { type: "text", text },
...(messageId === undefined ? {} : { messageId }),
};
}
async function openReplaySession(
replay: SessionUpdate[],
history: "replay" | "skip",
): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> {
const harness = connectorHarness({
capabilities: { loadSession: true },
handleRequest(instance, request) {
if (request.method !== "session/load") return false;
for (const update of replay) {
instance.notify("session/update", { sessionId: "connector-session", update });
}
instance.respond(request, {
sessionId: "connector-session",
modes: null,
configOptions: [],
});
return true;
},
});
const registration = runAcpProvider({
id: "replay-acp",
label: "Replay ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message", "session.persistence"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
const input = openInput();
input.persistence = { version: 1, data: { sessionId: "connector-session" } };
input.history = history;
await connection.send(input);
await waitForEvent(events, (event) => event.type === "session.ready");
return { connection, events };
}
function timelineProjection(events: ProviderEvent[]): Array<Record<string, string>> {
return events.flatMap((event) => {
if (event.type !== "timeline.item") return [];
const item = event.item;
if (
item.type === "user_message" ||
item.type === "assistant_message" ||
item.type === "reasoning"
) {
return [{ type: item.type, id: item.id, text: item.text }];
}
if (item.type === "tool_call") return [{ type: item.type, id: item.id, status: item.status }];
return [{ type: item.type, id: item.id }];
});
}
describe("ACP session history replay", () => {
it("restores the timeline replayed before session/load resolves", async () => {
const { connection, events } = await openReplaySession(
[
userChunk("What is the ", "user-1"),
userChunk("temperature?", "user-1"),
textChunk("Checking the sensor", "agent_thought_chunk"),
{
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "Read sensor",
kind: "read",
status: "in_progress",
},
{ sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: "completed" },
textChunk("It is 25°C"),
],
"replay",
);
try {
expect(timelineProjection(events)).toEqual([
{ type: "user_message", id: "user-1", text: "What is the " },
{ type: "user_message", id: "user-1", text: "What is the temperature?" },
{ type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" },
{ type: "tool_call", id: "tool-1", status: "running" },
{ type: "tool_call", id: "tool-1", status: "completed" },
{ type: "assistant_message", id: "agent_message_chunk:2", text: "It is 25°C" },
]);
const lastReplayed = events.findLastIndex((event) => event.type === "timeline.item");
const opened = events.findIndex((event) => event.type === "session.opened");
expect(lastReplayed).toBeLessThan(opened);
} finally {
await connection.close();
}
});
it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => {
const { connection, events } = await openReplaySession(
[
userChunk("first "),
userChunk("question"),
textChunk("answer"),
userChunk("second question"),
],
"replay",
);
try {
expect(timelineProjection(events)).toEqual([
{ type: "user_message", id: "user_message_chunk:1", text: "first " },
{ type: "user_message", id: "user_message_chunk:1", text: "first question" },
{ type: "assistant_message", id: "agent_message_chunk:2", text: "answer" },
{ type: "user_message", id: "user_message_chunk:3", text: "second question" },
]);
} finally {
await connection.close();
}
});
it("does not duplicate the live prompt echo when the agent echoes it as a user chunk", async () => {
const harness = connectorHarness({
capabilities: { loadSession: true },
promptUpdates: (promptText) => [
{ sessionUpdate: "user_message_chunk", content: { type: "text", text: promptText } },
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Acknowledged" } },
],
});
const registration = runAcpProvider({
id: "echo-acp",
label: "Echo ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message", "session.persistence"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
const input = openInput();
input.persistence = { version: 1, data: { sessionId: "connector-session" } };
input.history = "replay";
await connection.send(input);
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "echo-1",
delivery: "auto",
input: { type: "message", content: [{ type: "text", text: "hello" }] },
},
});
try {
await waitForEvent(
events,
(event) =>
event.type === "session.turn" &&
event.turnId === "acp:echo-1" &&
event.state === "completed",
);
expect(timelineProjection(events)).toEqual([
{ type: "user_message", id: "echo-1", text: "hello" },
{ type: "assistant_message", id: "agent_message_chunk:1", text: "Acknowledged" },
]);
} finally {
await connection.close();
}
});
it("drops replayed updates when the open request asks to skip history", async () => {
const { connection, events } = await openReplaySession(
[userChunk("What is in the README?", "user-1"), textChunk("It describes the project.")],
"skip",
);
try {
expect(timelineProjection(events)).toEqual([]);
} finally {
await connection.close();
}
});
});
+20
View File
@@ -9,6 +9,7 @@ export const PROVIDER_CAPABILITIES = [
"prompt.image",
"prompt.output_schema",
"prompt.steer",
"session.can_stop_runtime",
"session.archive",
"session.configure",
"session.list",
@@ -277,6 +278,7 @@ export type ProviderInput =
}
| { type: "session.archive"; requestId: string; persistence: ProviderPersistence }
| { type: "session.unarchive"; requestId: string; persistence: ProviderPersistence }
| { type: "session.can_stop_runtime"; requestId: string; sessionId: string }
| { type: "session.close"; requestId: string; sessionId: string };
export interface ProviderThinkingOption {
@@ -552,6 +554,7 @@ export type ProviderTimelineItem =
});
export type ProviderEvent =
| { type: "session.can_stop_runtime"; requestId: string; sessionId: string; canStop: boolean }
| { type: "catalog"; requestId: string; catalog: ProviderCatalog }
| { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] }
| { type: "request.completed"; requestId: string }
@@ -634,6 +637,8 @@ function requiredPromptCapabilities(prompt: ProviderPrompt): readonly ProviderCa
export function requiredProviderCapabilities(input: ProviderInput): readonly ProviderCapability[] {
switch (input.type) {
case "session.can_stop_runtime":
return ["session.can_stop_runtime"];
case "catalog":
case "session.interrupt":
case "session.close":
@@ -955,6 +960,13 @@ export const ProviderInputSchema: z.ZodType<ProviderInput> = z.discriminatedUnio
persistence: persistenceSchema,
})
.strict(),
z
.object({
type: z.literal("session.can_stop_runtime"),
requestId: idSchema,
sessionId: idSchema,
})
.strict(),
z.object({ type: z.literal("session.close"), requestId: idSchema, sessionId: idSchema }).strict(),
]);
@@ -1326,6 +1338,14 @@ export const ProviderEventSchema: z.ZodType<ProviderEvent> = z.discriminatedUnio
sessionId: idSchema,
})
.strip(),
z
.object({
type: z.literal("session.can_stop_runtime"),
requestId: idSchema,
sessionId: idSchema,
canStop: z.boolean(),
})
.strip(),
z
.object({
type: z.literal("session.closed"),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.13.0-beta.1",
"version": "0.13.0-beta.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0-beta.1",
"@getpaseo/highlight": "0.13.0-beta.1",
"@getpaseo/plugin": "0.13.0-beta.1",
"@getpaseo/protocol": "0.13.0-beta.1",
"@getpaseo/relay": "0.13.0-beta.1",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/highlight": "0.13.0-beta.2",
"@getpaseo/plugin": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/relay": "0.13.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1,3 +1,4 @@
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
import {
prepareAgentMessage,
formatAgentMessage,
@@ -10007,6 +10008,47 @@ test("closeAgent persists one final closed snapshot", async () => {
}
});
test("idle runtime cleanup honors a provider veto and resumes the saved conversation", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-cleanup-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let safe = false;
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = await super.createSession(config);
session.canStopRuntime = async () => safe;
return session;
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const collect = runtimeCleanup(manager);
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const now = new Date(Date.now() + 61 * 60_000);
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
safe = true;
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
const later = new Date(now.getTime() + 61 * 60_000);
expect((await collect({ now: later })).collected).toEqual([
{ agentId: agent.id, provider: "codex", sessionId: agent.persistence?.sessionId },
]);
expect(manager.getAgent(agent.id)).toBeNull();
const resumed = await ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(resumed.id).toBe(agent.id);
expect(resumed.persistence).toEqual(agent.persistence);
await manager.runAgent(agent.id, "Continue the saved conversation");
} finally {
await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined);
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
}
});
test("idle agents remain resident until an explicit lifecycle action closes them", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-"));
let closeCount = 0;
@@ -10951,12 +10993,12 @@ test("tryRunOutOfBand gives providers the command another agent sent, not its en
try {
const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" });
expect(manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
expect(await manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
expect(seenPrompts).toEqual(["/handled"]);
// A human who pastes an envelope gets it quoted, and it stays a plain prompt.
const pasted = prepareAgentMessage(String(fromAgent.prompt));
expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
expect(await manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
expect(seenPrompts.at(-1)).toBe(pasted.prompt);
} finally {
await manager.closeAgent(agent.id);
@@ -12083,3 +12125,176 @@ test("reads the last durable assistant response without loading a stored-only ch
);
expect(manager.getAgent("stored-child")).toBeNull();
});
function runtimeCleanup(manager: AgentManager) {
let now = Date.now();
let protectedCaller = (_agentId: string) => false;
const cleanup = createIdleRuntimeCleanup({
enabled: true,
runtimes: () => manager.getRuntimeCleanupCandidates(),
logger,
now: () => now,
isProtected: (agentId) => protectedCaller(agentId),
schedule: () => () => {},
});
onTestFinished(() => cleanup.close());
return (options: { now: Date; isProtected?: (agentId: string) => boolean }) => {
now = options.now.getTime();
protectedCaller = options.isProtected ?? (() => false);
return cleanup.sweep();
};
}
function idleCleanupHarness() {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-protection-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const commands: AgentPromptInput[] = [];
const prompts: AgentPromptInput[] = [];
const recordPrompts = (session: AgentSession) => {
session.tryHandleOutOfBand = (prompt) =>
prompt === "/maintenance"
? {
run: async () => {
commands.push(prompt);
},
}
: null;
const start = session.startTurn.bind(session);
session.startTurn = (prompt, options) => {
prompts.push(prompt);
return start(prompt, options);
};
return session;
};
const sessions: AgentSession[] = [];
const probes: Array<(() => Promise<boolean>) | undefined> = [];
const closeEntered = deferred<void>();
let closeGate: Promise<void> | undefined;
let closes = 0;
const client = new (class extends TestAgentClient {
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
const session = await super.resumeSession(handle, config);
session.describePersistence = () => handle;
return recordPrompts(session);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const index = sessions.length;
const session: AgentSession = await super.createSession(config);
session.canStopRuntime = async () => probes[index]?.() ?? false;
session.close = async () => {
closes++;
closeEntered.resolve();
await closeGate;
};
sessions.push(session);
return recordPrompts(session);
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
onTestFinished(async () => {
closeGate = undefined;
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
});
return {
commands,
sweep: runtimeCleanup(manager),
manager,
storage,
sessions,
probes,
prompts,
closeEntered,
get closes() {
return closes;
},
holdClose: (gate: Promise<void>) => {
closeGate = gate;
},
create: (labels?: Record<string, string>) =>
manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
labels,
}),
later: (minutes: number) => new Date(Date.now() + minutes * 60_000),
};
}
test("idle cleanup rechecks new prompt activity after an asynchronous safety probe", async () => {
const h = idleCleanupHarness();
const agent = await h.create();
const entered = deferred<void>();
const probe = deferred<boolean>();
h.probes[0] = () => {
entered.resolve();
return probe.promise;
};
const sweep = h.sweep({ now: h.later(61) });
await entered.promise;
await h.manager.runAgent(agent.id, "arrived during safety query");
probe.resolve(true);
expect((await sweep).collected).toEqual([]);
expect(h.closes).toBe(0);
});
test("idle cleanup retains a parent whose idle managed child owns background work", async () => {
const h = idleCleanupHarness();
const parent = await h.create();
await h.create({ [PARENT_AGENT_ID_LABEL]: parent.id });
h.probes[0] = async () => true;
h.probes[1] = async () => false;
expect((await h.sweep({ now: h.later(61) })).collected).toEqual([]);
expect(h.closes).toBe(0);
});
test("idle cleanup rechecks queued deliveries admitted during the safety probe", async () => {
const h = idleCleanupHarness();
await h.create();
let queued = false;
h.probes[0] = async () => {
queued = true;
return true;
};
expect((await h.sweep({ now: h.later(61), isProtected: () => queued })).collected).toEqual([]);
expect(h.closes).toBe(0);
});
test("a prompt racing idle cleanup waits for close and resumes exactly once", async () => {
const h = idleCleanupHarness();
const agent = await h.create();
h.probes[0] = async () => true;
const gate = deferred<void>();
h.holdClose(gate.promise);
const sweep = h.sweep({ now: h.later(61) });
await h.closeEntered.promise;
const run = h.manager.runAgent(agent.id, "arrived while closing");
gate.resolve();
expect((await sweep).collected).toHaveLength(1);
const result = await run;
expect(result.sessionId).toBe(agent.persistence?.sessionId);
expect(h.closes).toBe(1);
expect(h.prompts).toEqual(["arrived while closing"]);
});
test("an out-of-band command racing idle cleanup waits for the resumed runtime", async () => {
const h = idleCleanupHarness();
const agent = await h.create();
h.probes[0] = async () => true;
const gate = deferred<void>();
h.holdClose(gate.promise);
const sweep = h.sweep({ now: h.later(61) });
await h.closeEntered.promise;
const command = startAgentRun(h.manager, agent.id, "/maintenance", logger);
await new Promise<void>((resolve) => setImmediate(resolve));
const deliveredWhileClosing = [...h.commands];
gate.resolve();
await sweep;
expect(deliveredWhileClosing).toEqual([]);
expect(await command).toEqual({ disposition: "out_of_band" });
expect(h.commands).toEqual(["/maintenance"]);
expect(h.prompts).toEqual([]);
});
@@ -1,3 +1,5 @@
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { projectTimelineRows } from "./timeline-projection.js";
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
@@ -758,6 +760,8 @@ export class AgentManager {
private readonly agentRegistrationTasks = new Set<Promise<void>>();
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
private readonly collectingRuntimes = new Set<string>();
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
@@ -1747,6 +1751,64 @@ export class AgentManager {
});
}
/** Capture runtime capabilities without handing lifecycle ownership to the collector. */
getRuntimeCleanupCandidates(): RuntimeCleanupCandidate[] {
if (!this.registry) return [];
const candidates: RuntimeCleanupCandidate[] = [];
for (const agent of this.agents.values()) {
const updatedAt = agent.updatedAt;
let identity = this.runtimeCleanupIdentities.get(agent.session);
if (!identity) {
identity = Object.freeze({});
this.runtimeCleanupIdentities.set(agent.session, identity);
}
const isEligible = () =>
this.agents.get(agent.id) === agent &&
agent.updatedAt === updatedAt &&
this.runtimeHasNoPendingWork(agent);
candidates.push({
identity,
agentId: agent.id,
parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? null,
lastActivityAt: updatedAt.getTime(),
isEligible,
canStopRuntime: agent.session.canStopRuntime?.bind(agent.session),
closeIfEligible: async (revalidate) => {
if (!agent.persistence?.sessionId) return false;
// Block prompt admission before entering the close lane. A skipped close also
// releases this barrier, so the waiting prompt keeps the existing runtime.
this.collectingRuntimes.add(agent.id);
try {
return await this.runLifecycleMutation(agent.id, () =>
this.closeAgentRuntime(agent.id, () => isEligible() && revalidate()),
);
} finally {
this.collectingRuntimes.delete(agent.id);
}
},
});
}
return candidates;
}
private runtimeHasNoPendingWork(agent: LiveManagedAgent): boolean {
return (
agent.lifecycle === "idle" &&
!agent.internal &&
agent.activeForegroundTurnId === null &&
!this.runs.hasRun(agent.id) &&
!agent.pendingReplacement &&
agent.pendingPermissions.size === 0 &&
agent.inFlightPermissionResponses.size === 0 &&
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
!this.foregroundMutationTails.has(agent.id) &&
!this.sessionEventTails.has(agent.id) &&
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
);
}
closeAgent(agentId: string): Promise<void> {
const existing = this.inFlightAgentCloses.get(agentId);
if (existing) {
@@ -1767,7 +1829,7 @@ export class AgentManager {
return close;
}
private async closeAgentRuntime(agentId: string): Promise<void> {
private async closeAgentRuntime(agentId: string, canClose?: () => boolean): Promise<boolean> {
const agent = this.requireAgent(agentId);
this.logger.trace(
{
@@ -1782,6 +1844,7 @@ export class AgentManager {
"agent.manager.close.start",
);
await this.drainSessionEvents(agentId);
if (canClose && !canClose()) return false;
// Retain ownership until shutdown succeeds. A failed close may still own a
// native writer, so publishing a resumable closed snapshot would orphan it.
await agent.session.close();
@@ -1807,6 +1870,7 @@ export class AgentManager {
if (persistError !== undefined) {
throw persistError;
}
return true;
}
private cancelRunningProviderSubagents(parentAgentId: string): void {
@@ -2459,7 +2523,19 @@ export class AgentManager {
* emitted by the handler flow through dispatchStream so they persist and
* broadcast like normal timeline events.
*/
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
async tryRunOutOfBand(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<boolean> {
if (this.collectingRuntimes.has(agentId) || !this.agents.has(agentId)) {
await this.waitForAgentClose(agentId);
await ensureAgentLoaded(agentId, {
agentManager: this,
agentStorage: this.requireRegistry(),
logger: this.logger,
});
}
const agent = this.requireSessionAgent(agentId);
// Providers parse slash commands from the raw text, so a command another agent sent
// must reach them without its sender envelope.
@@ -2470,6 +2546,7 @@ export class AgentManager {
if (!handler) {
return false;
}
this.touchUpdatedAt(agent);
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted");
this.emitState(agent);
@@ -2489,7 +2566,7 @@ export class AgentManager {
}
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
};
void (async () => {
void this.runForegroundMutation(agentId, async () => {
try {
await handler.run({ emit: dispatch });
} catch (error) {
@@ -2500,7 +2577,7 @@ export class AgentManager {
item: { type: "assistant_message", text: `[Error] ${text}` },
});
}
})();
});
return true;
}
@@ -2584,11 +2661,27 @@ export class AgentManager {
}
}
private async *streamAfterRuntimeCollection(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
await this.waitForAgentClose(agentId);
await ensureAgentLoaded(agentId, {
agentManager: this,
agentStorage: this.requireRegistry(),
logger: this.logger,
});
yield* this.streamAgent(agentId, prompt, options);
}
streamAgent(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry))
return this.streamAfterRuntimeCollection(agentId, prompt, options);
const existingAgent = this.requireSessionAgent(agentId);
this.logger.trace(
{
@@ -283,3 +283,33 @@ test("batches retain each sender's attachment context and image order", async ()
)?.map((message) => message.source?.agentId),
).toEqual(["one", "two"]);
});
test("runtime safety sees queued messages until provider dispatch settles", async () => {
const clock = createClock();
let release!: () => void;
const dispatched = new Promise<void>((resolve) => {
release = resolve;
});
const delivery = createAgentMessageDelivery({
schedule: clock.schedule,
dispatch: async () => {
await dispatched;
return "steered";
},
onError: (error) => {
throw error;
},
});
expect(delivery.hasPending("parent")).toBe(false);
delivery.enqueue({
recipientId: "parent",
prompt: "finished",
source: { kind: "agent-message", agentId: "child" },
});
expect(delivery.hasPending("parent")).toBe(true);
clock.advance(2000);
expect(delivery.hasPending("parent")).toBe(true);
release();
await delivery.close();
expect(delivery.hasPending("parent")).toBe(false);
});
@@ -43,6 +43,8 @@ interface Dependencies {
}
export interface AgentMessageDelivery {
enqueue(message: Message): Receipt;
/** Queued messages and provider dispatches still owned by this recipient. */
hasPending(recipientId: string): boolean;
/** Stop admission, flush pending messages, and await provider dispatch acknowledgments. */
close(): Promise<void>;
/** Existing completion waits include agent messages admitted before the wait. */
@@ -138,6 +140,7 @@ export function createAgentMessageDelivery(dependencies: Dependencies): AgentMes
}
return {
hasPending: (recipientId) => pending.has(recipientId) || dispatching.has(recipientId),
enqueue(message) {
if (closed) throw new Error("Agent message delivery is closed");
let resolve!: Admitted["resolve"];
@@ -110,7 +110,7 @@ export async function startAgentRun(
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
// in-flight turn — replaceAgentRun would interrupt the running turn. The
// intercept lives at this layer so it covers every prompt entrypoint.
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
if (await agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
options?.onDispatch?.("out_of_band");
return { disposition: "out_of_band" };
}
@@ -694,6 +694,10 @@ export interface AgentSession {
* still uncertain.
*/
interrupt(): Promise<void>;
/** True only when releasing this runtime loses no background work or pending replies.
* An optional additional veto: missing methods use daemon baseline eligibility;
* implemented checks that reject or return false keep the runtime resident. */
canStopRuntime?(): Promise<boolean>;
/** Release live runtime resources without archiving or deleting the durable native session. */
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
@@ -1,3 +1,4 @@
import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js";
import { HostObservationError } from "../host-operations/index.js";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -412,3 +413,87 @@ test("shutdown during observation setup owns rejection of the already-canceled n
expect(await state.store.list()).toHaveLength(1);
expect(state.delivery.prompts).toEqual([]);
});
test("remote finish intent protects its caller through registration, observation and delivery", async () => {
const s = await scenario();
let time = 61 * 60_000;
let collected = false;
const runtime = {
identity: {},
agentId: "parent",
parentAgentId: undefined,
provider: "test",
sessionId: "parent",
lastActivityAt: 0,
isEligible: () => !collected,
closeIfEligible: async (revalidate: () => boolean) => {
if (!revalidate()) return false;
collected = true;
return true;
},
};
const cleanup = createIdleRuntimeCleanup({
enabled: true,
logger: createTestLogger(),
now: () => time,
runtimes: () => (collected ? [] : [runtime]),
isProtected: (agentId) => s.service.hasPendingForCaller(agentId),
schedule: () => () => {},
});
const savedPut = s.store.put.bind(s.store);
const writing = deferred<void>();
s.store.put = async (record) => {
await writing.promise;
await savedPut(record);
};
const registration = s.service.register({ ...intent, serverId: "remote" }, true);
const protectedDuringRegistration = s.service.hasPendingForCaller?.("parent") ?? false;
writing.resolve();
const accepted = await registration;
expect(protectedDuringRegistration).toBe(true);
expect((await cleanup.sweep()).collected).toEqual([]);
expect(s.service.hasPendingForCaller("parent")).toBe(true);
accepted.accept();
await until(() => s.observation.waits.length === 1);
expect(s.service.hasPendingForCaller("parent")).toBe(true);
s.observation.waits[0]!.result.resolve(outcome());
await until(() => s.delivery.prompts.length === 1);
expect(s.service.hasPendingForCaller("parent")).toBe(true);
s.delivery.acknowledgments[0]!.resolve("steered");
await until(() => !s.service.hasPendingForCaller("parent"));
expect(await s.store.list()).toEqual([]);
time = 62 * 60_000;
expect((await cleanup.sweep()).collected).toEqual([]);
time = 122 * 60_000;
expect((await cleanup.sweep()).collected).toHaveLength(1);
await cleanup.close();
});
test("finish recovery protects callers before durable intent has loaded", async () => {
const s = await scenario();
await s.service.register({ ...intent, serverId: "remote" });
await s.service.close();
const loading = deferred<void>();
const recovered = createFinishSubscriptions({
store: {
...s.store,
list: async () => {
await loading.promise;
return s.store.list();
},
},
messages: () => s.delivery,
open: s.observation.open.bind(s.observation),
callerIsActive: async () => true,
serverId: "origin",
logger: createTestLogger(),
});
services.push(recovered);
expect(recovered.hasPendingForCaller("parent")).toBe(true);
const starting = recovered.start();
expect(recovered.hasPendingForCaller("parent")).toBe(true);
loading.resolve();
await starting;
expect(recovered.hasPendingForCaller("parent")).toBe(true);
expect(recovered.hasPendingForCaller("unrelated")).toBe(false);
});
@@ -49,6 +49,8 @@ export interface FinishRegistration {
}
export interface FinishSubscriptions {
start(): Promise<void>;
/** Pending notification intent, including registration, recovery and unacknowledged delivery. */
hasPendingForCaller(agentId: string): boolean;
register(
input: Omit<FinishSubscription, "id">,
pendingDispatch?: boolean,
@@ -72,6 +74,8 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
const tasks = new Set<Promise<void>>();
const watches = new Map<string, Set<Watch>>();
let closed = false;
let recovering = true;
const registering = new Map<string, number>();
let mutations = Promise.resolve();
function serialize<T>(action: () => Promise<T>): Promise<T> {
const result = mutations.then(action);
@@ -300,6 +304,13 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
}
return {
hasPendingForCaller(agentId) {
if (recovering || registering.has(agentId)) return true;
for (const group of watches.values()) {
for (const watch of group) if (watch.records[0]!.callerAgentId === agentId) return true;
}
return false;
},
start: () =>
serialize(async () => {
const groups = new Map<string, FinishSubscription[]>();
@@ -309,9 +320,11 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
groups.set(key(record), records);
}
for (const records of groups.values()) attach(records, false);
recovering = false;
}),
register: (input, pendingDispatch = false) =>
serialize(async () => {
register: (input, pendingDispatch = false) => {
registering.set(input.callerAgentId, (registering.get(input.callerAgentId) ?? 0) + 1);
return serialize(async () => {
if (closed) throw new Error("Finish subscriptions are closed");
const record: FinishSubscription = {
...input,
@@ -337,13 +350,18 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
},
reject: () =>
serialize(async () => {
stop(watch, actualGroup);
await remove(watch);
stop(watch, actualGroup);
}),
};
if (!pendingDispatch) registration.accept();
return registration;
}),
}).finally(() => {
const remaining = registering.get(input.callerAgentId)! - 1;
if (remaining) registering.set(input.callerAgentId, remaining);
else registering.delete(input.callerAgentId);
});
},
async close() {
closed = true;
await mutations;
@@ -0,0 +1,196 @@
import { expect, onTestFinished, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { createIdleRuntimeCleanup, type RuntimeCleanupCandidate } from "./index.js";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((yes) => {
resolve = yes;
});
return { promise, resolve };
}
class Runtime {
readonly identity = {};
busy = false;
closed = false;
resumable = true;
activity = 0;
parentAgentId: string | undefined;
probe: (() => Promise<boolean>) | undefined;
constructor(readonly id = "agent") {}
capture(): RuntimeCleanupCandidate {
const activity = this.activity;
return {
identity: this.identity,
agentId: this.id,
parentAgentId: this.parentAgentId,
provider: "test",
sessionId: this.resumable ? this.id : null,
lastActivityAt: activity,
isEligible: () => !this.closed && !this.busy && this.activity === activity,
canStopRuntime: this.probe,
closeIfEligible: async (eligible) => {
if (!eligible()) return false;
this.closed = true;
return true;
},
};
}
}
function scenario(enabled = true) {
const runtime = new Runtime();
const runtimes = [runtime];
let time = 0;
let protectedCaller = false;
let tick!: () => void;
const cleanup = createIdleRuntimeCleanup({
enabled,
logger: createTestLogger(),
now: () => time,
runtimes: () => runtimes.filter((item) => !item.closed).map((item) => item.capture()),
isProtected: () => protectedCaller,
schedule: (callback, interval) => {
expect(interval).toBe(60_000);
tick = callback;
return () => {};
},
});
onTestFinished(() => cleanup.close());
return {
runtime,
runtimes,
cleanup,
tick: () => tick(),
protect: (value: boolean) => {
protectedCaller = value;
},
sweep: (minutes: number) => {
time = minutes * 60_000;
return cleanup.sweep();
},
};
}
test("a runtime without a provider check is collected at exactly one hour", async () => {
const s = scenario();
expect((await s.sweep(59)).collected).toEqual([]);
expect((await s.sweep(60)).collected).toEqual([
{ agentId: "agent", provider: "test", sessionId: "agent" },
]);
});
test("activity restarts the single one-hour idle period", async () => {
const s = scenario();
await s.sweep(59);
s.runtime.activity = 59 * 60_000;
expect((await s.sweep(118)).collected).toEqual([]);
expect((await s.sweep(119)).collected).toHaveLength(1);
});
test.each(["foreground", "finish subscription"])(
"a positive provider check cannot override %s",
async (blocker) => {
const s = scenario();
let calls = 0;
s.runtime.probe = async () => {
calls++;
return true;
};
if (blocker === "foreground") s.runtime.busy = true;
else s.protect(true);
expect((await s.sweep(61)).collected).toEqual([]);
expect(calls).toBe(0);
s.runtime.busy = false;
s.protect(false);
expect((await s.sweep(62)).collected).toEqual([]);
expect((await s.sweep(121)).collected).toEqual([]);
expect((await s.sweep(122)).collected).toHaveLength(1);
},
);
test.each(["veto", "error"])(
"a supported provider %s retains the runtime until a fresh idle hour passes",
async (decision) => {
const s = scenario();
s.runtime.probe = async () => {
if (decision === "error") throw new Error("Probe failed");
return false;
};
const blocked = await s.sweep(61);
expect(blocked.collected).toEqual([]);
expect(blocked.failures).toHaveLength(decision === "error" ? 1 : 0);
await s.sweep(119);
s.runtime.probe = async () => true;
expect((await s.sweep(120)).collected).toEqual([]);
expect((await s.sweep(179)).collected).toEqual([]);
expect((await s.sweep(180)).collected).toHaveLength(1);
},
);
test("an idle managed descendant with provider-held work vetoes its parent", async () => {
const s = scenario();
const child = new Runtime("child");
child.parentAgentId = s.runtime.id;
child.probe = async () => false;
s.runtimes.push(child);
expect((await s.sweep(61)).collected).toEqual([]);
child.probe = async () => true;
expect((await s.sweep(62)).collected).toEqual([]);
expect((await s.sweep(122)).collected).toHaveLength(2);
});
test("new caller protection during an async provider probe prevents stale collection", async () => {
const s = scenario();
const probe = deferred<boolean>();
s.runtime.probe = () => probe.promise;
const collecting = s.sweep(61);
s.protect(true);
probe.resolve(true);
expect((await collecting).collected).toEqual([]);
expect(s.runtime.closed).toBe(false);
s.protect(false);
expect((await s.sweep(62)).collected).toEqual([]);
expect((await s.sweep(122)).collected).toHaveLength(1);
});
test("a supported provider timeout retains ownership even if it later returns true", async () => {
const s = scenario();
const probe = deferred<boolean>();
s.runtime.probe = () => probe.promise;
const result = await s.sweep(61);
expect(result.collected).toEqual([]);
expect(result.failures[0]?.error).toEqual(new Error("Runtime safety probe timed out for agent"));
probe.resolve(true);
expect((await s.sweep(62)).collected).toEqual([]);
expect(s.runtime.closed).toBe(false);
}, 15_000);
test("the host opt-out disables explicit sweeps and scheduled ticks", async () => {
const s = scenario(false);
let calls = 0;
s.runtime.probe = async () => {
calls++;
return true;
};
expect((await s.sweep(61)).collected).toEqual([]);
s.tick();
await s.cleanup.close();
expect(calls).toBe(0);
expect(s.runtime.closed).toBe(false);
});
test("a managed descendant without persistence still protects its parent while working", async () => {
const s = scenario();
const child = new Runtime("initializing-child");
child.parentAgentId = s.runtime.id;
child.resumable = false;
child.busy = true;
s.runtimes.push(child);
expect((await s.sweep(61)).collected).toEqual([]);
child.busy = false;
expect((await s.sweep(62)).collected).toEqual([]);
expect((await s.sweep(122)).collected).toEqual([
{ agentId: "agent", provider: "test", sessionId: "agent" },
]);
expect(child.closed).toBe(false);
});
@@ -0,0 +1,149 @@
import type { Logger } from "pino";
import { withTimeout } from "../../../utils/promise-timeout.js";
/** A captured runtime capability. Its lifecycle owner revalidates before releasing it. */
export interface RuntimeCleanupCandidate {
readonly identity: object;
readonly agentId: string;
readonly parentAgentId: string | undefined;
readonly provider: string;
readonly sessionId: string | null;
readonly lastActivityAt: number;
isEligible(): boolean;
canStopRuntime?(): Promise<boolean>;
closeIfEligible(revalidate: () => boolean): Promise<boolean>;
}
interface Options {
enabled: boolean;
runtimes(): readonly RuntimeCleanupCandidate[];
isProtected?(agentId: string): boolean;
logger: Pick<Logger, "debug" | "info" | "warn">;
now?: () => number;
schedule?: (tick: () => void, intervalMs: number) => () => void;
}
interface CollectionEntry {
agentId: string;
provider: string;
sessionId: string;
}
interface CollectionResult {
collected: CollectionEntry[];
failures: Array<{ agentId: string; error: unknown }>;
}
const IDLE_PERIOD_MS = 60 * 60_000;
/** Owns idle clocks, baseline eligibility, optional provider vetoes and the sweep lifetime. */
export function createIdleRuntimeCleanup(options: Options) {
const protectedAt = new WeakMap<object, { at: number; protected: boolean }>();
let collection: Promise<CollectionResult> | null = null;
let stopped = false;
const isProtected = options.isProtected ?? (() => false);
const now = options.now ?? Date.now;
function familyOf(root: RuntimeCleanupCandidate, runtimes: readonly RuntimeCleanupCandidate[]) {
const family = new Map([[root.agentId, root]]);
for (const parent of family.values()) {
for (const child of runtimes) {
if (child.parentAgentId === parent.agentId) family.set(child.agentId, child);
}
}
return [...family.values()];
}
function baselineAllows(family: readonly RuntimeCleanupCandidate[]) {
return family.every((runtime) => runtime.isEligible() && !isProtected(runtime.agentId));
}
async function providersAllow(family: readonly RuntimeCleanupCandidate[]) {
for (const runtime of family) {
if (!runtime.canStopRuntime) continue;
const allowed = await withTimeout(
runtime.canStopRuntime(),
10_000,
`Runtime safety probe timed out for ${runtime.agentId}`,
);
if (allowed !== true) return false;
}
return true;
}
async function collect(): Promise<CollectionResult> {
const result: CollectionResult = { collected: [], failures: [] };
if (!options.enabled || stopped) return result;
const timestamp = now();
const runtimes = options.runtimes();
for (const runtime of runtimes) {
if (!runtime.sessionId) continue;
const family = familyOf(runtime, runtimes);
try {
if (!baselineAllows(family) || !(await providersAllow(family))) {
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
options.logger.debug(
{ agentId: runtime.agentId },
"Idle runtime retained by a baseline or provider veto",
);
continue;
}
const previous = protectedAt.get(runtime.identity);
if (previous?.protected)
protectedAt.set(runtime.identity, { at: timestamp, protected: false });
const idleSince = Math.max(
runtime.lastActivityAt,
protectedAt.get(runtime.identity)?.at ?? 0,
);
if (timestamp - idleSince < IDLE_PERIOD_MS) continue;
const revalidate = () => {
const currentFamily = familyOf(runtime, options.runtimes());
return (
currentFamily.length === family.length &&
currentFamily.every((current) =>
family.some((captured) => captured.identity === current.identity),
) &&
baselineAllows(family)
);
};
if (await runtime.closeIfEligible(revalidate)) {
const entry = {
agentId: runtime.agentId,
provider: runtime.provider,
sessionId: runtime.sessionId,
};
result.collected.push(entry);
options.logger.info(entry, "Collected idle agent runtime");
} else {
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
}
} catch (error) {
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
result.failures.push({ agentId: runtime.agentId, error });
options.logger.warn(
{ agentId: runtime.agentId, err: error },
"Idle runtime cleanup failed",
);
}
}
return result;
}
function sweep(): Promise<CollectionResult> {
if (collection) return collection;
collection = collect().finally(() => {
collection = null;
});
return collection;
}
const schedule =
options.schedule ??
((tick, intervalMs) => {
const timer = setInterval(tick, intervalMs);
timer.unref();
return () => clearInterval(timer);
});
const cancel = schedule(() => {
void sweep().catch((error) => options.logger.warn({ err: error }, "Idle runtime sweep failed"));
}, 60_000);
return {
sweep,
async close() {
stopped = true;
cancel();
await collection;
},
};
}
@@ -32,7 +32,7 @@ class FakePermissionAgentManager {
return this.permissionResult;
}
tryRunOutOfBand(): boolean {
async tryRunOutOfBand(): Promise<boolean> {
return this.outOfBandHandled;
}
@@ -1,3 +1,8 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AgentStorage } from "./agent-storage.js";
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
import { relative } from "node:path";
import type {
ProviderConnection,
@@ -1234,3 +1239,85 @@ test("config-required plugin membership is independent of enablement and other p
await plugins.shutdown();
}
});
test.each([true, false, "error", "wrong-session", "missing"] as const)(
"plugin runtime safety query: %s",
async (answer) => {
const harness = createProviderHarness({
capabilities:
answer === "missing" ? CAPABILITIES : [...CAPABILITIES, "session.can_stop_runtime"],
handleInput: async (input, emit) => {
if (input.type !== "session.can_stop_runtime") return false;
if (answer === "error")
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "Probe failed" },
});
else
emit({
type: "session.can_stop_runtime",
requestId: input.requestId,
sessionId: answer === "wrong-session" ? "child-1" : input.sessionId,
canStop: answer === true,
});
return true;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" });
try {
if (answer === "error" || answer === "wrong-session") {
await expect(session.canStopRuntime!()).rejects.toThrow(
answer === "error" ? "Probe failed" : "another session",
);
} else if (answer === "missing") expect(session.canStopRuntime).toBeUndefined();
else expect(await session.canStopRuntime!()).toBe(answer === true);
const requests = harness.inputs.filter((input) => input.type === "session.can_stop_runtime");
expect(requests).toHaveLength(answer === "missing" ? 0 : 1);
if (answer !== "missing")
expect(requests[0]).toMatchObject({
sessionId: harness.inputs.find((input) => input.type === "session.open")?.sessionId,
});
} finally {
await session.close();
await registry.shutdown();
}
},
);
test("plugin without a runtime safety capability uses baseline collection without a query", async () => {
const home = await mkdtemp(join(tmpdir(), "plugin-baseline-cleanup-"));
const logger = createTestLogger();
const storage = new AgentStorage(join(home, "agents"), logger);
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([harness.registration]);
const manager = new AgentManager({ clients: registry.clients(), registry: storage, logger });
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: home },
undefined,
{ workspaceId: undefined },
);
const cleanup = createIdleRuntimeCleanup({
enabled: true,
logger,
runtimes: () => manager.getRuntimeCleanupCandidates(),
now: () => agent.updatedAt.getTime() + 60 * 60_000,
schedule: () => () => {},
});
try {
expect(agent.session?.canStopRuntime).toBeUndefined();
expect((await cleanup.sweep()).collected).toHaveLength(1);
expect(manager.getAgent(agent.id)).toBeNull();
expect(harness.inputs.filter((input) => input.type === "session.can_stop_runtime")).toEqual([]);
} finally {
await cleanup.close();
await Promise.all(manager.listAgents().map((item) => manager.closeAgent(item.id)));
await registry.shutdown();
await storage.flush();
await rm(home, { recursive: true, force: true });
}
});
@@ -113,9 +113,15 @@ function providerError(error: ProviderError): Error {
function isProviderRequestReply(
event: ProviderEvent,
): event is Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }> {
): event is Extract<
ProviderEvent,
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
> {
return (
event.type === "request.completed" || event.type === "catalog" || event.type === "sessions"
event.type === "request.completed" ||
event.type === "catalog" ||
event.type === "sessions" ||
event.type === "session.can_stop_runtime"
);
}
@@ -564,10 +570,21 @@ class ProviderRuntime {
}
private finishRequest(
event: Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }>,
event: Extract<
ProviderEvent,
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
>,
): void {
const request = this.requests.get(event.requestId);
if (!request) return;
if (
event.type === "session.can_stop_runtime" &&
this.providerSessions.get(event.sessionId)?.id !== request.sessionId
) {
this.requests.delete(event.requestId);
request.reject(new Error("Runtime safety response belongs to another session"));
return;
}
this.requests.delete(event.requestId);
request.resolve(event);
}
@@ -635,6 +652,27 @@ class ProviderRuntimeSession {
}
}
async canStopRuntime(): Promise<boolean> {
if (
this.terminal ||
this.prompts.size ||
this.activeTurnIds.size ||
!this.capabilities.includes("session.can_stop_runtime")
)
return false;
const event = await this.runtime.complete(
{
type: "session.can_stop_runtime",
requestId: randomUUID(),
sessionId: this.providerSessionId,
},
AbortSignal.timeout(10_000),
);
if (event.type !== "session.can_stop_runtime")
throw new Error("Invalid runtime safety response");
return event.canStop;
}
async configure(changes: ProviderConfigChanges): Promise<void> {
const input: Extract<ProviderInput, { type: "session.configure" }> = {
type: "session.configure",
@@ -1201,6 +1239,11 @@ class PluginAgentSession implements AgentSession {
for (const event of this.history) yield event;
}
get canStopRuntime(): (() => Promise<boolean>) | undefined {
if (!this.bridge.negotiatedCapabilities.includes("session.can_stop_runtime")) return undefined;
return () => this.bridge.canStopRuntime();
}
async getRuntimeInfo() {
return {
provider: this.provider,
@@ -469,6 +469,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(),
close: () => inner.close(),
canStopRuntime: inner.canStopRuntime?.bind(inner),
listCommands: inner.listCommands?.bind(inner),
setModel: inner.setModel?.bind(inner),
setThinkingOption: inner.setThinkingOption?.bind(inner),
@@ -1,3 +1,5 @@
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -706,3 +708,137 @@ describe("background Claude subagents", () => {
},
);
});
test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => {
const safety = new ClaudeRuntimeSafety();
const observe = (message: unknown) => safety.observe(message as SDKMessage);
observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" });
expect(safety.canStopRuntime()).toBe(true);
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [
{ task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" },
{ task_id: "monitor", task_type: "local_bash", description: "watch build" },
{ task_id: "native-child", task_type: "local_agent", description: "research" },
],
});
expect(safety.canStopRuntime()).toBe(false);
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
expect(safety.canStopRuntime()).toBe(false);
for (const task_id of ["quiet-shell", "monitor"])
observe({ type: "system", subtype: "task_notification", task_id });
expect(safety.canStopRuntime()).toBe(false);
observe({ type: "system", subtype: "task_notification", task_id: "native-child" });
expect(safety.canStopRuntime()).toBe(true);
safety.reset();
observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" });
expect(safety.canStopRuntime()).toBe(false);
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
expect(safety.canStopRuntime()).toBe(true);
});
test("Claude session runtime safety observes pumped background work and autonomous replies", async () => {
let wake: (() => void) | undefined;
const channel = { closed: false };
const queue: Array<{ message: SDKMessage; consumed(): void }> = [];
const pump = (async function* () {
while (!channel.closed) {
const entry = queue.shift();
if (!entry) {
await new Promise<void>((resolve) => {
wake = resolve;
});
continue;
}
yield entry.message;
entry.consumed();
}
})();
const push = (message: unknown) =>
new Promise<void>((consumed) => {
queue.push({ message: message as SDKMessage, consumed });
wake?.();
});
const end = () => {
channel.closed = true;
wake?.();
};
let nativeInput!: AsyncIterator<SDKUserMessage>;
let connected!: () => void;
const connection = new Promise<void>((resolve) => {
connected = resolve;
});
// The SDK Query is injected through the production adapter's existing launch port.
const query = {
next: () => pump.next(),
return: async () => {
end();
return pump.return(undefined);
},
close: end,
interrupt: async () => {},
setPermissionMode: async () => {},
setModel: async () => {},
supportedModels: async () => [],
supportedCommands: async () => [],
[Symbol.asyncIterator]() {
return this;
},
} as unknown as Query;
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude/bin",
queryFactory: ({ prompt }) => {
if (typeof prompt === "string") throw new Error("Expected streaming input");
nativeInput = prompt[Symbol.asyncIterator]();
connected();
return query;
},
}).createSession({ provider: "claude", cwd: process.cwd() });
const init = {
type: "system",
subtype: "init",
session_id: "runtime-safety",
claude_code_version: "2.1.292",
permissionMode: "default",
};
const result = {
type: "result",
subtype: "success",
usage: { input_tokens: 1, output_tokens: 1 },
total_cost_usd: 0,
};
try {
await session.startTurn("start background work");
await connection;
const input = await nativeInput.next();
await push({ type: "command_lifecycle", command_uuid: input.value.uuid, state: "started" });
await push(init);
await push(result);
expect(await session.canStopRuntime!()).toBe(true);
await push({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "monitor", task_type: "local_bash", description: "quiet monitor" }],
});
expect(await session.canStopRuntime!()).toBe(false);
await push({ type: "system", subtype: "background_tasks_changed", tasks: [] });
expect(await session.canStopRuntime!()).toBe(false);
await push({
type: "system",
subtype: "task_notification",
task_id: "monitor",
status: "completed",
output: "ready",
summary: "ready",
});
// Completion can wake Claude autonomously after the foreground turn has ended.
await push(init);
expect(await session.canStopRuntime!()).toBe(false);
await push(result);
expect(await session.canStopRuntime!()).toBe(true);
} finally {
await session.close();
}
});
@@ -7,6 +7,7 @@ import type { AgentTimelineItem } from "../../agent-sdk-types.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { projectTimelineRows } from "../../timeline-projection.js";
describe("Claude session request journal replay", () => {
let root: string;
@@ -137,4 +138,100 @@ describe("Claude session request journal replay", () => {
}),
]);
});
test.each(["remove", "user"])(
"replays a background completion once across queue enqueue and %s records",
async (delivery) => {
const content =
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>";
const items = await replay([
{ type: "queue-operation", operation: "enqueue", content },
delivery === "remove"
? { type: "queue-operation", operation: "remove", content }
: {
type: "user",
uuid: "notification-delivery",
origin: { kind: "task-notification" },
message: { role: "user", content },
},
]);
const projected = projectTimelineRows({
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
mode: "projected",
});
expect(projected.map((row) => row.item)).toEqual([
expect.objectContaining({
type: "tool_call",
name: "task_notification",
status: "completed",
metadata: expect.objectContaining({ taskId: "bg-1" }),
}),
]);
},
);
test("restores each Monitor event and completion of one task once", async () => {
const notifications = ["1", "2", "3", "4"].map(
(event) =>
`<task-notification>\n<task-id>monitor-1</task-id>\n<summary>Monitor event</summary>\n<event>${event}</event>\n</task-notification>`,
);
notifications.push(
"<task-notification>\n<task-id>monitor-1</task-id>\n<status>completed</status>\n<summary>Monitor stream ended</summary>\n</task-notification>",
);
const items = await replay(
notifications.flatMap((content, index) => [
{ type: "queue-operation", operation: "enqueue", content },
{ type: "user", uuid: `delivery-${index}`, message: { role: "user", content } },
]),
);
const projected = projectTimelineRows({
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
mode: "projected",
});
expect(projected.map(({ item }) => item.type === "tool_call" && item.detail)).toEqual(
notifications.map((text, index) =>
expect.objectContaining({
label: index === 4 ? "Monitor stream ended" : "Monitor event",
text,
}),
),
);
});
test.each(["user", "remove", "system"])(
"preserves identical notifications from separate occurrences with %s deliveries",
async (delivery) => {
const content =
"<task-notification>\n<task-id>monitor-repeat</task-id>\n<status>completed</status>\n<summary>Monitor event</summary>\n</task-notification>";
const queued = [1, 2].map(() => ({ type: "queue-operation", operation: "enqueue", content }));
const delivered = [1, 2].map((index) => {
if (delivery === "user") {
return { type: "user", uuid: `repeat-${index}`, message: { role: "user", content } };
}
if (delivery === "remove") {
return { type: "queue-operation", operation: "remove", content };
}
return {
type: "system",
subtype: "task_notification",
uuid: `repeat-${index}`,
task_id: "monitor-repeat",
status: "completed",
summary: "Monitor event",
};
});
const items = await replay([
...queued,
...delivered,
{ type: "queue-operation", operation: "enqueue", content },
]);
const projected = projectTimelineRows({
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
mode: "projected",
});
expect(projected.map(({ item }) => item.type === "tool_call" && item.detail?.label)).toEqual([
"Monitor event",
"Monitor event",
"Monitor event",
]);
},
);
});
@@ -834,7 +834,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
.filter((item) => item.type === "tool_call" && item.name === "task_notification"),
).toEqual([
expect.objectContaining({
callId: "task_notification_workflow-notification",
callId: expect.stringMatching(/^task_notification_[a-f0-9]+_1$/),
status: "completed",
}),
]);
@@ -1,3 +1,4 @@
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import { validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -2255,6 +2256,8 @@ class ClaudeAgentSession implements AgentSession {
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
private query: Query | null = null;
private readonly runtimeSafety = new ClaudeRuntimeSafety();
private processingSdkMessages = 0;
private readonly harnessEnvironment: Record<string, string>;
private readonly usageSessionKey = randomUUID();
private childProcess: ChildProcess | null = null;
@@ -2930,6 +2933,18 @@ class ClaudeAgentSession implements AgentSession {
return this.persistence;
}
async canStopRuntime(): Promise<boolean> {
return (
!this.activeForegroundTurnId &&
!this.autonomousTurn &&
!this.mainTurnInFlight &&
this.unstartedMessageUuids.size === 0 &&
this.pendingPermissions.size === 0 &&
this.processingSdkMessages === 0 &&
this.runtimeSafety.canStopRuntime()
);
}
async close(): Promise<void> {
this.logger.trace(
{
@@ -3423,6 +3438,7 @@ class ClaudeAgentSession implements AgentSession {
const options = await this.buildOptions(launchMode);
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
this.runtimeSafety.reset();
// A fresh Claude process has no turn of its own in flight.
this.mainTurnInFlight = false;
this.query = claudeQuery(
@@ -4078,13 +4094,17 @@ class ClaudeAgentSession implements AgentSession {
);
};
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
logRawMessage(message);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) {
return true;
this.processingSdkMessages++;
try {
this.runtimeSafety.observe(message);
logRawMessage(message);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
await this.routeSdkMessageFromPump(message);
return false;
} finally {
this.processingSdkMessages--;
}
await this.routeSdkMessageFromPump(message);
return false;
};
const drainActiveQuery = async (): Promise<boolean> => {
for await (const message of activeQuery) {
@@ -6313,6 +6333,7 @@ export function convertClaudeHistoryEntry(
const userTaskNotificationItem = mapTaskNotificationUserContentToToolCall({
content,
messageId: userMessageId,
notificationId: typeof entry.notificationId === "string" ? entry.notificationId : undefined,
});
if (userTaskNotificationItem) {
return [userTaskNotificationItem];
@@ -0,0 +1,42 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { parseClaudeCodeVersion } from "./model-manifest.js";
/** Process-local background ownership. Completion delivery can follow an empty roster. */
export class ClaudeRuntimeSafety {
private supported = false;
private liveTasks = new Set<string>();
private readonly awaitingNotifications = new Set<string>();
reset(): void {
this.supported = false;
this.liveTasks.clear();
this.awaitingNotifications.clear();
}
observe(message: SDKMessage): void {
if (message.type !== "system") return;
if (message.subtype === "init") {
// 2.1.292 is a known roster-capable release. Older binaries can demonstrate
// support by sending a roster; a fresh process starts with an empty roster.
const version = parseClaudeCodeVersion(message.claude_code_version ?? "");
if (
version &&
(version[0] > 2 ||
(version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292))))
)
this.supported = true;
}
if (message.subtype === "background_tasks_changed") {
this.supported = true;
this.liveTasks = new Set(message.tasks.map((task) => task.task_id));
for (const id of this.liveTasks) this.awaitingNotifications.add(id);
}
if (message.subtype === "task_notification") {
this.awaitingNotifications.delete(message.task_id);
}
}
canStopRuntime(): boolean {
return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0;
}
}
@@ -97,4 +97,17 @@ describe("task-notification-tool-call", () => {
expect(item).toBeNull();
});
it("keeps notifications without task IDs distinct by their message IDs", () => {
const items = ["message-1", "message-2"].map((messageId) =>
mapTaskNotificationUserContentToToolCall({
content: "<task-notification><status>completed</status></task-notification>",
messageId,
}),
);
expect(items.map((item) => item?.callId)).toEqual([
"task_notification_message-1",
"task_notification_message-2",
]);
});
});
@@ -12,6 +12,7 @@ const OptionalNonEmptyTrimmedStringSchema = z.preprocess(
);
const TaskNotificationEnvelopeSchema = z.object({
notificationId: z.string().optional(),
messageId: z.string().nullable(),
taskId: z.string().nullable(),
toolUseId: z.string().nullable(),
@@ -35,7 +36,9 @@ const TaskNotificationUserContentSchema = z.union([
const TaskNotificationHistoryRecordSchema = z
.object({
notificationId: z.string().optional(),
type: z.string().optional(),
operation: z.string().optional(),
subtype: z.string().optional(),
uuid: z.string().optional(),
message_id: z.string().optional(),
@@ -52,6 +55,7 @@ const TaskNotificationHistoryRecordSchema = z
const MapTaskNotificationUserContentToToolCallInputSchema = z.object({
content: z.unknown(),
messageId: z.string().optional().nullable(),
notificationId: z.string().optional(),
});
export type TaskNotificationEnvelope = z.infer<typeof TaskNotificationEnvelopeSchema>;
@@ -155,6 +159,7 @@ function parseTaskNotificationFromUserContent(
}
return TaskNotificationEnvelopeSchema.parse({
notificationId: parsedInput.data.notificationId,
messageId: toNonEmptyString(parsedInput.data.messageId),
taskId: readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }),
toolUseId:
@@ -186,6 +191,7 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
const rawText = toNonEmptyString(systemRecord.content);
return TaskNotificationEnvelopeSchema.parse({
notificationId: systemRecord.notificationId,
messageId: toNonEmptyString(systemRecord.uuid) ?? toNonEmptyString(systemRecord.message_id),
taskId:
toNonEmptyString(systemRecord.task_id) ??
@@ -212,25 +218,76 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
});
}
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
* are separate records of the same occurrence; each source counts repeated identical bodies
* independently so the second event is not merged with the first. */
export function* assignTaskNotificationReplayIds<T>(entries: Iterable<T>): Generator<T> {
const occurrences = new Map<string, Map<string, number>>();
for (const entry of entries) {
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
if (!record.success) {
yield entry;
continue;
}
const data = record.data;
const envelope =
data.type === "user"
? parseTaskNotificationFromUserContent({ content: data.message?.content })
: parseTaskNotificationFromSystemRecord(data);
if (!envelope?.taskId || !data.type) {
yield entry;
continue;
}
const signature = taskNotificationReplaySignature(envelope);
const source = data.type === "queue-operation" ? `queue-${data.operation}` : data.type;
const counts = occurrences.get(signature) ?? new Map<string, number>();
const occurrence = (counts.get(source) ?? 0) + 1;
counts.set(source, occurrence);
occurrences.set(signature, counts);
yield { ...entry, notificationId: `task_notification_${signature}_${occurrence}` };
}
}
function taskNotificationReplaySignature(envelope: TaskNotificationEnvelope): string {
// Known fields are canonical across XML user/queue records and structured system records.
// Keep the remaining body too: Monitor events share a summary but carry different <event>s.
const remainder = (envelope.rawText ?? "")
.replace(/<\/?task-notification>/gi, "")
.replace(
/<(task-id|tool-use-id|tool_use_id|status|summary|output-file|output_file)>[\s\S]*?<\/\1>/gi,
"",
)
.trim();
return createHash("sha1")
.update(
JSON.stringify([
envelope.taskId,
envelope.toolUseId,
envelope.status,
envelope.summary,
envelope.outputFile,
remainder,
]),
)
.digest("hex");
}
function normalizeTaskNotificationCallIdSegment(segment: string): string | null {
const normalized = segment.trim().replace(/[^a-zA-Z0-9._:-]+/g, "_");
return normalized.length > 0 ? normalized : null;
}
function buildTaskNotificationCallId(envelope: TaskNotificationEnvelope): string {
if (envelope.notificationId) return envelope.notificationId;
const messageSegment = envelope.messageId
? normalizeTaskNotificationCallIdSegment(envelope.messageId)
: null;
if (messageSegment) {
return `task_notification_${messageSegment}`;
}
if (messageSegment) return `task_notification_${messageSegment}`;
const taskSegment = envelope.taskId
? normalizeTaskNotificationCallIdSegment(envelope.taskId)
: null;
if (taskSegment) {
return `task_notification_${taskSegment}`;
}
if (taskSegment) return `task_notification_${taskSegment}`;
const seed =
[envelope.status, envelope.summary, envelope.outputFile, envelope.rawText]
@@ -1,3 +1,4 @@
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
import fs from "node:fs";
import path from "node:path";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
@@ -88,21 +89,24 @@ function indexedRecords(
): Iterable<TranscriptEntry> {
return {
*[Symbol.iterator]() {
for (const location of locations) {
const fd = descriptors.get(location.file)!;
const buffer = Buffer.allocUnsafe(location.length);
let read = 0;
while (read < buffer.length) {
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
if (count === 0)
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
read += count;
}
const entry = parseRecord(buffer.toString("utf8"));
if (entry) yield entry;
}
yield* assignTaskNotificationReplayIds(readEntries());
},
};
function* readEntries(): Generator<TranscriptEntry> {
for (const location of locations) {
const fd = descriptors.get(location.file)!;
const buffer = Buffer.allocUnsafe(location.length);
let read = 0;
while (read < buffer.length) {
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
if (count === 0)
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
read += count;
}
const entry = parseRecord(buffer.toString("utf8"));
if (entry) yield entry;
}
}
}
function isToolResult(entry: TranscriptEntry): boolean {
@@ -7167,3 +7167,35 @@ describe("Codex interactive resume startup", () => {
},
);
});
test("Codex runtime safety queries live terminals and retains unsupported app servers", async () => {
let response: unknown = { data: [], nextCursor: null };
const appServer = createFakeCodexAppServer({ "thread/backgroundTerminals/list": () => response });
const session = new CodexAppServerAgentSession(
createConfig(),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.startTurn("hello");
await appServer.waitForTurnStart();
expect(await session.canStopRuntime()).toBe(false);
appServer.completeTurn({ threadId: "thread-1" });
await expect.poll(() => session.canStopRuntime()).toBe(true);
response = { data: [{ processId: "shell-1" }], nextCursor: null };
expect(await session.canStopRuntime()).toBe(false);
response = {};
await expect(session.canStopRuntime()).rejects.toThrow();
response = { __jsonRpcError: { code: -32601, message: "Method not found" } };
await expect(session.canStopRuntime()).rejects.toThrow("Method not found");
expect(
appServer
.requests()
.findLast((request) => request.method === "thread/backgroundTerminals/list"),
).toMatchObject({ params: { threadId: "thread-1" } });
appServer.assertNoErrors();
} finally {
await session.close();
}
});
@@ -5029,6 +5029,34 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
async canStopRuntime(): Promise<boolean> {
if (
!this.client ||
this.client.hasPendingRequests() ||
!this.currentThreadId ||
this.activeForegroundTurnId ||
this.currentTurnId ||
this.pendingForegroundStart ||
this.pendingPermissionHandlers.size ||
this.getPendingPermissions().length ||
this.pendingSubAgentNotificationsByThreadId.size
)
return false;
// Older app servers reject this request. A failed or malformed probe must retain
// the process: command completion alone does not mean its terminal has exited.
const loaded = z
.object({ data: z.array(z.string()) })
.parse(await this.client.request("thread/loaded/list", {}, 10_000));
const threadIds = new Set([this.currentThreadId, ...loaded.data]);
for (const threadId of threadIds) {
const response = z
.object({ data: z.array(z.unknown()), nextCursor: z.string().nullable() })
.parse(await this.client.request("thread/backgroundTerminals/list", { threadId }, 10_000));
if (response.data.length > 0 || response.nextCursor !== null) return false;
}
return !this.client.hasPendingRequests();
}
async close(): Promise<void> {
this.closed = true;
this.clearPendingPermissions();
@@ -202,6 +202,7 @@ function createNewlineLineReader(
export class CodexAppServerClient {
private readonly stdoutLineReader: { close(): void };
private activeProviderRequests = 0;
private readonly pending = new Map<number, PendingRequest>();
private readonly requestHandlers = new Map<string, RequestHandler>();
private notificationHandler: NotificationHandler | null = null;
@@ -255,6 +256,10 @@ export class CodexAppServerClient {
this.requestHandlers.set(method, handler);
}
hasPendingRequests(): boolean {
return this.pending.size > 0 || this.activeProviderRequests > 0;
}
request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
if (this.disposed) {
return Promise.reject(new Error("Codex app-server client is closed"));
@@ -392,6 +397,7 @@ export class CodexAppServerClient {
this.traceRawEvent(request);
const handler = this.requestHandlers.get(request.method);
try {
this.activeProviderRequests++;
const result = handler ? await handler(request.params, request.id) : {};
this.writeJsonRpcResponse({ id: request.id, result });
} catch (error) {
@@ -399,6 +405,8 @@ export class CodexAppServerClient {
id: request.id,
error: { message: error instanceof Error ? error.message : String(error) },
});
} finally {
this.activeProviderRequests--;
}
return;
}
@@ -10,7 +10,7 @@ import type { OmpAgentMessage } from "./rpc-types.js";
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
import type { OmpUsagePollScheduler } from "./usage-poller.js";
import { resolveOmpProviderOptions } from "./provider-config.js";
import { OmpRuntimeEventSchema } from "./rpc-types.js";
import { OmpRuntimeEventSchema, OmpSessionStateSchema } from "./rpc-types.js";
import { OmpHarness } from "./test-utils/omp-harness.js";
import { OmpAgentClient } from "./agent.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
@@ -1544,3 +1544,86 @@ describe("OMP agent client and session", () => {
]);
});
});
test("omp runtime safety protects queued replies and runtime-owned activity", async () => {
const runtime = new FakeOmp();
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({
provider: "omp",
cwd: process.cwd(),
});
onTestFinished(() => session.close());
const native = runtime.latestSession();
native.state.isSettled = true;
native.state.hasPendingAsyncWork = false;
expect(await session.canStopRuntime!()).toBe(true);
native.state.queuedMessageCount = 1;
expect(await session.canStopRuntime!()).toBe(false);
native.state.queuedMessageCount = 0;
native.state.isStreaming = true;
expect(await session.canStopRuntime!()).toBe(false);
native.state.isStreaming = false;
native.state.isCompacting = true;
expect(await session.canStopRuntime!()).toBe(false);
native.state.isCompacting = false;
expect(await session.canStopRuntime!()).toBe(true);
native.getStateError = new Error("Unable to prove idle");
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
});
test("OMP runtime safety retains async work after the foreground turn finishes", async () => {
const runtime = new FakeOmp();
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({
provider: "omp",
cwd: process.cwd(),
});
onTestFinished(() => session.close());
const native = runtime.latestSession();
native.state.hasPendingAsyncWork = true;
native.state.isSettled = false;
const prompted = native.nextPrompt();
const run = session.run("start a background job");
await prompted;
native.beginTurn();
native.acceptPrompt("start a background job", "async-user");
native.streamAssistantText("Job started in background");
native.finishTurn();
await run;
expect(native.state.isStreaming).toBe(false);
expect(native.state.queuedMessageCount).toBe(0);
expect(await session.canStopRuntime!()).toBe(false);
// The delivery is drained, but an admitted/scheduled continuation still prevents settlement.
native.state.hasPendingAsyncWork = false;
expect(await session.canStopRuntime!()).toBe(false);
native.state.isSettled = true;
expect(await session.canStopRuntime!()).toBe(true);
});
test.each([
{ hasPendingAsyncWork: true, isSettled: true, expected: false },
{ hasPendingAsyncWork: false, isSettled: false, expected: false },
{ hasPendingAsyncWork: false, isSettled: true, expected: true },
{ hasPendingAsyncWork: undefined, isSettled: true, expected: false },
{ hasPendingAsyncWork: false, isSettled: undefined, expected: false },
{ hasPendingAsyncWork: undefined, isSettled: undefined, expected: false },
])(
"OMP runtime safety requires explicit settlement: %j",
async ({ hasPendingAsyncWork, isSettled, expected }) => {
const runtime = new FakeOmp();
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession(
{
provider: "omp",
cwd: process.cwd(),
},
);
onTestFinished(() => session.close());
const native = runtime.latestSession();
const response = { ...native.state };
if (hasPendingAsyncWork !== undefined) response.hasPendingAsyncWork = hasPendingAsyncWork;
if (isSettled !== undefined) response.isSettled = isSettled;
// Legacy get_state responses stay valid; missing safety fields never imply settlement.
native.state = OmpSessionStateSchema.parse(response);
expect(native.state.hasPendingAsyncWork).toBe(hasPendingAsyncWork);
expect(native.state.isSettled).toBe(isSettled);
expect(await session.canStopRuntime!()).toBe(expected);
},
);
@@ -1172,6 +1172,27 @@ export class OmpAgentSession implements AgentSession {
this.activeToolCalls.clear();
}
async canStopRuntime(): Promise<boolean> {
if (
this.closed ||
this.activeTurnId ||
this.activeToolCalls.size ||
this.pendingExtensionUiRequests.size ||
this.pendingClientMessages.length ||
this.pendingNoTurnOutputs.length ||
this.pendingNoTurnCompletionAbort
)
return false;
const state = await this.runtimeSession.getState();
return (
state.isSettled === true &&
state.hasPendingAsyncWork === false &&
!state.isStreaming &&
!state.isCompacting &&
state.queuedMessageCount === 0
);
}
async close(): Promise<void> {
if (this.closed) {
return;
@@ -152,6 +152,8 @@ export const OmpSessionStateSchema = z
thinkingLevel: OmpThinkingLevelSchema.optional(),
isStreaming: z.boolean(),
isCompacting: z.boolean(),
hasPendingAsyncWork: z.boolean().optional(),
isSettled: z.boolean().optional(),
autoCompactionEnabled: z.boolean().optional(),
fastModeEnabled: z.boolean().optional(),
fastModeActive: z.boolean().optional(),
@@ -7010,3 +7010,21 @@ describe("OpenCode session permission rules", () => {
}
});
});
test("OpenCode v1 runtime safety retains terminals and busy sessions on its shared server", async () => {
const { parent, openCode } = await createParentSession("runtime-safety");
try {
expect(await parent.canStopRuntime!()).toBe(true);
openCode.ptyListResponse = { data: [{ id: "shell", status: "running" }] };
expect(await parent.canStopRuntime!()).toBe(false);
openCode.ptyListResponse = { data: [] };
openCode.sessionStatusResponse = { data: { "other-native-session": { type: "busy" } } };
expect(await parent.canStopRuntime!()).toBe(false);
openCode.sessionStatusResponse = { data: {} };
expect(await parent.canStopRuntime!()).toBe(true);
openCode.ptyListResponse = { error: "Inventory unavailable" };
await expect(parent.canStopRuntime!()).rejects.toThrow("Unable to inspect OpenCode terminals");
} finally {
await parent.close();
}
});
@@ -5055,6 +5055,43 @@ class OpenCodeAgentSession implements AgentSession {
};
}
async canStopRuntime(): Promise<boolean> {
if (
this.closed ||
this.serverExited ||
this.reconnection ||
this.turnState.status !== "idle" ||
this.runningToolCalls.size ||
this.pendingPermissions.size ||
this.pendingSteerSubmissions.length
)
return false;
const request = { signal: AbortSignal.timeout(10_000) };
const family = new Map([[this.sessionId, this.config.cwd]]);
const inspected = new Set<string>();
for (const [sessionId, directory] of family) {
if (!inspected.has(directory)) {
const statuses = await this.client.session.status({ directory }, request);
if (statuses.error || !statuses.data)
throw new Error("Unable to inspect OpenCode executions");
if (Object.values(statuses.data).some((status) => status.type !== "idle")) return false;
const terminals = await this.client.pty.list({ directory }, request);
if (terminals.error || !terminals.data)
throw new Error("Unable to inspect OpenCode terminals");
if (terminals.data.some((terminal) => terminal.status === "running")) return false;
inspected.add(directory);
}
const response = await this.client.session.children(
{ sessionID: sessionId, directory },
request,
);
const children = readOpenCodeChildSessionInfosFromResponse(response);
if (!children) throw new Error("Unable to inspect OpenCode child sessions");
for (const child of children) family.set(child.id, child.directory ?? directory);
}
return true;
}
async close(): Promise<void> {
try {
this.closed = true;
@@ -170,6 +170,7 @@ export class TestOpenCodeClient {
null;
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
sessionPromptAsyncResponse: OpenCodeResponse = {};
ptyListResponse: OpenCodeResponse = { data: [] };
sessionStatusResponse: OpenCodeResponse = { data: {} };
sessionStatusImplementation:
| ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>)
@@ -195,6 +196,7 @@ export class TestOpenCodeClient {
asSdkClient(): OpencodeClient {
return {
pty: { list: async () => this.ptyListResponse },
app: {
agents: async (parameters: unknown, options: unknown) => {
this.calls.appAgents.push(parameters);
@@ -90,6 +90,9 @@ export class V2Harness {
readonly api: V2Api = {
server: { info: unexpected },
shell: {
list: async () => ({ location: { directory: this.info.location.directory }, data: [] }),
},
plugin: {
list: async () => ({
location: { directory: this.info.location.directory },
@@ -131,6 +134,7 @@ export class V2Harness {
},
permission: { list: async () => [], reply: unexpected },
session: {
inbox: { list: async () => [] },
form: { list: async () => [], reply: unexpected, cancel: unexpected },
create: async (input = {}) => {
this.creates.push(input);
@@ -1074,3 +1074,110 @@ describe("OpenCode v2 session lifecycle", () => {
}
});
});
test("OpenCode v2 runtime safety retains shells, queued reports and shared executions", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
expect(await session.canStopRuntime!()).toBe(true);
harness.api.shell.list = async () => ({
location: { directory: "/tmp/project" },
data: [
{
id: "quiet-shell",
status: "running",
command: "sleep 7200",
cwd: "/tmp/project",
shell: "sh",
file: "/tmp/out",
metadata: {},
time: { started: 1 },
},
],
});
expect(await session.canStopRuntime!()).toBe(false);
harness.api.shell.list = async () => ({ location: { directory: "/tmp/project" }, data: [] });
harness.api.session.inbox.list = async () => [
{
id: "report",
sessionID: session.id!,
type: "synthetic",
delivery: "queue",
time: { created: 1 },
payload: { text: "Background child finished" },
},
];
expect(await session.canStopRuntime!()).toBe(false);
harness.api.session.inbox.list = async () => [];
harness.api.session.active = async () => ({
"unrelated-autonomous-session": { type: "running" },
});
expect(await session.canStopRuntime!()).toBe(false);
harness.api.session.active = async () => ({});
expect(await session.canStopRuntime!()).toBe(true);
harness.api.shell.list = async () => {
throw new Error("No shell inventory");
};
await expect(session.canStopRuntime!()).rejects.toThrow("No shell inventory");
} finally {
await session.close();
}
});
test("OpenCode v2 runtime safety follows descendant locations before releasing the last lease", async () => {
const harness = new V2Harness();
const child = {
...harness.info,
id: "child",
parentID: harness.info.id,
location: { directory: "/tmp/child-worktree" },
};
const locations: string[] = [];
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
// A completed native child can still own a quiet shell in another location.
harness.api.session.list = async (input) => ({
data: input.parentID === session.id ? [child] : [],
cursor: {},
});
let running = true;
harness.api.shell.list = async (input) => {
const directory = input?.location?.directory ?? "";
locations.push(directory);
return {
location: { directory },
data:
directory === child.location.directory && running
? [
{
id: "child-shell",
status: "running",
command: "sleep 7200",
cwd: directory,
shell: "sh",
file: "/tmp/out",
metadata: {},
time: { started: 1 },
},
]
: [],
};
};
try {
expect(await session.canStopRuntime!()).toBe(false);
expect(locations).toEqual(["/tmp/project", "/tmp/child-worktree"]);
running = false;
expect(await session.canStopRuntime!()).toBe(true);
await session.close();
expect(harness.releases).toBe(1);
} finally {
await session.close();
}
});
@@ -2,6 +2,7 @@ import type { OpenCodeClient } from "@opencode/client";
export interface V2Api {
server: OpenCodeClient["server"];
shell: Pick<OpenCodeClient["shell"], "list">;
session: Pick<
OpenCodeClient["session"],
| "create"
@@ -20,6 +21,7 @@ export interface V2Api {
| "revert"
| "log"
> & {
inbox: Pick<OpenCodeClient["session"]["inbox"], "list">;
form: Pick<OpenCodeClient["session"]["form"], "list" | "reply" | "cancel">;
};
plugin: Pick<OpenCodeClient["plugin"], "list">;
@@ -516,6 +516,36 @@ export class OpenCodeV2Session implements AgentSession {
await delay(100, undefined, { signal }).catch(() => undefined);
}
}
async canStopRuntime(): Promise<boolean> {
if (this.closed || this.exited || this.reconnecting || this.turns.id || this.dirty)
return false;
const request = { signal: AbortSignal.timeout(10_000) };
// Active executions are server-wide. Releasing the last lease must not stop an
// autonomous execution merely because it belongs to another session or location.
if (Object.keys(await this.client.session.active(request)).length > 0) return false;
const family = new Map([[this.info.id, this.info]]);
const directories = new Set<string>();
for (const info of family.values()) {
if ((await this.client.session.inbox.list({ sessionID: info.id }, request)).length)
return false;
directories.add(info.location.directory);
let cursor: string | undefined;
do {
const page = await this.client.session.list(
{ ...(cursor ? { cursor } : { parentID: info.id }), limit: 100 },
request,
);
for (const child of page.data) family.set(child.id, child);
cursor = page.cursor.next ?? undefined;
} while (cursor);
}
for (const directory of directories) {
const shells = await this.client.shell.list({ location: { directory } }, request);
if (shells.data.some((shell) => shell.status === "running")) return false;
}
return !this.turns.id && !this.dirty;
}
async close() {
if (this.closed) return;
this.closed = true;
@@ -3488,3 +3488,23 @@ describe("transformPiModels", () => {
]);
});
});
test("pi runtime safety protects queued replies and runtime-owned activity", async () => {
const runtime = new FakePi();
const session = await createClient(runtime).createSession(createConfig());
onTestFinished(() => session.close());
const native = runtime.latestSession();
expect(await session.canStopRuntime!()).toBe(true);
native.state.pendingMessageCount = 1;
expect(await session.canStopRuntime!()).toBe(false);
native.state.pendingMessageCount = 0;
native.state.isStreaming = true;
expect(await session.canStopRuntime!()).toBe(false);
native.state.isStreaming = false;
native.state.isCompacting = true;
expect(await session.canStopRuntime!()).toBe(false);
native.state.isCompacting = false;
expect(await session.canStopRuntime!()).toBe(true);
native.getStateError = new Error("Unable to prove idle");
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
});
@@ -1581,6 +1581,22 @@ export class PiRpcAgentSession implements AgentSession {
return await resultPromise;
}
async canStopRuntime(): Promise<boolean> {
if (
this.closed ||
this.activeTurnId ||
this.pendingExtensionUiRequests.size ||
this.pendingExtensionResults.size ||
this.pendingExtensionHydrations.size ||
this.pendingSteerSubmissions.length ||
this.pendingNoTurnOutputs.length ||
this.interruptingTurn
)
return false;
const state = await this.runtimeSession.getState();
return !state.isStreaming && !state.isCompacting && state.pendingMessageCount === 0;
}
async close(): Promise<void> {
if (this.closed) {
return;
+13
View File
@@ -1,3 +1,4 @@
import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js";
import {
createFinishSubscriptions,
createFinishObservation,
@@ -453,6 +454,7 @@ export interface PaseoDaemonConfig {
dictationFinalTimeoutMs?: number;
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
idleRuntimeCleanup?: boolean;
providerCatalogRefreshTimeoutMs?: number;
metadataGeneration?: {
providers?: Array<{
@@ -1398,6 +1400,7 @@ export async function createPaseoDaemon(
archiveWorkspace: archiveScheduleWorkspaceExternal,
});
await scheduleService.start();
agentManager.setAgentArchivedCallback(async (agentId) => {
try {
await scheduleService.completeForAgent(agentId);
@@ -1445,6 +1448,15 @@ export async function createPaseoDaemon(
}),
});
const idleRuntimeCleanup = createIdleRuntimeCleanup({
enabled: config.idleRuntimeCleanup !== false,
runtimes: () => agentManager.getRuntimeCleanupCandidates(),
isProtected: (agentId) =>
finishSubscriptions.hasPendingForCaller(agentId) ||
(wsServer?.agentMessages.hasPending(agentId) ?? false),
logger,
});
const createAgentToolHostDependencies = (
runtime: PaseoToolRuntimeContext,
): PaseoToolHostDependencies => ({
@@ -1907,6 +1919,7 @@ export async function createPaseoDaemon(
await hubRelationships.stop();
workspaceReconciliation.dispose();
scriptHealthMonitor.stop();
await idleRuntimeCleanup.close();
// Freeze both ingress and registration before taking the agent closure snapshot.
wsServer?.prepareForShutdown();
await finishSubscriptions.close();
+11
View File
@@ -27,6 +27,17 @@ describe("server config", () => {
expect(standaloneConfig.desktopManaged).toBe(false);
});
test("idle runtime cleanup defaults on and supports a host-wide opt-out", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-idle-cleanup-"));
roots.push(paseoHome);
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
await writeFile(
path.join(paseoHome, "config.json"),
JSON.stringify({ agents: { idleRuntimeCleanup: false } }),
);
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false);
});
test("loads the provider catalog refresh timeout", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-"));
roots.push(paseoHome);
+1
View File
@@ -660,6 +660,7 @@ export function resolveConfigFromPersisted(
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
voiceLlmModel: voiceLlm.model,
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides,
@@ -339,6 +339,7 @@ export const PersistedConfigSchema = z
agents: z
.object({
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
idleRuntimeCleanup: z.boolean().optional(),
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
metadataGeneration: AgentMetadataGenerationSchema.optional(),
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
@@ -0,0 +1,70 @@
import { createServer } from "node:http";
import { setTimeout as delay } from "node:timers/promises";
import pino from "pino";
import { OpenAITTS } from "../tts.js";
import { TTSManager } from "../../../../agent/tts-manager.js";
if (!global.gc) throw new Error("Run with --expose-gc");
const sentences = ["First sentence.", "Second sentence.", "Third sentence."];
const audio = sentences.map((_, index) => Buffer.alloc(192_000, index + 1));
const requests: string[] = [];
const server = createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const { input } = JSON.parse(Buffer.concat(chunks).toString()) as { input: string };
requests.push(input);
const body = audio[sentences.indexOf(input)];
response.writeHead(200, { "Content-Type": "audio/pcm", "Content-Length": body.length });
response.end(body);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing server address");
const logger = pino({ level: "silent" });
const provider = new OpenAITTS(
{ apiKey: "test", baseUrl: `http://127.0.0.1:${address.port}/v1` },
logger,
);
const manager = new TTSManager("prefetched-playback", logger, provider);
const emitted: { bytes: number; value: number; format: string }[] = [];
try {
await manager.generateAndWaitForPlayback(
sentences.join(" "),
(message) => {
if (message.type !== "audio_output") return;
const buffer = Buffer.from(message.payload.audio, "base64");
emitted.push({ bytes: buffer.length, value: buffer[0], format: message.payload.format });
if (emitted.length === 1) {
// Hold the playback acknowledgement while the other responses are prefetched.
void (async () => {
while (requests.length < sentences.length) await delay(10);
for (let cycle = 0; cycle < 20; cycle++) {
await delay(25);
global.gc!();
}
manager.confirmAudioPlayed(message.payload.id);
})();
} else {
setImmediate(() => manager.confirmAudioPlayed(message.payload.id));
}
},
new AbortController().signal,
true,
);
console.log(JSON.stringify({ requests, emitted }));
} finally {
manager.cleanup();
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}

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