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
74 changed files with 1861 additions and 380 deletions
+4 -11
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.13.0 - 2026-10-24
## 0.13.0-beta.2 - 2026-10-23
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
@@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop
### Improved
- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
- 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))
@@ -32,7 +32,6 @@ paseo host add 'relay://...' --label laptop
- 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))
- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
- 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))
@@ -49,11 +48,10 @@ paseo host add 'relay://...' --label laptop
- 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, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
- 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 reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
- 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
@@ -68,11 +66,6 @@ paseo host add 'relay://...' --label laptop
- 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 a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737))
- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768))
- 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))
@@ -80,7 +73,7 @@ paseo host add 'relay://...' --label laptop
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
+4 -11
View File
@@ -1,6 +1,6 @@
# 更新日志
## 0.13.0 - 2026-10-24
## 0.13.0-beta.2 - 2026-10-23
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
@@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop
### 改进
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”;工作区不再支持拖动排序 ([#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))
@@ -32,7 +32,6 @@ paseo host add 'relay://...' --label laptop
- 手机上的 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))
- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
- 移除了旧版终端渲染器;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))
@@ -49,11 +48,10 @@ paseo host add 'relay://...' --label laptop
- 新增插件列表翻译,可通过 `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`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
- 所有插件 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))
- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### 修复
@@ -68,11 +66,6 @@ paseo host add 'relay://...' --label laptop
- 修复了没有应用显示工作区时,工作区 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))
- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737))
- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768))
- 修复了滚动时 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))
@@ -80,7 +73,7 @@ paseo host add 'relay://...' --label laptop
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
+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,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.13.0",
"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",
"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",
"version": "0.13.0-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"@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",
"version": "0.13.0-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@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",
"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",
"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",
"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",
"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",
"@getpaseo/protocol": "0.13.0",
"@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",
"@getpaseo/protocol": "0.13.0",
"@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",
"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",
"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",
"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",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@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",
"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",
"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",
"version": "0.13.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -10,16 +10,6 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
await page.keyboard.press(`${modifier}+Comma`);
}
async function openComposerTab(page: import("@playwright/test").Page, agentId: string) {
await page
.getByTestId(`workspace-tab-agent_${agentId}`)
.filter({ visible: true })
.first()
.click();
await expectAgentTabActive(page, agentId);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
interface ComposerFrame {
at: number;
visible: boolean;
@@ -37,9 +27,7 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
const sample = () => {
if (capture.stopped) return;
const root = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="message-input-root"]'),
).find((candidate) => candidate.offsetParent !== null);
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
if (root && input) {
const style = getComputedStyle(input);
@@ -190,8 +178,6 @@ test.describe("Settings toggle tab regression", () => {
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
await openComposerTab(page, firstAgent.id);
await openComposerTab(page, secondAgent.id);
const input = page.getByRole("textbox", { name: "Message agent..." });
await input.fill("Keep this short draft in the composer");
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
@@ -208,7 +194,6 @@ test.describe("Settings toggle tab regression", () => {
const frames = await stopComposerFrameCapture(page);
const visibleFrames = frames.filter((frame) => frame.visible);
expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
const tallestIndex = visibleFrames.reduce(
(index, frame, candidateIndex) =>
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
+29 -29
View File
@@ -38,10 +38,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.36.0",
version: "0.33.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.36.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -60,10 +60,10 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.73",
version: "3.0.46",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.73", "--acp"],
command: ["npx", "-y", "cline@3.0.46", "--acp"],
},
{
id: "codebuddy-code",
@@ -123,10 +123,10 @@ const CATALOG_DATA = [
id: "deepagents",
title: "DeepAgents",
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
version: "0.1.36",
version: "0.1.20",
iconId: "deepagents",
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.36"],
command: ["npx", "-y", "deepagents-acp@0.1.20"],
},
{
id: "devin",
@@ -141,29 +141,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.5.21",
version: "0.2.36",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.5.21", "acp"],
command: ["npx", "-y", "dimcode@0.2.36", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.5.23",
version: "0.4.22",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.5.23", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.249.0",
version: "0.179.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.249.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -174,19 +174,19 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.10.47",
version: "0.9.22",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.10.47", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"],
},
{
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.65.0",
version: "0.52.0",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.65.0", "--acp"],
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
},
{
id: "gjc",
@@ -205,11 +205,11 @@ const CATALOG_DATA = [
id: "glm-acp-agent",
title: "GLM Agent",
description:
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.3, glm-5.3-flash, glm-5-turbo, glm-4.7). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
version: "1.15.0",
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
version: "1.3.0",
iconId: "glm-acp-agent",
installLink: "https://github.com/stefandevo/glm-acp-agent",
command: ["npx", "-y", "glm-acp-agent@1.15.0"],
command: ["npx", "-y", "glm-acp-agent@1.3.0"],
},
{
id: "goose",
@@ -289,20 +289,20 @@ const CATALOG_DATA = [
id: "minimax-code",
title: "MiniMax Code",
description: "MiniMax's coding agent for the terminal",
version: "0.6.13",
version: "0.1.2",
iconId: "minimax-code",
installLink: "https://agent.minimax.io",
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
},
{
id: "minion-code",
title: "Minion Code",
description:
"An enhanced AI code assistant built on the Minion framework with rich development tools",
version: "0.1.45",
version: "0.1.44",
iconId: "minion-code",
installLink: "https://github.com/femto/minion-code",
command: ["uvx", "--from", "minion-code==0.1.45", "minion-code", "acp"],
command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"],
},
{
id: "mistral-vibe",
@@ -317,10 +317,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.59",
version: "1.1.29",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.59", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"],
},
{
id: "poolside",
@@ -335,19 +335,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.1.78",
version: "1.1.4",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.78", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.26.0",
version: "0.20.1",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.26.0", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -61,8 +61,8 @@ describe("ACP provider catalog", () => {
it("offers MiniMax Code through its pinned public ACP package", () => {
expect(findProvider("minimax-code")).toMatchObject({
title: "MiniMax Code",
version: "0.6.13",
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
version: "0.1.2",
command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
});
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
});
-16
View File
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
const runtime = {
@@ -706,21 +705,6 @@ describe("evaluatePluginClientBundle", () => {
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
});
it("keeps the host reveal hook on the deprecated client/react-native entry", () => {
const plugin = evaluatePluginClientBundle(
"legacy-reveal",
bundle(`
const legacy = require("@getpaseo/plugin/client/react-native");
const ui = require("@getpaseo/plugin/client/ui");
if (typeof legacy.useRevealedText !== "function") throw new Error("Missing legacy reveal hook");
if (legacy.StreamedText !== ui.StreamedText) throw new Error("Missing current UI exports");
if ("useRevealedText" in ui) throw new Error("Legacy hook leaked into current UI");
plugin.addScreen({ id: "reveal", title: "Reveal", Component: legacy.useRevealedText });
`),
);
expect(plugin.surfaces[0]?.Component).toBe(useRevealedText);
});
it("keeps shared and client runtime exports separate", () => {
expect(() =>
evaluatePluginClientBundle(
+1 -6
View File
@@ -2,7 +2,6 @@ import { requirePluginText } from "@getpaseo/plugin/client/host";
import type { createPluginHosts } from "./hosts";
import { openExternalUrl } from "@/utils/open-external-url";
import * as pluginUiRuntime from "./react-native/ui";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { useSettings } from "./settings/use-settings";
import * as pluginSharedRuntime from "@getpaseo/plugin";
import * as pluginClientRuntime from "@getpaseo/plugin/client";
@@ -115,10 +114,6 @@ export type PluginClientRuntime = Pick<
| "addHeaderButton"
> & { hosts: ReturnType<typeof createPluginHosts> };
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
// The 0.12 entry exposed the reveal hook as well as UI components.
const pluginReactNativeCompatRuntime = { ...pluginUiRuntime, useRevealedText };
export function runPluginClientBundle(
id: string,
bundle: string,
@@ -508,7 +503,7 @@ export function runPluginClientBundle(
};
const runtimeRequire = (name: string): unknown => {
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
if (name === "@getpaseo/plugin/client/react-native") return pluginReactNativeCompatRuntime;
if (name === "@getpaseo/plugin/client/react-native") return pluginUiRuntime;
if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime;
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.13.0",
"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",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"@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",
"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",
"@getpaseo/relay": "0.13.0",
"@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",
"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",
"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",
"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",
"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",
"@getpaseo/protocol": "0.13.0",
"@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",
"@getpaseo/protocol": "0.13.0",
"@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"
+6 -23
View File
@@ -101,29 +101,12 @@ describe("plugin SDK import boundaries", () => {
);
});
it.each(compatibilityEntries)("%s respects the client boundary", (entry) => {
expect(
boundaryViolations(
path.resolve(sourceDirectory, "..", manifest.exports[entry].source),
"client",
),
).toEqual([]);
});
it("typechecks a 0.12 reveal-hook consumer without exposing the hook on current UI", () => {
expect(
typeErrors(`
import { useRevealedText, Icon } from "@getpaseo/plugin/client/react-native";
import { StreamedText } from "@getpaseo/plugin/client/ui";
// @ts-expect-error the legacy hook is not part of the current UI API
import { useRevealedText as currentHook } from "@getpaseo/plugin/client/ui";
import { Text } from "react-native";
function LegacyRow({ text, phase }: { text: string; phase: "streaming" | "complete" }) {
const revealed: string = useRevealedText(text, phase);
return <Text>{revealed}</Text>;
}
`),
).toEqual([]);
it.each(compatibilityEntries)("%s only re-exports the UI entry", (entry) => {
const source = readFileSync(
path.resolve(sourceDirectory, "..", manifest.exports[entry].source),
"utf8",
);
expect(source.replace(/\/\/[^\n]*|\/\*[^]*?\*\//g, "").trim()).toBe('export * from "./ui.js";');
});
it.each(Object.entries(entries))(
@@ -1,6 +1,3 @@
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
/** @deprecated Import Paseo UI from @getpaseo/plugin/client/ui. */
export * from "./ui.js";
/** @deprecated Render StreamedText from @getpaseo/plugin/client/ui. Removed after 2027-04-20 with this entry. */
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
+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",
"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",
"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",
"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",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@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();
}
});
@@ -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) {
@@ -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;
}
}
@@ -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(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"private": true,
"type": "module",
"scripts": {
@@ -552,6 +552,9 @@
}
}
},
"idleRuntimeCleanup": {
"type": "boolean"
},
"catalogRefreshTimeoutMs": {
"type": "integer",
"exclusiveMinimum": 0,
@@ -1,92 +0,0 @@
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { fetchUsage } from "./usage.js";
it("shows the reported Pro percentage pools without treating bonus spend as an overage", async () => {
const directory = await mkdtemp(join(tmpdir(), "cursor-reported-pools-"));
const locator = join(directory, "auth.json");
try {
await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" }));
const report = await fetchUsage({ store: "file", locator }, async () =>
Response.json({
billingCycleStart: "2026-09-25T07:10:08.000Z",
billingCycleEnd: "2026-10-25T07:10:08.000Z",
planUsage: {
totalSpend: 5717,
includedSpend: 2000,
bonusSpend: 3717,
limit: 2000,
autoPercentUsed: 9.157777777777778,
apiPercentUsed: 35.46666666666667,
totalPercentUsed: 11.54949494949495,
},
spendLimitUsage: { individualLimit: 100, individualRemaining: 100, limitType: "user" },
}),
);
expect(report).toMatchObject({
status: "available",
windows: [
{ id: "plan_usage", usedPct: 11.54949494949495 },
{ id: "cursor_usage", usedPct: 9.157777777777778 },
{ id: "third_party_usage", usedPct: 35.46666666666667 },
],
balances: [],
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it.each([
["Pro+", 42015, 7000, 34.583333333333336, 4.6818181818181825, 32.07251908396947],
["Teams", 77145, 2000, 61.8352380952381, 61.09, 61.716],
] as const)(
"preserves the reported %s pools separately from bonus-inclusive dollars",
async (_plan, totalSpend, limit, autoPercentUsed, apiPercentUsed, totalPercentUsed) => {
const report = await reportFor({
totalSpend,
limit,
autoPercentUsed,
apiPercentUsed,
totalPercentUsed,
});
expect(report).toMatchObject({
status: "available",
windows: [
{ id: "plan_usage", usedPct: totalPercentUsed, summary: true },
{ id: "cursor_usage", usedPct: autoPercentUsed },
{ id: "third_party_usage", usedPct: apiPercentUsed },
],
balances: [],
});
},
);
it("retains a zero percentage without inventing absent breakdowns", async () => {
expect(await reportFor({ totalPercentUsed: "0", autoPercentUsed: null })).toMatchObject({
status: "available",
windows: [{ id: "plan_usage", usedPct: 0, remainingPct: 100 }],
balances: [],
});
});
it("keeps dollar-only reports when the response has no percentages", async () => {
expect(await reportFor({ totalSpend: 1500, remaining: 2500, limit: 4000 })).toMatchObject({
status: "available",
windows: [],
balances: [{ id: "plan_usage", used: 15, remaining: 25, limit: 40, unit: "usd" }],
});
});
async function reportFor(planUsage: Record<string, unknown>) {
const directory = await mkdtemp(join(tmpdir(), "cursor-usage-pools-"));
const locator = join(directory, "auth.json");
try {
await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" }));
return await fetchUsage({ store: "file", locator }, async () => Response.json({ planUsage }));
} finally {
await rm(directory, { recursive: true, force: true });
}
}
+1 -27
View File
@@ -9,12 +9,10 @@ import {
toneFromUsedPct,
usedPctOf,
unavailable,
windowFromUsedPct,
type UsageAccount,
type UsageReport,
type UsageBalance,
type UsageScope,
type UsageWindow,
} from "@getpaseo/plugin/server/usage";
const ApiNumberSchema = z.coerce.number().finite();
@@ -62,9 +60,6 @@ const CursorUsageResponseSchema = z.object({
bonusSpend: ApiNullableNumberSchema,
remaining: ApiNullableNumberSchema,
limit: ApiNullableNumberSchema,
totalPercentUsed: ApiNullableNumberSchema,
autoPercentUsed: ApiNullableNumberSchema,
apiPercentUsed: ApiNullableNumberSchema,
})
.nullish(),
billingCycleStart: CursorBillingCycleTimestampSchema,
@@ -182,29 +177,8 @@ export async function fetchUsage(
const resp = CursorUsageResponseSchema.parse(await res.json());
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
const windows: UsageWindow[] = [];
const balances: UsageBalance[] = [];
if (resp.planUsage) {
// Total spend includes bonus credits; returned percentages measure quota usage.
for (const [id, label, usedPct] of [
["plan_usage", "Total", resp.planUsage.totalPercentUsed],
["cursor_usage", "Cursor", resp.planUsage.autoPercentUsed],
["third_party_usage", "Third Party", resp.planUsage.apiPercentUsed],
] as const) {
if (usedPct === null) continue;
windows.push(
windowFromUsedPct({
id,
label,
utilizationPct: usedPct,
resetsAt: billingCycleEnd,
summary: id === "plan_usage",
tone: toneFromUsedPct(usedPct),
}),
);
}
}
if (resp.planUsage && windows.length === 0) {
const totalSpend = centsToDollars(resp.planUsage.totalSpend);
const remaining = centsToDollars(resp.planUsage.remaining);
const limit = centsToDollars(resp.planUsage.limit);
@@ -223,7 +197,7 @@ export async function fetchUsage(
return {
status: "available",
planLabel: undefined,
windows,
windows: [],
balances,
details: [],
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"private": true,
"type": "module",
"scripts": {
+15 -1
View File
@@ -74,7 +74,7 @@ Runtime-safe settings include relay enablement, MCP settings, browser tools, hos
New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon whose config already omitted this field when it started keeps the legacy relay-enabled behavior for compatibility. Set `daemon.relay.enabled` explicitly when editing an older config.
Listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths.
Idle runtime cleanup, listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths.
Deployment environment variables and legacy supervisor flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Stop and relaunch the deployment without the override to make the file value authoritative.
@@ -84,6 +84,20 @@ Agent providers, both the first-class ones Paseo ships with and custom entries y
See [Providers](/docs/providers) for the mental model and [Supported providers](/docs/supported-providers) for the full list of agents Paseo can launch. For pointing Claude at Anthropic-compatible endpoints (Z.AI, Alibaba/Qwen), multiple profiles, custom binaries, ACP agents, and the `additionalModels` merge behavior, see [Custom providers](/docs/custom-providers). The full field reference lives on GitHub at [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md).
## Idle runtimes
Paseo releases resumable idle agent processes after one hour when its daemon baseline allows
cleanup and any implemented provider check agrees. Your next prompt resumes the same conversation. Disable this
for the entire host with:
```json
{ "agents": { "idleRuntimeCleanup": false } }
```
This setting defaults to `true` and requires a daemon restart to change. There is no per-agent
setting. Providers without a runtime safety check use the same daemon baseline and one-hour idle
period. Active work, descendants and pending finish notifications prevent cleanup.
## Worktrees
New worktrees are created under `$PASEO_HOME/worktrees` by default. To place new worktrees somewhere else, set `worktrees.root`:
+11 -1
View File
@@ -72,7 +72,7 @@ paseo daemon restart
当你删除 `daemon.relay.enabled` 时,新的主目录会保持中继关闭。如果 daemon 启动时其配置就已经省略了这个字段,它会为了兼容而保持旧版的中继开启行为。编辑较旧的配置时,请显式设置 `daemon.relay.enabled`。
监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。
空闲运行时清理、监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。
部署环境变量和旧版进程管理器参数仍然具有最终决定权。当某个启动覆盖项阻止了文件中已更改的设置生效时,重新加载会在 `overrideControlledPaths` 下报告它。这包括启动设置,例如监听地址、密码、中继端点和 TLS、服务代理和 Web UI 设置、日志、语音合成与识别以及语音配置。主机名和 CORS 来源等列表设置仍然在各个来源之间追加,因此 `config.json` 中的值继续生效。要让文件中的值具有决定权,请停止该部署并在不带覆盖项的情况下重新启动。
@@ -82,6 +82,16 @@ Agent Provider,包括 Paseo 自带的一等 Provider 和你在 `agents.provide
心智模型见 [Provider](/docs/providers),Paseo 可以启动的全部 Agent 见[支持的 Provider](/docs/supported-providers)。要让 Claude 指向兼容 Anthropic 的端点(Z.AI、Alibaba/Qwen)、使用多个配置、自定义二进制文件、ACP Agent,以及了解 `additionalModels` 的合并行为,见[自定义 Provider](/docs/custom-providers)。完整的字段参考在 GitHub 上的 [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md)。
## 空闲运行时 {#idle-runtimes}
当 daemon 的基础条件允许清理,且 Provider 已实现的安全检查也允许时,Paseo 会在可恢复的 Agent 空闲一小时后释放其进程。你的下一条提示词会恢复同一个会话。要为整个主机禁用此功能,请设置:
```json
{ "agents": { "idleRuntimeCleanup": false } }
```
此设置默认为 `true`,更改后需要重启 daemon。没有单个 Agent 的设置。不提供运行时安全检查的 Provider 使用相同的 daemon 基础条件和一小时空闲期。活动中的工作、后代 Agent 和待发送的完成通知都会阻止清理。
## worktree {#worktrees}
新的 worktree 默认创建在 `$PASEO_HOME/worktrees` 下。要把新的 worktree 放到其他位置,请设置 `worktrees.root`:
+2 -3
View File
@@ -176,9 +176,8 @@ its registration; that registration belongs in the client entry.
### UI imports in v0.13
Use `/client/ui` for all Paseo UI components and UI hooks. The deprecated
`/client/react-native` entry retains `useRevealedText(text, phase): string` for 0.12 plugins
until removal after 2027-04-20. The hook is deprecated and is not exported from `/client/ui`.
Use `/client/ui` for all Paseo UI components and UI hooks. The previous entry remains an
undocumented compatibility alias until 2027-04-20.
For paced timeline text, replace `useRevealedText(text, phase)` and the surrounding `Text` with
`<StreamedText text={text} phase={phase} style={style} />` from `/client/ui`.
+46
View File
@@ -306,6 +306,52 @@ turn or finish as a side effect with a `completed` prompt result.
Advertise only capabilities you implement. If steering is unsupported, omit `prompt.steer`; Paseo
can replace the active turn instead.
## Permit idle runtime cleanup
Paseo can release an idle resumable session after one hour when its daemon baseline allows cleanup.
Add a provider-owned veto by advertising `session.can_stop_runtime` on the connection and the
session's `session.opened` capabilities. Without this capability, the same baseline and one-hour
idle period apply, and the plugin receives no safety request.
Handle the safety query in your existing `send(input)` handler. It addresses the provider's own
session ID, including when one connection serves several sessions:
```ts
case "session.can_stop_runtime": {
try {
const session = sessions.get(input.sessionId);
if (!session) throw new Error("Session not found");
emit({
type: "session.can_stop_runtime",
requestId: input.requestId,
sessionId: input.sessionId,
canStop: await session.canStopRuntime(),
});
} catch (error) {
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: error instanceof Error ? error.message : String(error) },
});
}
return;
}
```
`canStopRuntime(): Promise<boolean>` belongs to your provider's session implementation. Return
`true` only when releasing the runtime loses no background shells, monitors, native children,
queued completion reports, or pending autonomous replies. This query must not stop or mutate the
session. Return `false` while work remains. A rejected query, invalid response, or timeout also
keeps the runtime alive. Never respond with `request.completed` for this query.
Paseo always checks foreground work, permissions, queued deliveries, lifecycle operations,
working managed/native descendants, and outstanding local or remote finish subscriptions for the
caller. Subscription protection lasts through registration, recovery and notification dispatch
acknowledgment. A positive provider response never overrides these blockers. Paseo rechecks
activity before the existing `session.close` operation. After protected work finishes, a fresh
one-hour idle period starts. The saved provider persistence must resume the same conversation.
The host setting `agents.idleRuntimeCleanup: false` disables collection for every provider.
## Publish timeline items
Publish complete snapshots through `timeline.item`. Reuse an item `id` when updating streamed text,
+32
View File
@@ -269,6 +269,38 @@ Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,然后用当
只声明你实现了的能力。如果不支持引导,请省略 `prompt.steer`;Paseo 可以改为替换当前轮次。
## 允许清理空闲运行时 {#permit-idle-runtime-cleanup}
当 daemon 的基础条件允许清理时,Paseo 可以在可恢复的会话空闲一小时后释放其运行时。在连接和会话的 `session.opened` capabilities 中声明 `session.can_stop_runtime`,即可添加 Provider 自己的否决检查。没有此能力时,会话仍使用相同的基础条件和一小时空闲期,插件不会收到安全查询。
在现有的 `send(input)` 处理函数中处理安全查询。请求使用 Provider 自己的会话 ID,包括同一连接服务多个会话的情况:
```ts
case "session.can_stop_runtime": {
try {
const session = sessions.get(input.sessionId);
if (!session) throw new Error("Session not found");
emit({
type: "session.can_stop_runtime",
requestId: input.requestId,
sessionId: input.sessionId,
canStop: await session.canStopRuntime(),
});
} catch (error) {
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: error instanceof Error ? error.message : String(error) },
});
}
return;
}
```
`canStopRuntime(): Promise<boolean>` 由你的 Provider 会话实现。只有释放运行时不会丢失后台 shell、监控任务、原生子 Agent、排队的完成报告或待处理的自主回复时,才返回 `true`。此查询不得停止或修改会话。仍有工作时返回 `false`。查询拒绝、响应无效或超时也会保留运行时。不要用 `request.completed` 响应此查询。
Paseo 始终检查前台工作、权限、排队投递、生命周期操作、工作中的托管或原生后代 Agent,以及调用方尚未完成的本地或远程完成通知订阅。订阅保护覆盖注册、恢复,直到通知投递获确认。Provider 返回 `true` 也不能绕过这些阻止条件。Paseo 会在调用现有的 `session.close` 操作之前重新检查活动。受保护的工作完成后,会重新开始一小时的空闲期。保存的 Provider 持久化数据必须能恢复同一个会话。主机设置 `agents.idleRuntimeCleanup: false` 会禁用所有 Provider 的清理。
## 发布时间线条目 {#publish-timeline-items}
通过 `timeline.item` 发布完整的快照。更新流式文本、运行中的工具或待办列表时,复用条目的 `id`。Paseo 会推导出实时增量,并保持正常的时间线。