Compare commits

...
Author SHA1 Message Date
paseo-ai[bot] 1467121b16 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-28 18:00:41 +00:00
Mohamed Boudra 2f0fb2a8ba Bring 0.14.0-beta.2 versions and changelog to main (#8139)
* docs(changelog): fold 0.14.0-beta.2 UI fixes into the 0.14.0 entry

(cherry picked from commit 28bb7e29be)

* chore(release): cut 0.14.0-beta.2

(cherry picked from commit 1a05491be5)

* docs(changelog): keep unreleased main changes out of the 0.14.0-beta.2 entry

The beta.2 tag was cut from a selective hotfix branch, so #8100, #8117 and #8081 are on main but not in beta.2. Every changelog parser (app, website, release-notes sync) skips the preamble, so listing them there keeps them out of the beta.2 release body and What's new until the next release entry absorbs them.
2026-10-29 00:50:24 +07:00
Mohamed Boudraandsuntianc 83ed52669d Resume interrupted agents after host restart (#8127)
* feat(server): resume interrupted agents after host restart

Continue saved running conversations after graceful restart or crash, with host opt-out and a configurable continuation prompt. Preserve explicit launch overrides when reopening the same provider session.

Co-authored-by: coolwind <45729206+coolwindX@users.noreply.github.com>

Co-authored-by: suntianc <suntc8985@gmail.com>

* fix(app): translate restart recovery host settings

* test(app): synchronize sidebar drags and clean up added projects

Wait for the drag sensor click blocker to expire before the next user action. Resolve UI project keys to daemon IDs for cleanup so empty projects do not contaminate later sidebar tests.

* fix(app): tuck the resume prompt behind an edit sheet

Match the System prompt row: an Edit button left of the restart recovery
switch opens the prompt in a sheet, and is disabled while resume is off.

* fix(server): resume interrupted agents without holding startup

Startup now only chooses resume candidates before anything else reads or
repairs persisted running state. Reopening and prompting run in the
background after connections open, so a provider that is slow to reopen
holds back only its own agent. Finish observation waits for a pending
child's resume, so parents are not told the child was closed meanwhile.

* style(server): format restart resume e2e test

* test(server): wait for the fetch result before advancing the narrow refresh debounce

The test released the held fetch, flushed six microtasks, then advanced
the 1s refresh debounce. When the fetch result landed after that advance
(slow Windows runners), the debounce started late and vi.waitFor's
auto-advance reached it only at its real-time timeout. Wait on the
service's in-flight fetch count instead, as the sibling test does.

* test(server): retry stalled OpenCode fixture downloads within the test budget

npm abandons a stalled registry download only after its 300s default
fetch timeout, past the install's 180s budget. The killed install left
the fixture directory locked on Windows, so cleanup failed with EBUSY and
hid the timeout. Against a registry proxy that stalls the first tarball,
a default install hung 225s; with --fetch-timeout it retried and finished
in 16s.

---------

Co-authored-by: suntianc <suntc8985@gmail.com>
2026-10-28 23:40:35 +07:00
Mohamed Boudra 5cfe8f1218 Remove the out-of-date daemon sidebar notice (#8133)
The notice arrived with the terminal worker change (#7644) but is a separate
product change. Remove it with the native/compact callout filtering it needed,
restoring the sidebar callout slot to web-only. Restart and close terminals in
Settings stays.
2026-10-28 23:30:16 +07:00
Mohamed Boudra 38506ed372 test(plugins): allow time for Windows legacy migration workflows (#8112)
The migration case runs real Git operations and several plugin subprocess restarts. Windows CI exceeded its 30-second test deadline and entered cleanup while the operation was still active. Keep every assertion and allow 60 seconds for the complete workflow.
2026-10-28 22:59:21 +07:00
paseo-bot[bot] abc97e0545 Keep an interrupted background Claude subagent canceled after a daemon restart (#6259)
* Keep an interrupted background Claude subagent canceled after a daemon restart

Replay read a background Agent call's immediate async-launch result as the
child's outcome, so every background subagent restored as completed. Skip that
result, and read a child transcript that ends on the interrupt placeholder as
canceled, which is what the live path reported.

* Keep a background Claude subagent running when it got a new prompt after an interrupt

* fix(claude): preserve background subagent outcomes on replay

Use persisted task notifications as replay outcomes with the live status mapping. Diagnostic API request copies contain launch tool results without async metadata, so only transcript messages supply task declarations and tool outcomes.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 14:56:13 +00:00
suntiancandsuntianc 33faacc055 fix(server): release retained plugin streaming snapshots (#7675)
Release cumulative plugin snapshots after adapter attachment and retain canonical replay items. Preserve Unicode text, root/child ordering and current tool state; release history on close.

Co-authored-by: suntianc <suntc8985@gmail.com>
2026-10-28 20:14:12 +07:00
paseo-bot[bot] 8537709e72 test(server): reduce npm fixture setup in the Windows timeout case (#8106)
* test(server): batch npm fixture packing to reduce Windows setup time

* test(server): describe the batched npm fixture setup budget

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-28 12:57:33 +00:00
Mohamed BoudraandMatt Cowger 9fb7808191 feat(worktrees): let plugins choose creation options and placement (#8117)
* feat(worktrees): let plugins choose creation options and placement

Await the worktree creation request hook before Git source planning so plugins can change placement, branch and base without undoing a fetch. Manage arbitrary destinations through marker and registry ownership, and restore them at their recorded paths.

Co-authored-by: Matt Cowger <matt@cowger.us>

* fix(worktrees): compare repository paths across Windows aliases

Deletion must recognize the same repository through Git native and long path spellings. Reuse ownership path equivalence instead of comparing canonical strings, and let the recovery fixture accept Git platform line endings.

Co-authored-by: Matt Cowger <matt@cowger.us>

* fix(worktrees): respect recorded ownership and creation intent

Keep branch checkout distinct from branch-off, validate plugin placement once at the daemon boundary, and share default path collision resolution. A restored external worktree's marker must never override its recorded non-ownership.

Co-authored-by: Matt Cowger <matt@cowger.us>

* test(worktrees): record ownership in session archive fixture

The fixture creates a Paseo-owned worktree, so its durable record must say so. Archive now treats a recorded false value as authoritative instead of replacing it with filesystem discovery.

Co-authored-by: Matt Cowger <matt@cowger.us>

* test(plugins): retry temp cleanup after worktree archive on Windows

Windows releases directory handles after the daemon closes, so rmdir on the
source repository can hit EBUSY. Retry like the other git temp-dir tests.

* test(plugins): compare plugin-chosen worktree paths in canonical spelling

Windows temp directories come back as 8.3 aliases while the daemon records
the resolved spelling, so normalize the expected root the same way.

---------

Co-authored-by: Matt Cowger <matt@cowger.us>
2026-10-28 18:55:50 +07:00
122 changed files with 3391 additions and 1195 deletions
+17 -6
View File
@@ -1,6 +1,17 @@
# Changelog # Changelog
## 0.14.0-beta.1 - 2026-10-27 Not released yet. These changes are on `main` but not in 0.14.0-beta.2; the next release entry includes them.
Added:
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
Improved:
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0-beta.2 - 2026-10-28
Plugins can now add tools to individual agents and configure their MCP servers when sessions open. Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
@@ -10,9 +21,8 @@ Voice settings now let you configure speech providers and download local models
### Added ### Added
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938)) - Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav)) - Added Chat display settings that group tool calls or collapse completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983)) - Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295)) - Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte)) - Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
@@ -22,10 +32,9 @@ Voice settings now let you configure speech providers and download local models
### Improved ### Improved
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782)) - Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784)) - Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093))
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) - Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart)) - Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928)) - Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
@@ -51,10 +60,12 @@ Voice settings now let you configure speech providers and download local models
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski)) - Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910)) - Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku)) - Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889)) - Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916)) - Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1)) - Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842)) - Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- Fixed model discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan)) - Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### Notes ### Notes
+17 -5
View File
@@ -1,6 +1,17 @@
# 更新日志 # 更新日志
## 0.14.0-beta.1 - 2026-10-27 尚未发布。以下变更已合入 `main`,但不包含在 0.14.0-beta.2 中;下一个版本的条目会包含它们。
新增:
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
改进:
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0-beta.2 - 2026-10-28
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。 插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
@@ -11,7 +22,7 @@
### 新增 ### 新增
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938)) - 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav)) - 新增“聊天显示”设置,可将工具调用分组或折叠已完成的对话轮次 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983)) - 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295)) - 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte)) - 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
@@ -21,10 +32,9 @@
### 改进 ### 改进
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782)) - 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784)) - 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093))
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync)) - 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart)) - 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928)) - 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
@@ -50,10 +60,12 @@
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski)) - 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910)) - 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku)) - 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889)) - 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916)) - 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1)) - 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842)) - 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan)) - 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### 说明 ### 说明
+24 -2
View File
@@ -12,6 +12,26 @@ initializing → idle → running → idle (or error → closed)
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket. Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
## Daemon restart recovery
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
Disabling recovery consumes pending intent; enabling it later does not revive old work.
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle.
Explicit creation environment overrides are saved with the agent and reapplied when reopening.
Startup chooses candidates before anything else reads or repairs persisted running state, then
opens connections and resumes every candidate in the background. A provider that is slow to reopen
holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before
closing runtimes.
This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose
or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry
loop or exactly-once turn guarantee.
## Runtime residency ## Runtime residency
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
@@ -139,8 +159,10 @@ caller, child, and host into one watch.
Bootstrap restores these watches after repairing stale running records to `closed` and before opening Bootstrap restores these watches after repairing stale running records to `closed` and before opening
ingress. Reading a closed child's result does not load its provider. A finish notification reports that ingress. Reading a closed child's result does not load its provider. A finish notification reports that
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop recovery is observed only after its resume was sent, so the parent never hears that it was closed
in between. Archived or deleted parents suppress delivery;
parent-owned creation watches also stop
reporting after detachment. reporting after detachment.
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
+1 -1
View File
@@ -203,7 +203,7 @@ Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under th
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region. Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Native apps and every compact sidebar show only callouts registered with `native: true`; leave it unset for callouts about the desktop app. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for. Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page. Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
+6
View File
@@ -447,6 +447,12 @@ owns payloads, ordering, failure behavior, and examples. `agent.create.before` e
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary. every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
Worktree placement is resolved before Git source planning, so a hook can change the branch and base
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
ownership checks prefer the marker-verified worktree root over the legacy
path shape: a plugin can place a worktree several directories below the default worktrees root.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
or answer a permission through its own daemon session. Awaiting it there deadlocks that command. or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
+5
View File
@@ -10,6 +10,11 @@ then replaces the previous tool state or merges into the current text item. Inte
are never retained for history or catch-up. Provider history is the durable transcript authority are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes. and rebuilds the projection when an agent resumes.
Pagination bounds each read, not total resident history. Active and idle agents retain their
projected transcript; archive releases it and later history reads rebuild it from the provider.
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
must be released after their deltas have been consumed.
The invariants are: The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an > A continuously subscribed client applies every committed row in order. Opening or resuming an
+1 -1
View File
@@ -1 +1 @@
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0= sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw=
+28 -28
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
@@ -37590,7 +37590,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
@@ -39793,12 +39793,12 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.1", "@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.1", "@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.1", "@getpaseo/server": "0.14.0-beta.2",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -40045,10 +40045,10 @@
}, },
"packages/client": { "packages/client": {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.14.0-beta.1", "@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.1", "@getpaseo/relay": "0.14.0-beta.2",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -40060,7 +40060,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -40304,7 +40304,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
@@ -40350,7 +40350,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"dependencies": { "dependencies": {
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3", "@codemirror/legacy-modes": "^6.5.3",
@@ -40379,15 +40379,15 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^1.4.0", "@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.14.0-beta.1", "@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.1", "@getpaseo/protocol": "0.14.0-beta.2",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.2.3", "react": "19.2.3",
@@ -40397,8 +40397,8 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.14.0-beta.1", "@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.1", "@getpaseo/protocol": "0.14.0-beta.2",
"react": ">=19.1.0 <20", "react": ">=19.1.0 <20",
"react-native": ">=0.81.5", "react-native": ">=0.81.5",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -40420,7 +40420,7 @@
}, },
"packages/protocol": { "packages/protocol": {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"dependencies": { "dependencies": {
"semver": "^7.8.5", "semver": "^7.8.5",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -40447,7 +40447,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"dependencies": { "dependencies": {
"@noble/ciphers": "^2.4.0", "@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0", "@noble/curves": "^2.4.0",
@@ -40668,16 +40668,16 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.1", "@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.1", "@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.1", "@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.1", "@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.1", "@getpaseo/relay": "0.14.0-beta.2",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
@@ -41598,7 +41598,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1", "@cloudflare/workers-types": "^4.20260317.1",
@@ -43208,7 +43208,7 @@
}, },
"plugins": { "plugins": {
"name": "@getpaseo/builtin-plugins", "name": "@getpaseo/builtin-plugins",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"dependencies": { "dependencies": {
"@getpaseo/plugin": "*", "@getpaseo/plugin": "*",
"zod": "^4.4.3" "zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents", "description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [ "keywords": [
@@ -1,69 +0,0 @@
import { randomUUID } from "node:crypto";
import type { Page } from "@playwright/test";
import { expect, metroTest as test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
// 0.8.0 predates terminalsSurviveRestart and supports daemonSelfUpdate.
const PUBLISHED_DAEMON_VERSION = "0.8.0";
const COMPACT = { width: 390, height: 844 };
// Opens the app against a published 0.8.0 daemon, with the developer daemon blocked.
async function withOutdatedDaemon(page: Page, run: () => Promise<void>): Promise<void> {
const serverId = `srv_daemon_outdated_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, {
publishedVersion: PUBLISHED_DAEMON_VERSION,
});
try {
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.route(/:6767\b/, (route) => route.abort());
await page.routeWebSocket(/:6767\b/, async (webSocket) => {
await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." });
});
await page.addInitScript(
({ seededHost }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
},
{ seededHost: host },
);
await gotoAppShell(page);
await run();
} finally {
await daemon.close();
}
}
async function expectOutdatedNotice(page: Page): Promise<void> {
const callout = page.getByTestId("daemon-outdated-callout").first();
await expect(callout).toBeVisible({ timeout: 30_000 });
await expect(callout).toContainText("Daemon out of date");
await expect(callout).toContainText(`daemon v${PUBLISHED_DAEMON_VERSION}`);
await expect(callout).toContainText("Updating restarts the daemon and closes open terminals.");
const update = page.getByTestId("daemon-outdated-callout-update").first();
await expect(update).toBeVisible();
await expect(update).toBeEnabled();
}
test("sidebar shows an update notice when the active host's daemon is older than the app", async ({
page,
}) => {
test.setTimeout(180_000);
await withOutdatedDaemon(page, () => expectOutdatedNotice(page));
});
test("compact sidebar shows the update notice, which is a native callout", async ({ page }) => {
test.setTimeout(180_000);
await page.setViewportSize(COMPACT);
await withOutdatedDaemon(page, async () => {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
await expectOutdatedNotice(page);
});
});
@@ -204,19 +204,26 @@ test.describe("Projects settings", () => {
}) => { }) => {
const repo = await createTempGitRepo("projects-settings-empty-"); const repo = await createTempGitRepo("projects-settings-empty-");
const client = await connectSeedClient(); const client = await connectSeedClient();
let projectId: string | null = null; let projectViewKey: string | null = null;
try { try {
await gotoAppShell(page); await gotoAppShell(page);
projectId = await addProjectFromSidebar(page, repo.path); projectViewKey = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectId); await openProjectSettingsFromSidebar(page, projectViewKey);
await expectProjectSettingsFormVisible(page); await expectProjectSettingsFormVisible(page);
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible(); await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
} finally { } finally {
if (projectId) { if (projectViewKey) {
await client.removeProject(projectId).catch(() => undefined); const project = (await client.listProjects()).projects.find(
(entry) => entry.projectKey === projectViewKey,
);
expect(project).toBeDefined();
await client.removeProject(project!.projectId);
expect(
(await client.listProjects()).projects.map((entry) => entry.projectKey),
).not.toContain(projectViewKey);
} }
await client.close().catch(() => undefined); await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined); await repo.cleanup().catch(() => undefined);
@@ -30,6 +30,10 @@ import {
addDirectHostFromSettings, addDirectHostFromSettings,
expectHostOnlineWithoutError, expectHostOnlineWithoutError,
expectIdleAgentRelease, expectIdleAgentRelease,
editRestartRecoveryPrompt,
expectRestartRecoveryEnabled,
expectRestartRecoveryPrompt,
setRestartRecoveryEnabled,
setIdleAgentRelease, setIdleAgentRelease,
} from "../support/helpers/settings"; } from "../support/helpers/settings";
@@ -252,3 +256,20 @@ test.describe("Settings host page", () => {
await expectHostActionCards(page, serverId); await expectHostActionCards(page, serverId);
}); });
}); });
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
await expectRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Resume working.");
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
await setRestartRecoveryEnabled(page, false);
await page.reload();
await expectRestartRecoveryEnabled(page, false);
await setRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
@@ -415,19 +415,8 @@ test.describe("Settings — compact master-detail", () => {
const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" }); const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" });
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId); const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId);
const worktreeSetupCallout = page.locator('[data-testid^="worktree-setup-callout-"]'); await openWorkspace(page, workspace);
await test.step("the wide sidebar shows the worktree setup prompt for this workspace", async () => {
await page.setViewportSize({ width: 1280, height: 900 });
await openWorkspace(page, workspace);
await expect(worktreeSetupCallout.first()).toBeVisible({ timeout: 30_000 });
await page.setViewportSize({ width: 390, height: 844 });
});
await page.getByRole("button", { name: "Open menu", exact: true }).click(); await page.getByRole("button", { name: "Open menu", exact: true }).click();
// The compact sidebar shows only native callouts, so the prompt stays out.
await expect(page.getByTestId("sidebar-hosts-trigger")).toBeVisible();
await expect(worktreeSetupCallout).toHaveCount(0);
await page.getByTestId("sidebar-hosts-trigger").click(); await page.getByTestId("sidebar-hosts-trigger").click();
await page.getByRole("button", { name: /Open .* settings/ }).click(); await page.getByRole("button", { name: /Open .* settings/ }).click();
@@ -53,11 +53,18 @@ async function quickDragFirstRowAfterSecond(
await page.mouse.move(source.x, source.y + 7); await page.mouse.move(source.x, source.y + 7);
await expect(trailingScrim).toHaveCount(0); await expect(trailingScrim).toHaveCount(0);
await page.mouse.move(target.x, target.y, { steps: 4 }); await page.mouse.move(target.x, target.y, { steps: 4 });
await page.mouse.up(); await finishSidebarDrag(page);
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]); await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
} }
async function finishSidebarDrag(page: Page): Promise<void> {
await page.mouse.up();
// dnd-kit's pointer sensor keeps its document click blocker for 50 ms after drop.
// Row order updates before that blocker is removed; wait before the next user action.
await page.waitForTimeout(60);
}
async function dragWithoutReordering(rows: Locator): Promise<void> { async function dragWithoutReordering(rows: Locator): Promise<void> {
const before = await rowTestIds(rows); const before = await rowTestIds(rows);
const source = await visibleBoundingBox(rows.nth(0)); const source = await visibleBoundingBox(rows.nth(0));
@@ -66,7 +73,7 @@ async function dragWithoutReordering(rows: Locator): Promise<void> {
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 }); await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
await page.mouse.up(); await finishSidebarDrag(page);
await expect.poll(() => rowTestIds(rows)).toEqual(before); await expect.poll(() => rowTestIds(rows)).toEqual(before);
} }
@@ -1,35 +0,0 @@
# Native daemon-outdated notice
The flow needs the development app connected to an isolated daemon older than the app. Install a published daemon that predates `terminalsSurviveRestart`, start it in its own home, and bundle the app against it. `<checkout>` is the root of this repository; `<port>`, `<metro-port>` and `<udid>` are a free daemon port, a free Metro port and the booted simulator.
Run this once, in any directory. It installs into `/tmp/old-daemon`, not globally:
```sh
npm install --prefix /tmp/old-daemon --no-package-lock @getpaseo/server@0.8.0
```
Terminal 1, the old daemon. It runs in the foreground; keep it running:
```sh
cd /tmp/old-daemon/node_modules/@getpaseo/server
PASEO_HOME=/tmp/old-daemon-home PASEO_LISTEN=0.0.0.0:<port> PASEO_RELAY_ENABLED=0 node dist/scripts/supervisor-entrypoint.js
```
Terminal 2, Metro. It runs in the foreground; keep it running:
```sh
cd <checkout>/packages/app
APP_VARIANT=development EXPO_PUBLIC_LOCAL_DAEMON=localhost:<port> npx expo start --port <metro-port> --clear
```
Terminal 3, the flow, while terminals 1 and 2 are still running:
```sh
cd <checkout>
xcrun simctl openurl <udid> "paseo://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A<metro-port>"
maestro --udid <udid> test packages/app/e2e/mobile/agent-device/native-daemon-outdated-notice.ios.yaml --env DAEMON_VERSION=0.8.0
```
Start with the sidebar closed. The flow opens it, then asserts the notice, its daemon version, the terminals line for a daemon without `terminalsSurviveRestart`, and an enabled Update button. Do not tap Update: on 0.8.0 it runs a global `npm install -g @getpaseo/cli@latest`.
When you are done, stop terminals 1 and 2 and remove `/tmp/old-daemon` and `/tmp/old-daemon-home`.
@@ -1,26 +0,0 @@
appId: sh.paseo.debug
---
# The app must be connected to a daemon older than the app that predates
# terminalsSurviveRestart (0.8.0). See native-daemon-outdated-notice-preconditions.md.
- launchApp
- runFlow:
when:
visible: ".*getRegistrationInfoAsync.*"
commands:
- tapOn: ".*getRegistrationInfoAsync.*"
- tapOn: Dismiss
- extendedWaitUntil:
visible: "Open menu"
timeout: 60000
- tapOn: "Open menu"
- extendedWaitUntil:
visible:
id: daemon-outdated-callout
timeout: 30000
- assertVisible: "Daemon out of date"
- assertVisible: ".*daemon v${DAEMON_VERSION}.*"
- assertVisible: "Updating restarts the daemon and closes open terminals."
- assertVisible:
id: daemon-outdated-callout-update
enabled: true
- takeScreenshot: native-daemon-outdated-notice
@@ -560,3 +560,41 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-identity")).toContainText("Online"); await expect(page.getByTestId("host-page-identity")).toContainText("Online");
await expect(page.getByTestId("host-connection-error")).toHaveCount(0); await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
} }
function restartRecoverySwitch(page: Page) {
return page.getByRole("switch", { name: "Resume agents after restart", exact: true });
}
function restartRecoveryEditButton(page: Page) {
return page.getByRole("button", { name: "Edit prompt", exact: true });
}
export async function setRestartRecoveryEnabled(page: Page, enabled: boolean) {
const toggle = restartRecoverySwitch(page);
await toggle.click();
await expectRestartRecoveryEnabled(page, enabled);
}
// The prompt is only editable while restart recovery is on.
export async function expectRestartRecoveryEnabled(page: Page, enabled: boolean) {
await expect(restartRecoverySwitch(page)).toBeChecked({ checked: enabled });
await expect(restartRecoveryEditButton(page)).toBeEnabled({ enabled });
}
export async function editRestartRecoveryPrompt(page: Page, prompt: string) {
await restartRecoveryEditButton(page).click();
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
await sheet.getByRole("textbox", { name: "Resume prompt", exact: true }).fill(prompt);
await sheet.getByRole("button", { name: "Save", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
export async function expectRestartRecoveryPrompt(page: Page, prompt: string) {
await restartRecoveryEditButton(page).click();
const sheet = page.getByTestId("host-page-restart-recovery-sheet");
await expect(sheet.getByRole("textbox", { name: "Resume prompt", exact: true })).toHaveValue(
prompt,
);
await sheet.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(sheet).toHaveCount(0);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
-2
View File
@@ -84,7 +84,6 @@ import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings"; import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source"; import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source"; import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { DaemonOutdatedCalloutSource } from "@/components/daemon-outdated-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action"; import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
@@ -487,7 +486,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<DownloadToast /> <DownloadToast />
<RosettaCalloutSource /> <RosettaCalloutSource />
<UpdateCalloutSource /> <UpdateCalloutSource />
<DaemonOutdatedCalloutSource />
<LegacyAgentSkillsMigration /> <LegacyAgentSkillsMigration />
<WorktreeSetupCalloutSource /> <WorktreeSetupCalloutSource />
<CommandCenterRootActions /> <CommandCenterRootActions />
@@ -1,182 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { TFunction } from "i18next";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
type SidebarCalloutAction,
SidebarCalloutDescriptionText,
} from "@/components/sidebar-callout";
import {
type DaemonOutdatedUpdateState,
resolveDaemonOutdatedCallout,
} from "@/components/resolve-daemon-outdated-callout";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { updateDaemonFromSettings } from "@/screens/settings/daemon-lifecycle";
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { orderHostsLocalFirst, resolveActiveHostServerId } from "@/types/host-connection";
import { resolveAppVersion } from "@/utils/app-version";
import { confirmDialog } from "@/utils/confirm-dialog";
interface HostUpdateState {
serverId: string;
update: DaemonOutdatedUpdateState;
}
const IDLE: DaemonOutdatedUpdateState = { status: "idle" };
function DaemonOutdatedDescription({ lines }: { lines: string[] }) {
return (
<>
{lines.map((line) => (
<SidebarCalloutDescriptionText key={line}>{line}</SidebarCalloutDescriptionText>
))}
</>
);
}
interface ConfirmAndUpdateDaemonInput {
serverId: string;
hostLabel: string;
client: DaemonClient;
t: TFunction;
onState: (next: DaemonOutdatedUpdateState) => void;
}
async function confirmAndUpdateDaemon(input: ConfirmAndUpdateDaemonInput): Promise<void> {
const { serverId, hostLabel, client, t, onState } = input;
let confirmed: boolean;
try {
confirmed = await confirmDialog({
title: t("settings.host.daemon.update.confirmTitle", { name: hostLabel }),
message: t("settings.host.daemon.update.confirmMessage"),
confirmLabel: t("settings.host.daemon.update.confirm"),
cancelLabel: t("common.actions.cancel"),
destructive: false,
});
} catch (error) {
console.error("[DaemonOutdatedCallout] Failed to open update confirmation", error);
onState({ status: "failed", message: t("settings.host.daemon.update.dialogFailedMessage") });
return;
}
if (!confirmed) return;
const phaseLabels = {
starting: t("settings.host.daemon.update.phaseStarting"),
downloading: t("settings.host.daemon.update.phaseDownloading"),
installing: t("settings.host.daemon.update.phaseInstalling"),
complete: t("settings.host.daemon.update.phaseComplete"),
};
onState({ status: "updating", phase: phaseLabels.starting });
const requestId = `sidebar_daemon_update_${serverId}`;
const unsubscribe = client.on("daemon.update.progress", (message) => {
if (message.payload.requestId !== requestId) return;
onState({ status: "updating", phase: phaseLabels[message.payload.phase] });
});
try {
await updateDaemonFromSettings(serverId, {
updateDaemon: () => client.updateDaemon(requestId),
getStatus: async () => ({
...(await client.getDaemonStatus()),
serverId: client.getLastServerInfoMessage()?.serverId ?? "",
version: client.getLastServerInfoMessage()?.version ?? null,
}),
});
onState({ status: "idle" });
} catch (error) {
console.error(`[DaemonOutdatedCallout] Failed to update daemon ${hostLabel}`, error);
onState({
status: "failed",
message: t("settings.host.daemon.update.requestFailedMessage", {
error: error instanceof Error ? error.message : String(error),
}),
});
} finally {
unsubscribe();
}
}
/** Sidebar notice when the active host's daemon is older than this app. */
export function DaemonOutdatedCalloutSource() {
const { t } = useTranslation();
const callouts = useSidebarCallouts();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const lastWorkspaceSelection = useLastWorkspaceSelection();
const orderedHosts = useMemo(
() => orderHostsLocalFirst(hosts, localServerId),
[hosts, localServerId],
);
// Same host resolution as settings: the last workspace's host, else the local daemon, else the first.
const serverId = resolveActiveHostServerId({
selectedServerId: lastWorkspaceSelection?.serverId ?? null,
localServerId,
hosts,
orderedHosts,
});
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? "";
const daemonClient = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const serverInfo = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.serverInfo ?? null) : null,
);
const [hostUpdate, setHostUpdate] = useState<HostUpdateState | null>(null);
const update = hostUpdate && hostUpdate.serverId === serverId ? hostUpdate.update : IDLE;
const runUpdate = useStableEvent(() => {
if (!serverId || !daemonClient) return;
void confirmAndUpdateDaemon({
serverId,
hostLabel,
client: daemonClient,
t,
onState: (next) => setHostUpdate({ serverId, update: next }),
});
});
useEffect(() => {
if (!serverId || !serverInfo) return;
const descriptor = resolveDaemonOutdatedCallout({
serverId,
hostLabel,
appVersion: resolveAppVersion(),
daemonVersion: serverInfo.version,
supportsSelfUpdate: serverInfo.features?.daemonSelfUpdate === true,
desktopManaged: serverInfo.desktopManaged === true,
// COMPAT(terminalsSurviveRestart): added in v0.12.0, remove gate after 2027-04-21.
terminalsSurviveRestart: serverInfo.features?.terminalsSurviveRestart === true,
isConnected,
update,
});
if (!descriptor) return;
const actions: SidebarCalloutAction[] = descriptor.updateAction
? [
{
label: descriptor.updateAction.label,
onPress: runUpdate,
variant: "primary",
disabled: descriptor.updateAction.disabled,
testID: "daemon-outdated-callout-update",
},
]
: [];
return callouts.show({
id: descriptor.id,
dismissalKey: descriptor.dismissalKey,
priority: descriptor.priority,
title: descriptor.title,
description: <DaemonOutdatedDescription lines={descriptor.description} />,
variant: descriptor.variant,
actions,
testID: descriptor.testID,
native: true,
});
// t: the resolver reads the active language through i18n, so a language change rebuilds the copy.
}, [callouts, hostLabel, isConnected, runUpdate, serverId, serverInfo, t, update]);
return null;
}
+1 -4
View File
@@ -40,7 +40,6 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session"; import { useImportSession } from "@/hooks/use-import-session";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -606,8 +605,6 @@ function MobileSidebar({
/> />
)} )}
<SidebarCalloutSlot nativeOnly />
<SidebarFooter <SidebarFooter
handleOpenProject={handleOpenProject} handleOpenProject={handleOpenProject}
handleSettings={handleSettings} handleSettings={handleSettings}
@@ -787,7 +784,7 @@ function DesktopSidebar({
/> />
)} )}
<SidebarCalloutSlot nativeOnly={isNative} /> <SidebarCalloutSlot />
<SidebarFooter <SidebarFooter
handleOpenProject={handleOpenProject} handleOpenProject={handleOpenProject}
@@ -1,195 +0,0 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import {
resolveDaemonOutdatedCallout,
type ResolveDaemonOutdatedCalloutInput,
} from "./resolve-daemon-outdated-callout";
function input(
overrides: Partial<ResolveDaemonOutdatedCalloutInput> = {},
): ResolveDaemonOutdatedCalloutInput {
return {
serverId: "srv_alpha",
hostLabel: "Workstation",
appVersion: "0.12.0",
daemonVersion: "0.11.4",
supportsSelfUpdate: true,
desktopManaged: false,
terminalsSurviveRestart: true,
isConnected: true,
update: { status: "idle" },
...overrides,
};
}
describe("resolveDaemonOutdatedCallout", () => {
it("shows an update notice when the daemon is older than the app", () => {
expect(resolveDaemonOutdatedCallout(input())).toEqual({
id: "daemon-outdated",
dismissalKey: "daemon-outdated:srv_alpha:0.11.4",
priority: 250,
title: "Daemon out of date",
description: [
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
"Updating restarts the daemon. Terminals stay open.",
],
variant: "default",
updateAction: { label: "Update", disabled: false },
testID: "daemon-outdated-callout",
});
});
it("hides when the daemon matches or is newer than the app", () => {
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.12.0" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "v0.12.0" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.12.1" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "1.0.0" }))).toBeNull();
});
it("compares numeric components numerically, not as strings", () => {
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.10.0", daemonVersion: "0.9.9" })),
).not.toBeNull();
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.9.9", daemonVersion: "0.10.0" })),
).toBeNull();
});
it("orders prereleases before their release", () => {
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0", daemonVersion: "0.12.0-beta.1" })),
).not.toBeNull();
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0-beta.1", daemonVersion: "0.12.0" })),
).toBeNull();
expect(
resolveDaemonOutdatedCallout(
input({ appVersion: "0.12.0-beta.10", daemonVersion: "0.12.0-beta.2" }),
),
).not.toBeNull();
expect(
resolveDaemonOutdatedCallout(
input({ appVersion: "0.12.0-beta.2", daemonVersion: "0.12.0-beta.2" }),
),
).toBeNull();
expect(
resolveDaemonOutdatedCallout(input({ appVersion: "0.12.0-rc.1", daemonVersion: "0.11.9" })),
).not.toBeNull();
});
it("hides when either version is missing or unparseable", () => {
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: null }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ appVersion: null }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "dev" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.11" }))).toBeNull();
expect(resolveDaemonOutdatedCallout(input({ appVersion: "unknown" }))).toBeNull();
});
it("offers no button for a desktop-managed daemon and points to Paseo on that computer", () => {
const descriptor = resolveDaemonOutdatedCallout(input({ desktopManaged: true }));
expect(descriptor?.updateAction).toBeNull();
expect(descriptor?.description).toEqual([
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
"Paseo Desktop manages this daemon. Update Paseo on that computer.",
"Updating Paseo Desktop keeps terminals open on macOS and Linux, and closes them on Windows.",
]);
});
it("never promises terminals survive a desktop update, which closes them on Windows", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ desktopManaged: true, terminalsSurviveRestart: true }),
);
expect(descriptor?.description).not.toContain(
"Updating restarts the daemon. Terminals stay open.",
);
expect(descriptor?.description.at(-1)).toContain("closes them on Windows");
});
it("says a desktop update closes terminals when the daemon does not keep them", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ desktopManaged: true, terminalsSurviveRestart: false }),
);
expect(descriptor?.description.at(-1)).toBe(
"Updating restarts the daemon and closes open terminals.",
);
});
it("offers no button when the daemon does not support self-update", () => {
const descriptor = resolveDaemonOutdatedCallout(input({ supportsSelfUpdate: false }));
expect(descriptor?.updateAction).toBeNull();
expect(descriptor?.description).toContain(
"This daemon can't update itself. Update Paseo on that computer.",
);
});
it("says the update closes terminals when the daemon does not keep them", () => {
const descriptor = resolveDaemonOutdatedCallout(input({ terminalsSurviveRestart: false }));
expect(descriptor?.description).toEqual([
"Workstation runs daemon v0.11.4. This app is v0.12.0.",
"Updating restarts the daemon and closes open terminals.",
]);
});
it("disables the button while the host is disconnected", () => {
expect(resolveDaemonOutdatedCallout(input({ isConnected: false }))?.updateAction).toEqual({
label: "Update",
disabled: true,
});
});
it("shows the update phase on a disabled button while updating", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ update: { status: "updating", phase: "Downloading packages..." } }),
);
expect(descriptor?.variant).toBe("default");
expect(descriptor?.updateAction).toEqual({ label: "Downloading packages...", disabled: true });
});
it("surfaces a failed update as an error with the button available again", () => {
const descriptor = resolveDaemonOutdatedCallout(
input({ update: { status: "failed", message: "Failed to update the daemon: boom" } }),
);
expect(descriptor?.title).toBe("Daemon update failed");
expect(descriptor?.variant).toBe("error");
expect(descriptor?.description).toEqual(["Failed to update the daemon: boom"]);
expect(descriptor?.updateAction).toEqual({ label: "Update", disabled: false });
});
it("keys dismissal by host and daemon version", () => {
const key = resolveDaemonOutdatedCallout(input())?.dismissalKey;
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "v0.11.4" }))?.dismissalKey).toBe(
key,
);
expect(resolveDaemonOutdatedCallout(input({ appVersion: "0.13.0" }))?.dismissalKey).toBe(key);
expect(resolveDaemonOutdatedCallout(input({ daemonVersion: "0.11.5" }))?.dismissalKey).toBe(
"daemon-outdated:srv_alpha:0.11.5",
);
expect(resolveDaemonOutdatedCallout(input({ serverId: "srv_beta" }))?.dismissalKey).toBe(
"daemon-outdated:srv_beta:0.11.4",
);
});
it("uses the active app language", async () => {
await i18n.changeLanguage("es");
try {
const descriptor = resolveDaemonOutdatedCallout(input({ terminalsSurviveRestart: false }));
expect(descriptor?.title).toBe("Daemon desactualizado");
expect(descriptor?.description).toEqual([
"Workstation ejecuta el daemon v0.11.4. Esta app es v0.12.0.",
"Actualizar reinicia el daemon y cierra las terminales abiertas.",
]);
expect(descriptor?.updateAction?.label).toBe("Actualizar");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -1,147 +0,0 @@
import { i18n } from "@/i18n/i18next";
export type DaemonOutdatedUpdateState =
| { status: "idle" }
| { status: "updating"; phase: string }
| { status: "failed"; message: string };
export interface ResolveDaemonOutdatedCalloutInput {
serverId: string;
hostLabel: string;
appVersion: string | null;
daemonVersion: string | null;
supportsSelfUpdate: boolean;
desktopManaged: boolean;
terminalsSurviveRestart: boolean;
isConnected: boolean;
update: DaemonOutdatedUpdateState;
}
export interface DaemonOutdatedUpdateAction {
label: string;
disabled: boolean;
}
export interface DaemonOutdatedCalloutDescriptor {
id: "daemon-outdated";
dismissalKey: string;
priority: number;
title: string;
description: string[];
variant: "default" | "error";
updateAction: DaemonOutdatedUpdateAction | null;
testID: "daemon-outdated-callout";
}
interface ParsedVersion {
core: [number, number, number];
prerelease: string[];
}
const VERSION_PATTERN =
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/i;
const NUMERIC_IDENTIFIER = /^\d+$/;
function parseVersion(value: string | null): ParsedVersion | null {
const match = value?.trim().match(VERSION_PATTERN);
if (!match) return null;
return {
core: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease: match[4] ? match[4].split(".") : [],
};
}
function compareIdentifiers(left: string, right: string): number {
const isLeftNumeric = NUMERIC_IDENTIFIER.test(left);
const isRightNumeric = NUMERIC_IDENTIFIER.test(right);
if (isLeftNumeric && isRightNumeric) return Number(left) - Number(right);
if (isLeftNumeric) return -1;
if (isRightNumeric) return 1;
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
// Semver precedence: a prerelease sorts before its release (0.12.0-beta.1 < 0.12.0).
function compareVersions(left: ParsedVersion, right: ParsedVersion): number {
for (let index = 0; index < 3; index += 1) {
const difference = left.core[index] - right.core[index];
if (difference !== 0) return difference;
}
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
return right.prerelease.length - left.prerelease.length;
}
const sharedLength = Math.min(left.prerelease.length, right.prerelease.length);
for (let index = 0; index < sharedLength; index += 1) {
const difference = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
if (difference !== 0) return difference;
}
return left.prerelease.length - right.prerelease.length;
}
function resolveUpdateAction(input: ResolveDaemonOutdatedCalloutInput): DaemonOutdatedUpdateAction {
if (input.update.status === "updating") {
return { label: input.update.phase, disabled: true };
}
return { label: i18n.t("daemonOutdated.update"), disabled: !input.isConnected };
}
function formatVersion(version: ParsedVersion): string {
const core = version.core.join(".");
return version.prerelease.length > 0 ? `${core}-${version.prerelease.join(".")}` : core;
}
// A daemon that keeps terminals across restarts does not keep them across a Paseo Desktop update
// on Windows, which closes them. server_info does not name the host's platform, so the line says
// both.
function resolveTerminalsLine(input: ResolveDaemonOutdatedCalloutInput): string {
if (!input.terminalsSurviveRestart) return i18n.t("daemonOutdated.terminalsClose");
if (input.desktopManaged) return i18n.t("daemonOutdated.desktopUpdateTerminals");
return i18n.t("daemonOutdated.terminalsStayOpen");
}
function resolveDescription(
input: ResolveDaemonOutdatedCalloutInput,
versions: { daemon: ParsedVersion; app: ParsedVersion },
): string[] {
if (input.update.status === "failed") return [input.update.message];
const lines = [
i18n.t("daemonOutdated.versions", {
host: input.hostLabel,
daemonVersion: `v${formatVersion(versions.daemon)}`,
appVersion: `v${formatVersion(versions.app)}`,
}),
];
if (input.desktopManaged) {
lines.push(i18n.t("daemonOutdated.desktopManaged"));
} else if (!input.supportsSelfUpdate) {
lines.push(i18n.t("daemonOutdated.selfUpdateUnsupported"));
}
lines.push(resolveTerminalsLine(input));
return lines;
}
export function resolveDaemonOutdatedCallout(
input: ResolveDaemonOutdatedCalloutInput,
): DaemonOutdatedCalloutDescriptor | null {
const app = parseVersion(input.appVersion);
const daemon = parseVersion(input.daemonVersion);
if (!app || !daemon) return null;
if (compareVersions(daemon, app) >= 0) return null;
// The update RPC refuses desktop-managed daemons; those update with Paseo Desktop on that computer.
const canSelfUpdate = input.supportsSelfUpdate && !input.desktopManaged;
const isFailed = input.update.status === "failed";
return {
id: "daemon-outdated",
dismissalKey: `daemon-outdated:${input.serverId}:${formatVersion(daemon)}`,
priority: 250,
title: isFailed ? i18n.t("daemonOutdated.updateFailedTitle") : i18n.t("daemonOutdated.title"),
description: resolveDescription(input, { daemon, app }),
variant: isFailed ? "error" : "default",
updateAction: canSelfUpdate ? resolveUpdateAction(input) : null,
testID: "daemon-outdated-callout",
};
}
+1
View File
@@ -0,0 +1 @@
export * from "./sidebar-callout-slot.native";
@@ -0,0 +1,3 @@
export function SidebarCalloutSlot() {
return null;
}
@@ -91,7 +91,7 @@ describe("SidebarCalloutSlot", () => {
root?.render( root?.render(
<SidebarCalloutProvider> <SidebarCalloutProvider>
<RegisterCallout /> <RegisterCallout />
<SidebarCalloutSlot nativeOnly={false} /> <SidebarCalloutSlot />
</SidebarCalloutProvider>, </SidebarCalloutProvider>,
); );
await Promise.resolve(); await Promise.resolve();
@@ -105,7 +105,7 @@ describe("SidebarCalloutSlot", () => {
await act(async () => { await act(async () => {
root?.render( root?.render(
<SidebarCalloutProvider> <SidebarCalloutProvider>
<SidebarCalloutSlot nativeOnly={false} /> <SidebarCalloutSlot />
</SidebarCalloutProvider>, </SidebarCalloutProvider>,
); );
await Promise.resolve(); await Promise.resolve();
@@ -2,14 +2,10 @@ import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context"; import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context";
/** export function SidebarCalloutSlot() {
* `nativeOnly` limits the slot to callouts marked `native`. Native apps and every compact
* sidebar pass it: the other callouts are about the desktop app.
*/
export function SidebarCalloutSlot({ nativeOnly }: { nativeOnly: boolean }) {
return ( return (
<View style={styles.slot} collapsable={false}> <View style={styles.slot} collapsable={false}>
<SidebarCalloutViewport nativeOnly={nativeOnly} /> <SidebarCalloutViewport />
</View> </View>
); );
} }
@@ -21,7 +21,6 @@ import {
showSidebarCallout, showSidebarCallout,
type SidebarCalloutEntry, type SidebarCalloutEntry,
type SidebarCalloutOptions, type SidebarCalloutOptions,
type SidebarCalloutSelectionState,
type SidebarCalloutState, type SidebarCalloutState,
unregisterSidebarCallout, unregisterSidebarCallout,
} from "./sidebar-callout-state"; } from "./sidebar-callout-state";
@@ -38,7 +37,7 @@ export interface SidebarCalloutsApi {
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals"; const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null); const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutSelectionState | null>(null); const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void { function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem( void AsyncStorage.setItem(
@@ -114,10 +113,11 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
}); });
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]); const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
return ( return (
<SidebarCalloutApiContext.Provider value={api}> <SidebarCalloutApiContext.Provider value={api}>
<SidebarCalloutStateContext.Provider value={state}> <SidebarCalloutStateContext.Provider value={activeCallout}>
{children} {children}
</SidebarCalloutStateContext.Provider> </SidebarCalloutStateContext.Provider>
</SidebarCalloutApiContext.Provider> </SidebarCalloutApiContext.Provider>
@@ -132,20 +132,12 @@ export function useSidebarCallouts(): SidebarCalloutsApi {
return api; return api;
} }
export function useActiveSidebarCallout( export function useActiveSidebarCallout(): SidebarCalloutEntry | null {
options: { nativeOnly?: boolean } = {}, return useContext(SidebarCalloutStateContext);
): SidebarCalloutEntry | null {
const state = useContext(SidebarCalloutStateContext);
const nativeOnly = options.nativeOnly === true;
return useMemo(
() => (state ? selectActiveSidebarCallout(state, { nativeOnly }) : null),
[nativeOnly, state],
);
} }
/** The active callout. `nativeOnly` limits it to callouts marked `native`, for native apps and compact sidebars. */ export function SidebarCalloutViewport() {
export function SidebarCalloutViewport({ nativeOnly = false }: { nativeOnly?: boolean }) { const activeCallout = useActiveSidebarCallout();
const activeCallout = useActiveSidebarCallout({ nativeOnly });
const api = useSidebarCallouts(); const api = useSidebarCallouts();
if (!activeCallout) { if (!activeCallout) {
return null; return null;
@@ -31,30 +31,6 @@ describe("sidebar callout state", () => {
expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts"); expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts");
}); });
it("selects only callouts marked native for the native sidebar", () => {
let state = createSidebarCalloutState();
state = showSidebarCallout(state, {
id: "desktop-update",
priority: 300,
title: "Update available",
}).state;
state = showSidebarCallout(state, {
id: "daemon-outdated",
priority: 250,
title: "Daemon out of date",
native: true,
}).state;
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
expect(selectActiveSidebarCallout(state, { nativeOnly: true })?.title).toBe(
"Daemon out of date",
);
state = dismissSidebarCallout(state, "daemon-outdated").state;
expect(selectActiveSidebarCallout(state, { nativeOnly: true })).toBeNull();
});
it("replaces a callout by id without duplicating the queue item", () => { it("replaces a callout by id without duplicating the queue item", () => {
let state = createSidebarCalloutState(); let state = createSidebarCalloutState();
state = showSidebarCallout(state, { state = showSidebarCallout(state, {
@@ -14,8 +14,6 @@ export interface SidebarCalloutOptions {
priority?: number; priority?: number;
onDismiss?: () => void; onDismiss?: () => void;
testID?: string; testID?: string;
/** Also shown in the iOS and Android sidebar and every compact sidebar. Callouts about the desktop app leave it unset. */
native?: boolean;
} }
export interface SidebarCalloutEntry extends SidebarCalloutOptions { export interface SidebarCalloutEntry extends SidebarCalloutOptions {
@@ -131,19 +129,10 @@ export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCallout
return { ...state, callouts: [] }; return { ...state, callouts: [] };
} }
export type SidebarCalloutSelectionState = Pick<
SidebarCalloutState,
"callouts" | "dismissedKeys" | "dismissalStorageLoaded"
>;
export function selectActiveSidebarCallout( export function selectActiveSidebarCallout(
state: SidebarCalloutSelectionState, state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
options: { nativeOnly?: boolean } = {},
): SidebarCalloutEntry | null { ): SidebarCalloutEntry | null {
const visibleCallouts = state.callouts.filter((entry) => { const visibleCallouts = state.callouts.filter((entry) => {
if (options.nativeOnly && entry.native !== true) {
return false;
}
const dismissalKey = normalizeDismissalKey(entry.dismissalKey); const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) { if (!dismissalKey) {
return true; return true;
+8 -12
View File
@@ -2058,18 +2058,6 @@ export const ar: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "رفض", dismiss: "رفض",
}, },
daemonOutdated: {
title: "الخدمة قديمة",
versions: "يشغّل {{host}} الخدمة {{daemonVersion}}. إصدار هذا التطبيق {{appVersion}}.",
terminalsStayOpen: "يعيد التحديث تشغيل الخدمة. تبقى الطرفيات مفتوحة.",
terminalsClose: "يعيد التحديث تشغيل الخدمة ويغلق الطرفيات المفتوحة.",
desktopUpdateTerminals:
"يُبقي تحديث Paseo Desktop الطرفيات مفتوحة على macOS وLinux، ويغلقها على Windows.",
desktopManaged: "يدير Paseo Desktop هذه الخدمة. حدّث Paseo على ذلك الحاسوب.",
selfUpdateUnsupported: "لا يمكن لهذه الخدمة تحديث نفسها. حدّث Paseo على ذلك الحاسوب.",
update: "تحديث",
updateFailedTitle: "فشل تحديث الخدمة",
},
contextWindow: { contextWindow: {
noData: "لا توجد بيانات للسياق", noData: "لا توجد بيانات للسياق",
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق", accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
@@ -2790,6 +2778,14 @@ export const ar: TranslationResources = {
}, },
agents: { agents: {
unavailable: "Connect to this host to manage agents", unavailable: "Connect to this host to manage agents",
resumeOnStartup: {
title: "استئناف الوكلاء بعد إعادة التشغيل",
info: "إعادة فتح الوكلاء الذين كانوا يعملون عند توقف المضيف وإرسال طلب الاستئناف. ينطبق ذلك بعد إعادة التشغيل العادية والتعطّل.",
hint: "متابعة العمل المنقطع تلقائيًا.",
prompt: "طلب الاستئناف",
edit: "تحرير",
editAccessibilityLabel: "تعديل الطلب",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "تحرير الوكلاء الخاملين", title: "تحرير الوكلاء الخاملين",
hint: "تحرير الذاكرة بعد ساعة من الخمول.", hint: "تحرير الذاكرة بعد ساعة من الخمول.",
+8 -12
View File
@@ -2083,18 +2083,6 @@ export const en = {
sidebarCallout: { sidebarCallout: {
dismiss: "Dismiss", dismiss: "Dismiss",
}, },
daemonOutdated: {
title: "Daemon out of date",
versions: "{{host}} runs daemon {{daemonVersion}}. This app is {{appVersion}}.",
terminalsStayOpen: "Updating restarts the daemon. Terminals stay open.",
terminalsClose: "Updating restarts the daemon and closes open terminals.",
desktopUpdateTerminals:
"Updating Paseo Desktop keeps terminals open on macOS and Linux, and closes them on Windows.",
desktopManaged: "Paseo Desktop manages this daemon. Update Paseo on that computer.",
selfUpdateUnsupported: "This daemon can't update itself. Update Paseo on that computer.",
update: "Update",
updateFailedTitle: "Daemon update failed",
},
contextWindow: { contextWindow: {
noData: "No context data", noData: "No context data",
accessibilityNoData: "Context window: No context data", accessibilityNoData: "Context window: No context data",
@@ -2882,6 +2870,14 @@ export const en = {
}, },
agents: { agents: {
unavailable: "Connect to this host to manage agents", unavailable: "Connect to this host to manage agents",
resumeOnStartup: {
title: "Resume agents after restart",
info: "Reopen agents that were working when the host stopped and send the resume prompt. Applies after graceful restarts and crashes.",
hint: "Continue interrupted work automatically.",
prompt: "Resume prompt",
edit: "Edit",
editAccessibilityLabel: "Edit prompt",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "Release idle agents", title: "Release idle agents",
hint: "Free up memory after an hour of inactivity.", hint: "Free up memory after an hour of inactivity.",
+8 -13
View File
@@ -2106,19 +2106,6 @@ export const es: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "Despedir", dismiss: "Despedir",
}, },
daemonOutdated: {
title: "Daemon desactualizado",
versions: "{{host}} ejecuta el daemon {{daemonVersion}}. Esta app es {{appVersion}}.",
terminalsStayOpen: "Actualizar reinicia el daemon. Las terminales siguen abiertas.",
terminalsClose: "Actualizar reinicia el daemon y cierra las terminales abiertas.",
desktopUpdateTerminals:
"Actualizar Paseo Desktop mantiene las terminales abiertas en macOS y Linux, y las cierra en Windows.",
desktopManaged: "Paseo Desktop administra este daemon. Actualiza Paseo en esa computadora.",
selfUpdateUnsupported:
"Este daemon no puede actualizarse solo. Actualiza Paseo en esa computadora.",
update: "Actualizar",
updateFailedTitle: "No se pudo actualizar el daemon",
},
contextWindow: { contextWindow: {
noData: "No hay datos de contexto", noData: "No hay datos de contexto",
accessibilityNoData: "Ventana de contexto: No hay datos de contexto", accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
@@ -2813,6 +2800,14 @@ export const es: TranslationResources = {
}, },
agents: { agents: {
unavailable: "Connect to this host to manage agents", unavailable: "Connect to this host to manage agents",
resumeOnStartup: {
title: "Reanudar agentes tras reiniciar",
info: "Vuelve a abrir los agentes que estaban trabajando cuando se detuvo el host y envía el mensaje de reanudación. Se aplica tras reinicios normales y fallos.",
hint: "Continuar automáticamente el trabajo interrumpido.",
prompt: "Mensaje de reanudación",
edit: "Editar",
editAccessibilityLabel: "Editar mensaje",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "Liberar agentes inactivos", title: "Liberar agentes inactivos",
hint: "Libera memoria tras una hora de inactividad.", hint: "Libera memoria tras una hora de inactividad.",
+8 -13
View File
@@ -2105,19 +2105,6 @@ export const fr: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "Ignorer", dismiss: "Ignorer",
}, },
daemonOutdated: {
title: "Daemon obsolète",
versions: "{{host}} exécute le daemon {{daemonVersion}}. Cette app est en {{appVersion}}.",
terminalsStayOpen: "La mise à jour redémarre le daemon. Les terminaux restent ouverts.",
terminalsClose: "La mise à jour redémarre le daemon et ferme les terminaux ouverts.",
desktopUpdateTerminals:
"La mise à jour de Paseo Desktop garde les terminaux ouverts sur macOS et Linux, et les ferme sur Windows.",
desktopManaged: "Paseo Desktop gère ce daemon. Mettez à jour Paseo sur cet ordinateur.",
selfUpdateUnsupported:
"Ce daemon ne peut pas se mettre à jour lui-même. Mettez à jour Paseo sur cet ordinateur.",
update: "Mettre à jour",
updateFailedTitle: "Échec de la mise à jour du daemon",
},
contextWindow: { contextWindow: {
noData: "Aucune donnée de contexte", noData: "Aucune donnée de contexte",
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte", accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
@@ -2816,6 +2803,14 @@ export const fr: TranslationResources = {
}, },
agents: { agents: {
unavailable: "Connectez-vous à cet hôte pour gérer les agents", unavailable: "Connectez-vous à cet hôte pour gérer les agents",
resumeOnStartup: {
title: "Reprendre les agents après un redémarrage",
info: "Rouvre les agents qui travaillaient lorsque l’hôte s’est arrêté et envoie le message de reprise. S’applique après un redémarrage normal ou un plantage.",
hint: "Continuer automatiquement le travail interrompu.",
prompt: "Message de reprise",
edit: "Modifier",
editAccessibilityLabel: "Modifier le message",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "Libérer les agents inactifs", title: "Libérer les agents inactifs",
hint: "Libère de la mémoire après une heure d’inactivité.", hint: "Libère de la mémoire après une heure d’inactivité.",
+8 -14
View File
@@ -2078,20 +2078,6 @@ export const ja: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "閉じる", dismiss: "閉じる",
}, },
daemonOutdated: {
title: "デーモンが古くなっています",
versions: "{{host}}のデーモンは{{daemonVersion}}です。このアプリは{{appVersion}}です。",
terminalsStayOpen: "更新するとデーモンが再起動します。ターミナルは開いたままです。",
terminalsClose: "更新するとデーモンが再起動し、開いているターミナルは閉じられます。",
desktopUpdateTerminals:
"Paseo Desktop を更新すると、macOS と Linux ではターミナルは開いたままですが、Windows では閉じられます。",
desktopManaged:
"このデーモンはPaseo Desktopによって管理されています。そのコンピューターでPaseoを更新してください。",
selfUpdateUnsupported:
"このデーモンは自身を更新できません。そのコンピューターでPaseoを更新してください。",
update: "更新",
updateFailedTitle: "デーモンを更新できませんでした",
},
contextWindow: { contextWindow: {
noData: "コンテキストデータがありません", noData: "コンテキストデータがありません",
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません", accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
@@ -2781,6 +2767,14 @@ export const ja: TranslationResources = {
}, },
agents: { agents: {
unavailable: "エージェントを管理するにはこのホストに接続してください", unavailable: "エージェントを管理するにはこのホストに接続してください",
resumeOnStartup: {
title: "再起動後にエージェントを再開",
info: "ホストの停止時に作業中だったエージェントを開き直し、再開メッセージを送信します。通常の再起動とクラッシュの両方に適用されます。",
hint: "中断された作業を自動的に続行します。",
prompt: "再開メッセージ",
edit: "編集",
editAccessibilityLabel: "メッセージを編集",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "アイドル状態のエージェントを解放", title: "アイドル状態のエージェントを解放",
hint: "1 時間のアイドル状態の後にメモリを解放します。", hint: "1 時間のアイドル状態の後にメモリを解放します。",
+8 -14
View File
@@ -2068,20 +2068,6 @@ export const ko: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "닫기", dismiss: "닫기",
}, },
daemonOutdated: {
title: "데몬이 오래되었습니다",
versions: "{{host}}의 데몬은 {{daemonVersion}}입니다. 이 앱은 {{appVersion}}입니다.",
terminalsStayOpen: "업데이트하면 데몬이 다시 시작됩니다. 터미널은 열린 상태로 유지됩니다.",
terminalsClose: "업데이트하면 데몬이 다시 시작되고 열려 있는 터미널이 닫힙니다.",
desktopUpdateTerminals:
"Paseo Desktop을 업데이트하면 macOS와 Linux에서는 터미널이 열린 상태로 유지되고, Windows에서는 닫힙니다.",
desktopManaged:
"이 데몬은 Paseo Desktop에서 관리됩니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
selfUpdateUnsupported:
"이 데몬은 스스로 업데이트할 수 없습니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
update: "업데이트",
updateFailedTitle: "데몬 업데이트 실패",
},
contextWindow: { contextWindow: {
noData: "컨텍스트 데이터 없음", noData: "컨텍스트 데이터 없음",
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음", accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
@@ -2769,6 +2755,14 @@ export const ko: TranslationResources = {
}, },
agents: { agents: {
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요", unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
resumeOnStartup: {
title: "재시작 후 에이전트 재개",
info: "호스트가 중지될 때 작업 중이던 에이전트를 다시 열고 재개 메시지를 보냅니다. 정상 재시작과 비정상 종료 후 모두 적용됩니다.",
hint: "중단된 작업을 자동으로 계속합니다.",
prompt: "재개 메시지",
edit: "편집",
editAccessibilityLabel: "메시지 편집",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "유휴 에이전트 해제", title: "유휴 에이전트 해제",
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.", hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
+8 -13
View File
@@ -2091,19 +2091,6 @@ export const ptBR: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "Dispensar", dismiss: "Dispensar",
}, },
daemonOutdated: {
title: "Daemon desatualizado",
versions: "{{host}} executa o daemon {{daemonVersion}}. Este app é {{appVersion}}.",
terminalsStayOpen: "Atualizar reinicia o daemon. Os terminais continuam abertos.",
terminalsClose: "Atualizar reinicia o daemon e fecha os terminais abertos.",
desktopUpdateTerminals:
"Atualizar o Paseo Desktop mantém os terminais abertos no macOS e no Linux, e os fecha no Windows.",
desktopManaged: "O Paseo Desktop gerencia este daemon. Atualize o Paseo nesse computador.",
selfUpdateUnsupported:
"Este daemon não consegue se atualizar sozinho. Atualize o Paseo nesse computador.",
update: "Atualizar",
updateFailedTitle: "Falha ao atualizar o daemon",
},
contextWindow: { contextWindow: {
noData: "Sem dados de contexto", noData: "Sem dados de contexto",
accessibilityNoData: "Janela de contexto: sem dados de contexto", accessibilityNoData: "Janela de contexto: sem dados de contexto",
@@ -2795,6 +2782,14 @@ export const ptBR: TranslationResources = {
}, },
agents: { agents: {
unavailable: "Conecte-se a este host para gerenciar agentes", unavailable: "Conecte-se a este host para gerenciar agentes",
resumeOnStartup: {
title: "Retomar agentes após reiniciar",
info: "Reabre os agentes que estavam trabalhando quando o host parou e envia a mensagem de retomada. Aplica-se após reinicializações normais e falhas.",
hint: "Continuar automaticamente o trabalho interrompido.",
prompt: "Mensagem de retomada",
edit: "Editar",
editAccessibilityLabel: "Editar mensagem",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "Liberar agentes ociosos", title: "Liberar agentes ociosos",
hint: "Libera memória após uma hora de inatividade.", hint: "Libera memória após uma hora de inatividade.",
+8 -12
View File
@@ -2091,18 +2091,6 @@ export const ru: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "Закрыть", dismiss: "Закрыть",
}, },
daemonOutdated: {
title: "Демон устарел",
versions: "На {{host}} работает демон {{daemonVersion}}. Версия приложения: {{appVersion}}.",
terminalsStayOpen: "Обновление перезапустит демон. Терминалы останутся открытыми.",
terminalsClose: "Обновление перезапустит демон и закроет открытые терминалы.",
desktopUpdateTerminals:
"Обновление Paseo Desktop оставляет терминалы открытыми в macOS и Linux и закрывает их в Windows.",
desktopManaged: "Этим демоном управляет Paseo Desktop. Обновите Paseo на том компьютере.",
selfUpdateUnsupported: "Этот демон не может обновиться сам. Обновите Paseo на том компьютере.",
update: "Обновить",
updateFailedTitle: "Не удалось обновить демон",
},
contextWindow: { contextWindow: {
noData: "Нет данных о контексте", noData: "Нет данных о контексте",
accessibilityNoData: "Контекстное окно: нет данных о контексте", accessibilityNoData: "Контекстное окно: нет данных о контексте",
@@ -2804,6 +2792,14 @@ export const ru: TranslationResources = {
}, },
agents: { agents: {
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами", unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
resumeOnStartup: {
title: "Возобновлять агентов после перезапуска",
info: "Открывает агентов, которые работали при остановке хоста, и отправляет сообщение для продолжения. Применяется после обычного перезапуска и сбоя.",
hint: "Автоматически продолжать прерванную работу.",
prompt: "Сообщение для продолжения",
edit: "Редактировать",
editAccessibilityLabel: "Изменить сообщение",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "Освобождать неактивных агентов", title: "Освобождать неактивных агентов",
hint: "Освобождает память после часа бездействия.", hint: "Освобождает память после часа бездействия.",
+8 -12
View File
@@ -2036,18 +2036,6 @@ export const zhCN: TranslationResources = {
sidebarCallout: { sidebarCallout: {
dismiss: "关闭", dismiss: "关闭",
}, },
daemonOutdated: {
title: "守护进程版本过旧",
versions: "{{host}} 运行的守护进程为 {{daemonVersion}},此应用为 {{appVersion}}。",
terminalsStayOpen: "更新会重启守护进程,终端将保持打开。",
terminalsClose: "更新会重启守护进程,并关闭已打开的终端。",
desktopUpdateTerminals:
"更新 Paseo Desktop 时,macOS 和 Linux 上的终端保持打开,Windows 上的终端会被关闭。",
desktopManaged: "此守护进程由 Paseo Desktop 管理。请在那台电脑上更新 Paseo。",
selfUpdateUnsupported: "此守护进程无法自行更新。请在那台电脑上更新 Paseo。",
update: "更新",
updateFailedTitle: "守护进程更新失败",
},
contextWindow: { contextWindow: {
noData: "暂无上下文数据", noData: "暂无上下文数据",
accessibilityNoData: "上下文窗口:暂无上下文数据", accessibilityNoData: "上下文窗口:暂无上下文数据",
@@ -2725,6 +2713,14 @@ export const zhCN: TranslationResources = {
}, },
agents: { agents: {
unavailable: "连接到这个 Host 以管理 Agent", unavailable: "连接到这个 Host 以管理 Agent",
resumeOnStartup: {
title: "重启后恢复智能体",
info: "重新打开主机停止时正在工作的智能体,并发送恢复提示。适用于正常重启和崩溃后的恢复。",
hint: "自动继续被中断的工作。",
prompt: "恢复提示",
edit: "编辑",
editAccessibilityLabel: "编辑提示",
},
idleRuntimeCleanup: { idleRuntimeCleanup: {
title: "释放空闲 Agent", title: "释放空闲 Agent",
hint: "空闲一小时后释放内存。", hint: "空闲一小时后释放内存。",
@@ -65,6 +65,7 @@ import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { useProviderIcon } from "@/components/provider-icons"; import { useProviderIcon } from "@/components/provider-icons";
import { BrowserToolsOptInCard } from "./browser-tools-card"; import { BrowserToolsOptInCard } from "./browser-tools-card";
import { RestartRecoveryCard } from "./restart-recovery-card";
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card"; import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle"; import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
@@ -292,6 +293,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
<InjectPaseoToolsCard serverId={serverId} /> <InjectPaseoToolsCard serverId={serverId} />
<BrowserToolsOptInCard serverId={serverId} /> <BrowserToolsOptInCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} /> <AppendSystemPromptCard serverId={serverId} />
<RestartRecoveryCard serverId={serverId} />
<IdleRuntimeCleanupCard serverId={serverId} /> <IdleRuntimeCleanupCard serverId={serverId} />
</SettingsSection> </SettingsSection>
) : ( ) : (
@@ -0,0 +1,199 @@
import { useCallback, useMemo, useState } from "react";
import { Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
const DEFAULT_RESUME_PROMPT = "Resume working.";
// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28.
export function RestartRecoveryCard({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useHostFeature(serverId, "resumeOnStartupSetting");
const { config, patchConfig } = useDaemonConfig(serverId);
const [isEditing, setIsEditing] = useState(false);
const mutation = useMutation({
mutationFn: async (next: { resumeOnStartup?: boolean; resumePrompt?: string }) => {
const result = await patchConfig(next);
if (!result) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return result;
},
});
const handleValueChange = useCallback(
(next: boolean) => {
mutation.mutate({ resumeOnStartup: next });
},
[mutation],
);
const handleOpen = useCallback(() => {
mutation.reset();
setIsEditing(true);
}, [mutation]);
const handleClose = useCallback(() => {
if (mutation.isPending) return;
mutation.reset();
setIsEditing(false);
}, [mutation]);
const handleSave = useCallback(
(prompt: string) => {
mutation.mutate({ resumePrompt: prompt }, { onSuccess: () => setIsEditing(false) });
},
[mutation],
);
if (!isConnected || !isSupported || !config) return null;
const isEnabled = config.resumeOnStartup !== false;
return (
<>
<View style={settingsStyles.card} testID="host-page-restart-recovery-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<View style={styles.titleRow}>
<Text style={settingsStyles.rowTitle}>
{t("settings.host.agents.resumeOnStartup.title")}
</Text>
<SettingsInfoTip
title={t("settings.host.agents.resumeOnStartup.title")}
info={t("settings.host.agents.resumeOnStartup.info")}
/>
</View>
<Text style={settingsStyles.rowHint}>
{t("settings.host.agents.resumeOnStartup.hint")}
</Text>
{mutation.error && !isEditing ? (
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
{mutation.error.message}
</Text>
) : null}
</View>
<View style={styles.trailing}>
<Button
variant="outline"
size="sm"
onPress={handleOpen}
disabled={!isEnabled || mutation.isPending}
accessibilityLabel={t("settings.host.agents.resumeOnStartup.editAccessibilityLabel")}
testID="host-page-restart-recovery-edit"
>
{t("settings.host.agents.resumeOnStartup.edit")}
</Button>
<Switch
value={isEnabled}
onValueChange={handleValueChange}
disabled={mutation.isPending}
accessibilityLabel={t("settings.host.agents.resumeOnStartup.title")}
testID="host-page-restart-recovery-switch"
/>
</View>
</View>
</View>
{isEditing ? (
<ResumePromptSheet
initialPrompt={config.resumePrompt ?? DEFAULT_RESUME_PROMPT}
isSaving={mutation.isPending}
error={mutation.error?.message ?? null}
onSave={handleSave}
onClose={handleClose}
/>
) : null}
</>
);
}
function ResumePromptSheet({
initialPrompt,
isSaving,
error,
onSave,
onClose,
}: {
initialPrompt: string;
isSaving: boolean;
error: string | null;
onSave: (prompt: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(initialPrompt);
const header = useMemo<SheetHeader>(
() => ({ title: t("settings.host.agents.resumeOnStartup.prompt") }),
[t],
);
const canSave = draft !== initialPrompt && draft.trim().length > 0 && !isSaving;
const handleSave = useCallback(() => onSave(draft), [draft, onSave]);
return (
<AdaptiveModalSheet
header={header}
visible
onClose={onClose}
testID="host-page-restart-recovery-sheet"
desktopMaxWidth={560}
>
<SettingsTextAreaCard
testID="host-page-restart-recovery-prompt-input"
accessibilityLabel={t("settings.host.agents.resumeOnStartup.prompt")}
value={draft}
onChangeText={setDraft}
placeholder={DEFAULT_RESUME_PROMPT}
/>
{error ? (
<Text style={settingsStyles.rowError} testID="host-page-restart-recovery-error">
{error}
</Text>
) : null}
<View style={styles.sheetActions}>
<Button variant="ghost" size="sm" onPress={onClose} disabled={isSaving}>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="sm"
onPress={handleSave}
disabled={!canSave}
testID="host-page-restart-recovery-save"
>
{t("common.actions.save")}
</Button>
</View>
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
sheetActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
}));
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"bin": { "bin": {
"paseo": "bin/paseo" "paseo": "bin/paseo"
@@ -28,9 +28,9 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.1", "@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.1", "@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/server": "0.14.0-beta.1", "@getpaseo/server": "0.14.0-beta.2",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"description": "Paseo client SDK package", "description": "Paseo client SDK package",
"files": [ "files": [
"dist", "dist",
@@ -47,8 +47,8 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.14.0-beta.1", "@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.1", "@getpaseo/relay": "0.14.0-beta.2",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh", "homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"keywords": [ "keywords": [
"ExpoTwoWayAudio", "ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"files": [ "files": [
"dist", "dist",
"!dist/**/*.map", "!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"description": "Paseo plugin SDK package", "description": "Paseo plugin SDK package",
"files": [ "files": [
"dist", "dist",
@@ -77,8 +77,8 @@
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.14.0-beta.1", "@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.1", "@getpaseo/protocol": "0.14.0-beta.2",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.2.3", "react": "19.2.3",
@@ -88,8 +88,8 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.14.0-beta.1", "@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.1", "@getpaseo/protocol": "0.14.0-beta.2",
"react": ">=19.1.0 <20", "react": ">=19.1.0 <20",
"react-native": ">=0.81.5", "react-native": ">=0.81.5",
"zod": "^4.4.3" "zod": "^4.4.3"
+18
View File
@@ -255,6 +255,24 @@ describe("lifecycle hook SDK types", () => {
typeErrors(` typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server"; import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
export default function contribute(server: PluginServerContext) { export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
// @ts-expect-error Branch checkout has no branch-off base.
request.baseRef;
} else {
const base: string = request.baseRef;
}
const branch: string = request.branch;
// @ts-expect-error Repository identity is read-only.
request.repoRoot = "/other";
// @ts-expect-error Source checkout identity is read-only.
request.sourceCheckout = "/other";
if (request.kind === "checkout-change-request") {
const number: number = request.changeRequestNumber;
}
return { ...request, branch: "feature", baseRef: "main", slug: "feature", path: "/work/feature" };
});
server.addHook("worktree.create.before", async () => {});
server.addHook("worktree.create.after", async ({ worktree }) => { server.addHook("worktree.create.after", async ({ worktree }) => {
const path: string = worktree.path; const path: string = worktree.path;
const branch: string = worktree.branch; const branch: string = worktree.branch;
+1
View File
@@ -21,6 +21,7 @@ export type {
PluginHookContext, PluginHookContext,
PluginHookWorkspace, PluginHookWorkspace,
PluginHookWorktree, PluginHookWorktree,
PluginWorktreeCreateRequest,
PluginHookAgent, PluginHookAgent,
PluginSessionOpenRequest, PluginSessionOpenRequest,
PluginTurnOutcome, PluginTurnOutcome,
+36
View File
@@ -27,6 +27,40 @@ export interface PluginHookWorktree {
sourceCheckout: string; sourceCheckout: string;
} }
/** Resolved creation options before Git fetches refs or chooses a unique branch name. */
export type PluginWorktreeCreateRequest = {
readonly repoRoot: string;
readonly sourceCheckout: string;
branch: string;
slug: string;
path: string;
} & (
| { kind: "branch-off"; baseRef: string }
| { kind: "checkout-branch" }
| {
kind: "checkout-change-request";
baseRef: string;
forge: string;
changeRequestNumber: number;
headRef: string;
headRepositoryOwner?: string;
headRepository?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
| {
kind: "checkout-github-pr";
baseRef: string;
githubPrNumber: number;
headRef: string;
headRepositoryOwner?: string;
checkoutRefs?: { remoteName?: string; remoteRef: string }[];
pushRemoteUrl?: string;
trackOriginHead?: boolean;
}
);
export interface PluginHookAgent { export interface PluginHookAgent {
id: string; id: string;
workspaceId: string | null; workspaceId: string | null;
@@ -81,6 +115,7 @@ export interface PluginHookEvents {
} }
export interface PluginHookRequests { export interface PluginHookRequests {
"worktree.create.before": PluginWorktreeCreateRequest;
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> }; "agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
"agent.open.before": PluginSessionOpenRequest; "agent.open.before": PluginSessionOpenRequest;
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">; "workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
@@ -140,6 +175,7 @@ export const pluginHookRegistry = {
"workspace.create.before": { waits: true }, "workspace.create.before": { waits: true },
"workspace.create.after": { waits: false }, "workspace.create.after": { waits: false },
"workspace.archive.after": { waits: false }, "workspace.archive.after": { waits: false },
"worktree.create.before": { waits: true },
"worktree.create.after": { waits: true }, "worktree.create.after": { waits: true },
"agent.create.before": { waits: true }, "agent.create.before": { waits: true },
"agent.create.after": { waits: false }, "agent.create.after": { waits: false },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"description": "Paseo shared protocol schemas and wire types", "description": "Paseo shared protocol schemas and wire types",
"files": [ "files": [
"dist", "dist",
+7
View File
@@ -237,6 +237,9 @@ export const MutableDaemonConfigSchema = z
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported. // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported.
// Persisted host-wide value, applied live by the daemon. // Persisted host-wide value, applied live by the daemon.
idleRuntimeCleanup: z.boolean().optional(), idleRuntimeCleanup: z.boolean().optional(),
// COMPAT(restartRecovery): added in v0.14.0, keep optional for older hosts.
resumeOnStartup: z.boolean().optional(),
resumePrompt: z.string().optional(),
appendSystemPrompt: z.string().default(""), appendSystemPrompt: z.string().default(""),
terminalProfiles: z.array(TerminalProfileSchema).optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(),
@@ -262,6 +265,8 @@ export const MutableDaemonConfigPatchSchema = z
autoArchiveAfterMerge: z.boolean().optional(), autoArchiveAfterMerge: z.boolean().optional(),
enableTerminalAgentHooks: z.boolean().optional(), enableTerminalAgentHooks: z.boolean().optional(),
idleRuntimeCleanup: z.boolean().optional(), idleRuntimeCleanup: z.boolean().optional(),
resumeOnStartup: z.boolean().optional(),
resumePrompt: z.string().optional(),
appendSystemPrompt: z.string().optional(), appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(),
@@ -3731,6 +3736,8 @@ export const ServerInfoStatusPayloadSchema = z
skillManagement: z.boolean().optional(), skillManagement: z.boolean().optional(),
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26. // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
idleRuntimeCleanupSetting: z.boolean().optional(), idleRuntimeCleanupSetting: z.boolean().optional(),
// COMPAT(restartRecovery): added in v0.14.0, remove gate after 2027-04-28.
resumeOnStartupSetting: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(), "terminal-restore-modes": z.boolean().optional(),
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"files": [ "files": [
"dist", "dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.14.0-beta.1", "version": "0.14.0-beta.2",
"description": "Paseo backend server", "description": "Paseo backend server",
"files": [ "files": [
"dist/server", "dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.1", "@getpaseo/client": "0.14.0-beta.2",
"@getpaseo/highlight": "0.14.0-beta.1", "@getpaseo/highlight": "0.14.0-beta.2",
"@getpaseo/plugin": "0.14.0-beta.1", "@getpaseo/plugin": "0.14.0-beta.2",
"@getpaseo/protocol": "0.14.0-beta.1", "@getpaseo/protocol": "0.14.0-beta.2",
"@getpaseo/relay": "0.14.0-beta.1", "@getpaseo/relay": "0.14.0-beta.2",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
@@ -135,6 +135,7 @@ export async function ensureAgentLoaded(
labels: record.labels, labels: record.labels,
workspaceId: record.workspaceId, workspaceId: record.workspaceId,
owner: record.owner, owner: record.owner,
env: record.launchEnv,
}); });
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
} }
@@ -2929,6 +2929,92 @@ test("createAgent passes daemon launch env through the provider launch context",
}); });
}); });
test.each([
{ provider: "codex", hasHandle: true },
{ provider: "opencode", hasHandle: true },
{ provider: "codex", hasHandle: false },
{ provider: "opencode", hasHandle: false },
])(
"$provider preserves explicit launch env across reloads and storage-backed resumes (handle: $hasHandle)",
async ({ provider, hasHandle }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-"));
const storagePath = join(workdir, "agents");
class LaunchEnvClient extends TestAgentClient {
lastLaunchContext: AgentLaunchContext | undefined;
override async createSession(
config: AgentSessionConfig,
context?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = context;
const session = await super.createSession(config);
session.describePersistence = () => ({ provider, sessionId: session.id });
return session;
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
context?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = context;
const session = await super.resumeSession(handle, config, context);
session.describePersistence = () => ({ provider, sessionId: handle.sessionId });
return session;
}
}
const client = new LaunchEnvClient(provider);
let storage = new AgentStorage(storagePath, logger);
let manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
const env = { XDG_DATA_HOME: join(workdir, "agent-data"), CUSTOM_EMPTY: "" };
const expectedEnv = { ...env };
const agent = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
env,
});
onTestFinished(async () => {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
env.XDG_DATA_HOME = "caller-mutated";
await manager.closeAgent(agent.id);
await manager.flush();
if (!hasHandle) {
const record = await storage.get(agent.id);
if (!record) throw new Error("Created agent was not persisted");
await storage.upsert({ ...record, persistence: null });
}
// Fresh storage and manager instances must read the environment from disk.
for (let restart = 0; restart < 2; restart += 1) {
storage = new AgentStorage(storagePath, logger);
manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
const resumed = await ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(client.lastLaunchContext).toEqual({
agentId: agent.id,
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
});
await manager.reloadAgentSession(agent.id);
expect(client.lastLaunchContext).toEqual({
agentId: agent.id,
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
});
expect(toAgentPayload(resumed)).not.toHaveProperty("launchEnv");
expect(toAgentPayload(resumed).persistence?.metadata).not.toHaveProperty("launchEnv");
await manager.closeAgent(agent.id);
await manager.flush();
}
},
);
test("createAgent passes persistSession to provider create options", async () => { test("createAgent passes persistSession to provider create options", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
@@ -421,6 +421,8 @@ interface ManagedAgentBase {
owner?: AgentOwner; owner?: AgentOwner;
capabilities: AgentCapabilityFlags; capabilities: AgentCapabilityFlags;
config: AgentSessionConfig; config: AgentSessionConfig;
/** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */
launchEnv?: Record<string, string>;
runtimeInfo?: AgentRuntimeInfo; runtimeInfo?: AgentRuntimeInfo;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -1399,6 +1401,7 @@ export class AgentManager {
initialTitle: options.initialTitle, initialTitle: options.initialTitle,
workspaceId: options.workspaceId, workspaceId: options.workspaceId,
owner: options.owner, owner: options.owner,
launchEnv: options.env ? { ...options.env } : undefined,
historyPrimed: true, historyPrimed: true,
}); });
if (!agent.internal) { if (!agent.internal) {
@@ -1487,10 +1490,11 @@ export class AgentManager {
: resumeOptions; : resumeOptions;
const purpose = currentResumeOptions?.purpose ?? "interactive"; const purpose = currentResumeOptions?.purpose ?? "interactive";
const launchEnv = record?.launchEnv;
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig, mergedConfig,
resolvedAgentId, resolvedAgentId,
{ purpose }, { purpose, env: launchEnv },
); );
const client = this.requireClient(handle.provider); const client = this.requireClient(handle.provider);
const available = await client.isAvailable(); const available = await client.isAvailable();
@@ -1505,7 +1509,7 @@ export class AgentManager {
client, client,
storedConfig.cwd, storedConfig.cwd,
paseoToolPolicy, paseoToolPolicy,
undefined, launchEnv,
{ {
reason: "resume", reason: "resume",
purpose, purpose,
@@ -1524,6 +1528,7 @@ export class AgentManager {
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, { return this.registerSession(session, storedConfig, resolvedAgentId, {
...options, ...options,
launchEnv,
persistence: handle, persistence: handle,
restoring: true, restoring: true,
}); });
@@ -1661,6 +1666,7 @@ export class AgentManager {
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
refreshConfig, refreshConfig,
agentId, agentId,
{ env: existing.launchEnv },
); );
const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId); const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId);
const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId); const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId);
@@ -1669,7 +1675,7 @@ export class AgentManager {
client, client,
storedConfig.cwd, storedConfig.cwd,
paseoToolPolicy, paseoToolPolicy,
undefined, existing.launchEnv,
{ {
reason: "refresh", reason: "refresh",
purpose: "interactive", purpose: "interactive",
@@ -1720,6 +1726,7 @@ export class AgentManager {
labels: existing.labels, labels: existing.labels,
workspaceId: existing.workspaceId, workspaceId: existing.workspaceId,
owner: existing.owner, owner: existing.owner,
launchEnv: existing.launchEnv,
createdAt: existing.createdAt, createdAt: existing.createdAt,
updatedAt: existing.updatedAt, updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt, lastUserMessageAt: existing.lastUserMessageAt,
@@ -3763,6 +3770,7 @@ export class AgentManager {
publishWhenReady?: boolean; publishWhenReady?: boolean;
workspaceId?: string; workspaceId?: string;
owner?: AgentOwner; owner?: AgentOwner;
launchEnv?: Record<string, string>;
}, },
): Promise<ManagedAgent> { ): Promise<ManagedAgent> {
let registered = false; let registered = false;
@@ -3948,6 +3956,7 @@ export class AgentManager {
persistence?: AgentPersistenceHandle; persistence?: AgentPersistenceHandle;
workspaceId?: string; workspaceId?: string;
owner?: AgentOwner; owner?: AgentOwner;
launchEnv?: Record<string, string>;
} }
| undefined; | undefined;
}): ActiveManagedAgent { }): ActiveManagedAgent {
@@ -3958,6 +3967,7 @@ export class AgentManager {
cwd: config.cwd, cwd: config.cwd,
workspaceId: options?.workspaceId, workspaceId: options?.workspaceId,
owner: options?.owner, owner: options?.owner,
launchEnv: options?.launchEnv,
session, session,
capabilities: session.capabilities, capabilities: session.capabilities,
config, config,
@@ -95,6 +95,7 @@ export function toStoredAgentRecord(
lastStatus: agent.lifecycle, lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null, lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null, config: config ?? null,
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
runtimeInfo, runtimeInfo,
features: normalizeFeatures(agent.features), features: normalizeFeatures(agent.features),
persistence, persistence,
@@ -54,8 +54,10 @@ const STORED_AGENT_SCHEMA = z.object({
title: z.string().nullable().optional(), title: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}), labels: z.record(z.string(), z.string()).default({}),
lastStatus: AgentStatusSchema.default("closed"), lastStatus: AgentStatusSchema.default("closed"),
resumeOnStartup: z.boolean().optional(),
lastModeId: z.string().nullable().optional(), lastModeId: z.string().nullable().optional(),
config: SERIALIZABLE_CONFIG_SCHEMA, config: SERIALIZABLE_CONFIG_SCHEMA,
launchEnv: z.record(z.string(), z.string()).optional(),
runtimeInfo: z runtimeInfo: z
.object({ .object({
provider: z.string(), provider: z.string(),
@@ -259,6 +261,8 @@ export class AgentStorage {
if (existing && existing.archivedAt !== undefined) { if (existing && existing.archivedAt !== undefined) {
record.archivedAt = existing.archivedAt; record.archivedAt = existing.archivedAt;
} }
record.resumeOnStartup =
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
return record; return record;
}); });
} }
@@ -13,6 +13,8 @@ import type { FinishObservation } from "../index.js";
export function createFinishObservation(input: { export function createFinishObservation(input: {
agentManager: AgentManager; agentManager: AgentManager;
agentStorage: AgentStorage; agentStorage: AgentStorage;
/** A child chosen for restart recovery reports its state only after its resume was sent. */
waitForResume(agentId: string, signal: AbortSignal): Promise<void>;
messages(): AgentMessageDelivery; messages(): AgentMessageDelivery;
hosts(): ToolHosts; hosts(): ToolHosts;
serverId: string; serverId: string;
@@ -24,6 +26,7 @@ export function createFinishObservation(input: {
hosts: input.hosts, hosts: input.hosts,
local: { local: {
async wait(agentId, signal) { async wait(agentId, signal) {
await input.waitForResume(agentId, signal);
await input.messages().waitForDispatch(agentId, signal); await input.messages().waitForDispatch(agentId, signal);
signal.throwIfAborted(); signal.throwIfAborted();
const live = agentManager.getAgent(agentId); const live = agentManager.getAgent(agentId);
@@ -52,6 +55,7 @@ export function createFinishObservation(input: {
}; };
}, },
async observe(agentId, handler, signal, onError) { async observe(agentId, handler, signal, onError) {
await input.waitForResume(agentId, signal);
const stored = await agentStorage.get(agentId); const stored = await agentStorage.get(agentId);
let updates = Promise.resolve(); let updates = Promise.resolve();
const publish = (snapshot: AgentSnapshotPayload | null, event?: AgentStreamEvent) => { const publish = (snapshot: AgentSnapshotPayload | null, event?: AgentStreamEvent) => {
@@ -881,6 +881,7 @@ async function createAgentMcpServer(
logger: options.logger, logger: options.logger,
...createFinishObservation({ ...createFinishObservation({
...options, ...options,
waitForResume: async () => {},
messages: () => messages, messages: () => messages,
serverId: options.serverId ?? "self", serverId: options.serverId ?? "self",
hosts: () => { hosts: () => {
@@ -4,6 +4,8 @@ import { join } from "node:path";
import { AgentStorage } from "./agent-storage.js"; import { AgentStorage } from "./agent-storage.js";
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js"; import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
import { relative } from "node:path"; import { relative } from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import type { import type {
ProviderConnection, ProviderConnection,
ProviderLaunch, ProviderLaunch,
@@ -25,6 +27,21 @@ import {
StaleProviderSessionError, StaleProviderSessionError,
} from "./stale-provider-session-error.js"; } from "./stale-provider-session-error.js";
test("long concurrent plugin and child streams retain text-sized history and release it on archive", () => {
const fixture = fileURLToPath(new URL("./test-utils/plugin-stream-memory.ts", import.meta.url));
const output = execFileSync(
process.execPath,
["--expose-gc", "--max-old-space-size=512", "--import", "tsx", fixture],
{
encoding: "utf8",
timeout: 20_000,
},
);
const memory = JSON.parse(output) as { retained: number; closed: number };
expect(memory.retained).toBeLessThan(24 * 1024 * 1024);
expect(memory.closed).toBeLessThan(6 * 1024 * 1024);
});
const CAPABILITIES = [ const CAPABILITIES = [
"prompt.message", "prompt.message",
"session.configure", "session.configure",
@@ -292,6 +309,99 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
} }
describe("PluginAgentClientRegistry", () => { describe("PluginAgentClientRegistry", () => {
test("history keeps the tool's first position when completion follows later text", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: client.provider, cwd: "/tmp" });
const opened = harness.inputs.find((input) => input.type === "session.open");
if (opened?.type !== "session.open") throw new Error("missing session open");
const tool = {
type: "tool_call" as const,
id: "tool-position",
callId: "call-position",
name: "read",
detail: { type: "unknown" as const, input: null, output: null },
error: null,
};
try {
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { ...tool, status: "running" },
});
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { type: "assistant_message", id: "later-text", text: "Working" },
});
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { ...tool, status: "completed" },
});
const items = [];
for await (const event of session.streamHistory())
if (event.type === "timeline") items.push(event.item);
expect(items).toEqual([
{
type: "tool_call",
callId: "call-position",
name: "read",
detail: tool.detail,
error: null,
status: "completed",
},
{ type: "assistant_message", messageId: "later-text", text: "Working" },
]);
} finally {
await session.close();
await registry.shutdown();
}
});
test("detached text deltas preserve split surrogates and history replays one complete message", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: client.provider, cwd: "/tmp" });
const opened = harness.inputs.find((input) => input.type === "session.open");
if (opened?.type !== "session.open") throw new Error("missing session open");
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const prefix = "long prefix ".repeat(10);
const first = prefix + "a\uD83D";
const complete = first + "\uDE00b";
try {
for (const text of [first, complete, complete]) {
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { type: "assistant_message", id: "split-message", text },
});
}
const deltas = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text]
: [],
);
expect(deltas).toEqual([first, "\uDE00b"]);
expect(deltas.join("")).toBe(prefix + "a😀b");
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) history.push(event);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item] : [],
),
).toEqual([{ type: "assistant_message", messageId: "split-message", text: complete }]);
} finally {
await session.close();
await registry.shutdown();
}
});
test("stores only agent options while the plugin receives merged defaults", async () => { test("stores only agent options while the plugin receives merged defaults", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
const harness = createProviderHarness(); const harness = createProviderHarness();
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { PluginSessionHistory } from "./plugin-session-history.js";
import { resolve as resolvePath } from "node:path"; import { resolve as resolvePath } from "node:path";
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { JsonValue } from "@getpaseo/protocol/agent-types";
@@ -592,6 +593,7 @@ class ProviderRuntime {
class ProviderRuntimeSession { class ProviderRuntimeSession {
readonly history: ProviderEvent[] = []; readonly history: ProviderEvent[] = [];
private historyClaimed = false;
private readonly listeners = new Set<(event: ProviderEvent) => void>(); private readonly listeners = new Set<(event: ProviderEvent) => void>();
private capabilities: readonly string[] = []; private capabilities: readonly string[] = [];
private openRequest: { requestId: string; deferred: Deferred<void> } | null = null; private openRequest: { requestId: string; deferred: Deferred<void> } | null = null;
@@ -622,6 +624,10 @@ class ProviderRuntimeSession {
} }
onEvent(listener: (event: ProviderEvent) => void): () => void { onEvent(listener: (event: ProviderEvent) => void): () => void {
// This buffer bridges session.open and adapter attachment. Once replayed,
// the adapter owns history; retaining live cumulative snapshots is quadratic.
this.historyClaimed = true;
this.history.length = 0;
this.listeners.add(listener); this.listeners.add(listener);
return () => this.listeners.delete(listener); return () => this.listeners.delete(listener);
} }
@@ -713,6 +719,7 @@ class ProviderRuntimeSession {
} }
async close(): Promise<void> { async close(): Promise<void> {
this.history.length = 0;
if (this.restoration === "parent") { if (this.restoration === "parent") {
this.runtime.removeSession(this.id, this.providerSessionId); this.runtime.removeSession(this.id, this.providerSessionId);
return; return;
@@ -826,7 +833,7 @@ class ProviderRuntimeSession {
if (event.type === "session.persistence" && this.restoration === "core") { if (event.type === "session.persistence" && this.restoration === "core") {
this.persistence = event.persistence; this.persistence = event.persistence;
} }
this.history.push(event); if (!this.historyClaimed) this.history.push(event);
for (const listener of this.listeners) listener(event); for (const listener of this.listeners) listener(event);
} }
} }
@@ -1128,17 +1135,30 @@ class PluginAgentClient implements AgentClient {
} }
} }
interface TimelineTextSnapshot {
type: "assistant_message" | "reasoning" | "user_message";
text: string;
}
function isTextSnapshotItem(
item: ProviderTimelineItem,
): item is Extract<ProviderTimelineItem, { type: TimelineTextSnapshot["type"] }> {
return (
item.type === "assistant_message" || item.type === "reasoning" || item.type === "user_message"
);
}
class PluginAgentSession implements AgentSession { class PluginAgentSession implements AgentSession {
readonly initialTimeline: ImportedProviderSession["timeline"]; readonly initialTimeline: ImportedProviderSession["timeline"];
private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
private readonly history: AgentStreamEvent[] = []; private readonly history = new PluginSessionHistory();
private readonly pendingPermissions = new Map<string, AgentPermissionRequest>(); private readonly pendingPermissions = new Map<string, AgentPermissionRequest>();
private readonly permissionResponses = new Map<string, AgentPermissionResponse>(); private readonly permissionResponses = new Map<string, AgentPermissionResponse>();
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>(); private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
private readonly timelineSnapshots = new Map<string, ProviderTimelineItem>(); private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
private readonly subagentIdsBySession = new Map<string, string | null>(); private readonly subagentIdsBySession = new Map<string, string | null>();
private readonly childUnsubscribes = new Map<string, () => void>(); private readonly childUnsubscribes = new Map<string, () => void>();
private readonly childSnapshots = new Map<string, Map<string, ProviderTimelineItem>>(); private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null; private unsubscribe: (() => void) | null = null;
private currentTurnId: string | null = null; private currentTurnId: string | null = null;
private closed = false; private closed = false;
@@ -1236,7 +1256,7 @@ class PluginAgentSession implements AgentSession {
} }
async *streamHistory(): AsyncGenerator<AgentStreamEvent> { async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
for (const event of this.history) yield event; for (const event of this.history.read(this.provider)) yield event;
} }
get canStopRuntime(): (() => Promise<boolean>) | undefined { get canStopRuntime(): (() => Promise<boolean>) | undefined {
@@ -1304,6 +1324,12 @@ class PluginAgentSession implements AgentSession {
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe(); for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
this.childUnsubscribes.clear(); this.childUnsubscribes.clear();
this.subagentIdsBySession.clear(); this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.listeners.clear(); this.listeners.clear();
this.onClose(); this.onClose();
await this.bridge.close(); await this.bridge.close();
@@ -1344,16 +1370,20 @@ class PluginAgentSession implements AgentSession {
} }
timelineHistory(): ImportedProviderSession["timeline"] { timelineHistory(): ImportedProviderSession["timeline"] {
return this.history.flatMap((event) => return this.history
event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [], .read(this.provider)
); .flatMap((event) =>
event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [],
);
} }
subagentHistory(): NonNullable<ImportedProviderSession["providerSubagentEvents"]> { subagentHistory(): NonNullable<ImportedProviderSession["providerSubagentEvents"]> {
return this.history.filter( return this.history
(event): event is Extract<AgentStreamEvent, { type: "provider_subagent" }> => .read(this.provider)
event.type === "provider_subagent", .filter(
); (event): event is Extract<AgentStreamEvent, { type: "provider_subagent" }> =>
event.type === "provider_subagent",
);
} }
attachChild( attachChild(
@@ -1382,7 +1412,7 @@ class PluginAgentSession implements AgentSession {
cwd: opened.cwd, cwd: opened.cwd,
}, },
}); });
const snapshots = new Map<string, ProviderTimelineItem>(); const snapshots = new Map<string, TimelineTextSnapshot>();
this.childSnapshots.set(childId, snapshots); this.childSnapshots.set(childId, snapshots);
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots); for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
this.childUnsubscribes.set( this.childUnsubscribes.set(
@@ -1568,7 +1598,7 @@ class PluginAgentSession implements AgentSession {
private acceptChildEvent( private acceptChildEvent(
childId: string, childId: string,
event: ProviderEvent, event: ProviderEvent,
snapshots: Map<string, ProviderTimelineItem>, snapshots: Map<string, TimelineTextSnapshot>,
): void { ): void {
if (event.type === "timeline.item") { if (event.type === "timeline.item") {
const item = mapTimelineItem(event.item, snapshots); const item = mapTimelineItem(event.item, snapshots);
@@ -1721,24 +1751,24 @@ function mapPromptContent(prompt: AgentPromptInput): ProviderContent[] {
function mapTimelineItem( function mapTimelineItem(
item: ProviderTimelineItem, item: ProviderTimelineItem,
snapshots: Map<string, ProviderTimelineItem>, snapshots: Map<string, TimelineTextSnapshot>,
revertTokens?: Map<string, ProviderTimelineItem["revertToken"]>, revertTokens?: Map<string, ProviderTimelineItem["revertToken"]>,
): AgentTimelineItem | null { ): AgentTimelineItem | null {
const previous = snapshots.get(item.id); const previous = snapshots.get(item.id);
snapshots.set(item.id, item); if (isTextSnapshotItem(item)) {
const { id, revertToken, ...withoutIdentity } = item; snapshots.set(item.id, { type: item.type, text: item.text });
if (revertToken !== undefined && revertTokens) {
revertTokens.set(id, revertToken);
if (item.type === "user_message" || item.type === "assistant_message") {
revertTokens.set(item.messageId ?? id, revertToken);
}
} }
const { id, revertToken: _revertToken, ...withoutIdentity } = item;
rememberRevertToken(item, revertTokens);
if (item.type === "assistant_message" || item.type === "reasoning") { if (item.type === "assistant_message" || item.type === "reasoning") {
const previousText = previous?.type === item.type ? previous.text : ""; const previousText = previous?.type === item.type ? previous.text : "";
const text = item.text.startsWith(previousText) const suffix = item.text.startsWith(previousText)
? item.text.slice(previousText.length) ? item.text.slice(previousText.length)
: item.text; : item.text;
// Match OpenCode's detachedSuffix: a sliced delta otherwise keeps its entire
// cumulative IPC snapshot alive in the projected timeline. Preserve split surrogates.
const text = Buffer.from(suffix, "utf16le").toString("utf16le");
if (text.length === 0) return null; if (text.length === 0) return null;
return item.type === "assistant_message" return item.type === "assistant_message"
? { type: "assistant_message", text, messageId: item.messageId ?? id } ? { type: "assistant_message", text, messageId: item.messageId ?? id }
@@ -1766,6 +1796,17 @@ function mapTimelineItem(
return withoutIdentity as AgentTimelineItem; return withoutIdentity as AgentTimelineItem;
} }
function rememberRevertToken(
item: ProviderTimelineItem,
tokens?: Map<string, ProviderTimelineItem["revertToken"]>,
): void {
if (item.revertToken === undefined || !tokens) return;
tokens.set(item.id, item.revertToken);
if (item.type === "user_message" || item.type === "assistant_message") {
tokens.set(item.messageId ?? item.id, item.revertToken);
}
}
function persistenceHandle( function persistenceHandle(
provider: string, provider: string,
persistence: ProviderPersistence, persistence: ProviderPersistence,
@@ -0,0 +1,66 @@
import { TimelineProjection } from "./timeline-projection.js";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
// Replay canonical items, rather than retaining every streamed delta or tool state.
// Sequence numbers here only preserve adapter replay order, not wire cursors.
export class PluginSessionHistory {
private readonly timelines = new Map<
string,
{ projection: TimelineProjection; seq: number; positions: Map<number, number> }
>();
private readonly events: Array<{ seq: number; event: AgentStreamEvent }> = [];
private seq = 0;
push(event: AgentStreamEvent): void {
const seq = ++this.seq;
const child =
event.type === "provider_subagent" && event.event.type === "timeline" ? event.event : null;
if (event.type !== "timeline" && !child) {
this.events.push({ seq, event });
return;
}
const key = child?.id ?? "";
let projection = this.timelines.get(key);
if (!projection) {
projection = { projection: new TimelineProjection(), seq: 0, positions: new Map() };
this.timelines.set(key, projection);
}
const timeline = event.type === "timeline" ? event : child!;
const row = projection.projection.append({
seq: ++projection.seq,
item: timeline.item,
timestamp: timeline.timestamp ?? "",
...(event.type === "timeline" && event.turnId ? { turnId: event.turnId } : {}),
});
if (!projection.positions.has(row.seqStart)) projection.positions.set(row.seqStart, seq);
}
read(provider: string): AgentStreamEvent[] {
const events = [...this.events];
for (const [id, projection] of this.timelines) {
for (const row of projection.projection.getRows()) {
const timestamp = row.timestamp ? { timestamp: row.timestamp } : {};
const event: AgentStreamEvent = id
? {
type: "provider_subagent",
provider,
event: { type: "timeline", id, item: row.item, ...timestamp },
}
: {
type: "timeline",
provider,
item: row.item,
...timestamp,
...(row.turnId ? { turnId: row.turnId } : {}),
};
events.push({ seq: projection.positions.get(row.seqStart)!, event });
}
}
return events.sort((a, b) => a.seq - b.seq).map(({ event }) => event);
}
clear(): void {
this.timelines.clear();
this.events.length = 0;
}
}
@@ -422,6 +422,237 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
}); });
}); });
describe("a background subagent", () => {
// Shapes recorded from Claude Code 2.1.x: the launch returns at once with an async result,
// and a child stopped by an interrupt ends on the interrupt placeholder, not an end_turn.
function backgroundLaunch(): string[] {
return [
parentEntry([
{
type: "tool_use",
id: TOOL_USE_ID,
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "probe sleeper",
run_in_background: true,
},
},
]),
JSON.stringify({
type: "user",
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:48.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: TOOL_USE_ID,
content: [
{
type: "text",
text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`,
},
],
},
],
},
toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID },
}),
];
}
function readStatus(event: { status?: string }): string | undefined {
return event.status;
}
function interruptedChild(): string[] {
return [
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:27:50.000Z",
message: {
role: "assistant",
content: [{ type: "tool_use", id: "toolu_child_bash", name: "Bash", input: {} }],
stop_reason: "tool_use",
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:28:05.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_child_bash",
content: "The user doesn't want to proceed with this tool use.",
is_error: true,
},
],
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:28:05.001Z",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user for tool use]" }],
},
}),
];
}
test.each([
["the meta sidecar", JSON.stringify({ toolUseId: TOOL_USE_ID })],
["the legacy scrape", null],
])("is canceled when an interrupt stopped it, linked through %s", async (_link, meta) => {
writeSession({ parentLines: backgroundLaunch(), meta, sidechainLines: interruptedChild() });
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: "canceled",
});
});
test("stays running when the child was given a new prompt after the interrupt", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [
...interruptedChild(),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:29:00.000Z",
message: { role: "user", content: [{ type: "text", text: "Carry on with the task." }] },
}),
],
});
const statuses = upserts(await replayDescriptors()).map(readStatus);
expect(statuses).not.toContain("canceled");
expect(statuses).toContain("running");
});
test.each(["interrupted", "running"])(
"ignores an API request copy of a %s child's launch result",
async (state) => {
const launch = backgroundLaunch();
const result = JSON.parse(launch[1]!);
writeSession({
parentLines: [
...launch,
JSON.stringify({
type: "api-request-blob",
sessionId: "replay-session",
hash: "request-copy",
message: result.message,
}),
],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines:
state === "interrupted"
? interruptedChild()
: [sidechainEntry({ stopReason: "tool_use" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: state === "interrupted" ? "canceled" : "running",
});
},
);
test("stays running while the child has not finished", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
});
const statuses = upserts(await replayDescriptors()).map(readStatus);
expect(statuses).not.toContain("completed");
expect(statuses).toContain("running");
});
function taskNotification(status: string, form = "user"): string {
const text = `<task-notification>\n<task-id>${AGENT_ID}</task-id>\n<tool-use-id>${TOOL_USE_ID}</tool-use-id>\n<status>${status}</status>\n<summary>Background task ended</summary>\n</task-notification>`;
const records = {
user: { message: { role: "user", content: [{ type: "text", text }] } },
system: {
subtype: "task_notification",
task_id: AGENT_ID,
tool_use_id: TOOL_USE_ID,
status,
},
"queue-operation": { operation: "enqueue", content: text },
};
return JSON.stringify({
type: form,
timestamp: "2026-07-26T06:29:00.000Z",
...records[form as keyof typeof records],
});
}
for (const form of ["user", "system", "queue-operation"]) {
test.each([
["completed", "completed"],
["failed", "failed"],
["killed", "canceled"],
["stopped", "canceled"],
])(
`restores a %s task notification from ${form} when the child stop_reason is null`,
async (status, expected) => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification(status, form)],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: expected,
});
},
);
}
test("prefers a stopped notification over the child's end_turn", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("stopped")],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "canceled" });
});
test("does not invent an outcome for an unknown notification status", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("unknown")],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "running" });
});
test("completes from the child's own end_turn", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "completed" });
});
});
test("does not replay a skill-spawned subagent the parent never named", async () => { test("does not replay a skill-spawned subagent the parent never named", async () => {
// The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only // The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only
// agentType and its id appears nowhere in the parent — it used to replay as running forever, // agentType and its id appears nowhere in the parent — it used to replay as running forever,
@@ -33,7 +33,9 @@ import {
mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall, mapTaskNotificationUserContentToToolCall,
readTaskNotificationToolUseIdFromHistoryRecord, readTaskNotificationToolUseIdFromHistoryRecord,
readTaskNotificationFromHistoryRecord,
} from "./task-notification-tool-call.js"; } from "./task-notification-tool-call.js";
import { isClaudeInterruptPlaceholderText } from "./interrupt-placeholder.js";
import { import {
getClaudeContextWindowMaxTokens, getClaudeContextWindowMaxTokens,
getClaudeModelsWithSettings, getClaudeModelsWithSettings,
@@ -56,6 +58,8 @@ import {
type ClaudeHookObservationInput, type ClaudeHookObservationInput,
} from "./subagents/live-source.js"; } from "./subagents/live-source.js";
import { observeReplaySubagents, type ClaudeReplayParentFacts } from "./subagents/replay-source.js"; import { observeReplaySubagents, type ClaudeReplayParentFacts } from "./subagents/replay-source.js";
import { mapTaskStatus } from "./subagents/task-status.js";
import type { ProviderSubagentStatus } from "../../provider-subagents/store.js";
import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js"; import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js";
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js"; import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js"; import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
@@ -425,7 +429,6 @@ const CLAUDE_ROOT_ONLY_COMMANDS = new Set([
"usage", "usage",
]); ]);
const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]"; const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]";
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested."; const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested.";
const STEER_SUPERSEDED_PERMISSION_MESSAGE = const STEER_SUPERSEDED_PERMISSION_MESSAGE =
"The user answered with a message instead of approving. Their message follows."; "The user answered with a message instead of approving. Their message follows.";
@@ -789,11 +792,6 @@ function normalizeClaudeTranscriptText(value: unknown): string | null {
return normalized.length > 0 ? normalized : null; return normalized.length > 0 ? normalized : null;
} }
function isClaudeInterruptPlaceholderText(value: unknown): boolean {
const normalized = normalizeClaudeTranscriptText(value);
return normalized !== null && INTERRUPT_PLACEHOLDER_PATTERN.test(normalized);
}
function isClaudeNoResponsePlaceholderText(value: unknown): boolean { function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER; return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER;
} }
@@ -6262,33 +6260,34 @@ async function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>, entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
): Promise<ClaudeReplayParentFacts> { ): Promise<ClaudeReplayParentFacts> {
const toolCalls = new Map<string, { title?: string; description?: string }>(); const toolCalls = new Map<string, { title?: string; description?: string }>();
const linksByAgentId = new Map<string, { toolCallId: string; failed: boolean }>(); const linksByAgentId = new Map<string, { toolCallId: string }>();
const outcomes = new Map<string, { failed: boolean }>(); const outcomes: Map<string, { status: ProviderSubagentStatus }> = new Map();
for await (const entry of entries) { for await (const entry of entries) {
const notification = readTaskNotificationFromHistoryRecord(entry);
const status = mapTaskStatus(notification?.status ?? undefined);
if (notification?.toolUseId && status) {
outcomes.set(notification.toolUseId, { status });
}
const content = toObjectRecord(entry.message)?.content; const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue; if (!Array.isArray(content)) continue;
for (const value of content) { for (const value of content) {
const block = toObjectRecord(value); const block = toObjectRecord(value);
const declaration = readClaudeReplayTaskDeclaration(entry.type, block);
if (declaration) toolCalls.set(declaration.id, declaration.identity);
if ( if (
block?.type === "tool_use" && entry.type !== "user" ||
(block.name === "Task" || block.name === "Agent") && block?.type !== "tool_result" ||
typeof block.id === "string" typeof block.tool_use_id !== "string"
) { )
const input = toObjectRecord(block.input); continue;
const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type); if (toObjectRecord(entry.toolUseResult)?.status !== "async_launched")
const description = readNonEmptyString(input?.description); outcomes.set(block.tool_use_id, {
toolCalls.set(block.id, { status: block.is_error === true ? "failed" : "completed",
...(title ? { title } : {}),
...(description ? { description } : {}),
}); });
}
if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
outcomes.set(block.tool_use_id, { failed: block.is_error === true });
const agentId = readLegacyClaudeSubagentId(block.content); const agentId = readLegacyClaudeSubagentId(block.content);
if (agentId) if (agentId)
linksByAgentId.set(agentId, { linksByAgentId.set(agentId, {
toolCallId: block.tool_use_id, toolCallId: block.tool_use_id,
failed: block.is_error === true,
}); });
} }
} }
@@ -6299,6 +6298,29 @@ async function readClaudeReplayParentFacts(
}; };
} }
function readClaudeReplayTaskDeclaration(
entryType: unknown,
block: Record<string, unknown> | undefined,
): {
id: string;
identity: { title?: string; description?: string };
} | null {
if (
entryType !== "assistant" ||
block?.type !== "tool_use" ||
(block.name !== "Task" && block.name !== "Agent") ||
typeof block.id !== "string"
)
return null;
const input = toObjectRecord(block.input);
const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type);
const description = readNonEmptyString(input?.description);
return {
id: block.id,
identity: { ...(title ? { title } : {}), ...(description ? { description } : {}) },
};
}
function readLegacyClaudeSubagentId(content: unknown): string | undefined { function readLegacyClaudeSubagentId(content: unknown): string | undefined {
return /agentId:\s*([\w-]+)/.exec(JSON.stringify(content))?.[1]; return /agentId:\s*([\w-]+)/.exec(JSON.stringify(content))?.[1];
} }
@@ -0,0 +1,6 @@
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
/** The text Claude Code records in place of a turn that an interrupt stopped. */
export function isClaudeInterruptPlaceholderText(value: unknown): boolean {
return typeof value === "string" && INTERRUPT_PLACEHOLDER_PATTERN.test(value.trim());
}
@@ -1,7 +1,8 @@
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { mapTaskStatus } from "./task-status.js";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentMetadata } from "../../../agent-sdk-types.js"; import type { AgentMetadata } from "../../../agent-sdk-types.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { resolveObservedClaudeModelId } from "../models.js"; import { resolveObservedClaudeModelId } from "../models.js";
import type { SubagentObservation } from "./observation.js"; import type { SubagentObservation } from "./observation.js";
import { import {
@@ -120,28 +121,6 @@ function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
} }
/**
* The descriptor has no paused or pending state, and neither is terminal, so both read as
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
*/
function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
switch (status) {
case "pending":
case "running":
case "paused":
return "running";
case "completed":
return "completed";
case "failed":
return "failed";
case "killed":
case "stopped":
return "canceled";
default:
return undefined;
}
}
export interface ClaudeTaskProtocolSourceInput { export interface ClaudeTaskProtocolSourceInput {
/** /**
* The parent's Task tool input, by tool_use id. * The parent's Task tool input, by tool_use id.
@@ -36,7 +36,7 @@ function parentWithTaskCall(failed = false): ClaudeReplayParentFacts {
], ],
]), ]),
linksByAgentId: new Map(), linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed }]]), outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: failed ? "failed" : "completed" }]]),
}; };
} }
@@ -117,7 +117,7 @@ describe("observeReplaySubagents", () => {
[nestedToolUseId, { title: "Explore", description: "Nested audit" }], [nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]), ]),
linksByAgentId: new Map(), linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]), outcomesByToolCallId: new Map([[nestedToolUseId, { status: "completed" }]]),
}, },
}, },
{ {
@@ -173,8 +173,8 @@ describe("observeReplaySubagents", () => {
it("falls back to the scraped link when there is no meta file", async () => { it("falls back to the scraped link when there is no meta file", async () => {
const parent: ClaudeReplayParentFacts = { const parent: ClaudeReplayParentFacts = {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]), linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
outcomesByToolCallId: new Map(), outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "completed" }]]),
}; };
const [declared, ...rest] = ( const [declared, ...rest] = (
@@ -263,8 +263,8 @@ describe("observeReplaySubagents", () => {
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: { parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]), linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
outcomesByToolCallId: new Map(), outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "failed" }]]),
}, },
convertEntry: () => [], convertEntry: () => [],
}) })
@@ -3,6 +3,7 @@ import { z } from "zod";
import type { AgentTimelineItem } from "../../../agent-sdk-types.js"; import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js"; import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js";
import { resolveObservedClaudeModelId } from "../models.js"; import { resolveObservedClaudeModelId } from "../models.js";
import type { SubagentObservation } from "./observation.js"; import type { SubagentObservation } from "./observation.js";
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js"; import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
@@ -139,9 +140,12 @@ export interface ClaudeReplayParentFacts {
/** Task `tool_use` id -> identity declared in the parent's tool input. */ /** Task `tool_use` id -> identity declared in the parent's tool input. */
toolCalls: ReadonlyMap<string, { title?: string; description?: string }>; toolCalls: ReadonlyMap<string, { title?: string; description?: string }>;
/** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */ /** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */
linksByAgentId: ReadonlyMap<string, { toolCallId: string; failed: boolean }>; linksByAgentId: ReadonlyMap<string, { toolCallId: string }>;
/** Task `tool_use` id -> outcome. Usable once meta.json supplies the link directly. */ /**
outcomesByToolCallId: ReadonlyMap<string, { failed: boolean }>; * Task `tool_use` id -> outcome, whichever way the child was linked. A background launch has
* none: its immediate result only says the child started.
*/
outcomesByToolCallId: ReadonlyMap<string, { status: ProviderSubagentStatus }>;
} }
interface ParentLink { interface ParentLink {
@@ -177,27 +181,14 @@ function resolveParentLink(
parent: ClaudeReplayParentFacts, parent: ClaudeReplayParentFacts,
): ParentLink | null { ): ParentLink | null {
const metaToolUseId = subagent.meta?.toolUseId?.trim(); const metaToolUseId = subagent.meta?.toolUseId?.trim();
if (metaToolUseId && parent.toolCalls.has(metaToolUseId)) { const toolCallId =
const outcome = parent.outcomesByToolCallId.get(metaToolUseId); metaToolUseId && parent.toolCalls.has(metaToolUseId)
let status: ProviderSubagentStatus | null = null; ? metaToolUseId
if (outcome) status = outcome.failed ? "failed" : "completed"; : parent.linksByAgentId.get(subagent.agentId)?.toolCallId;
return { if (!toolCallId || !parent.toolCalls.has(toolCallId)) return null;
id: metaToolUseId,
toolCallId: metaToolUseId,
status,
};
}
const scraped = parent.linksByAgentId.get(subagent.agentId); const status = parent.outcomesByToolCallId.get(toolCallId)?.status ?? null;
if (scraped && parent.toolCalls.has(scraped.toolCallId)) { return { id: toolCallId, toolCallId, status };
return {
id: scraped.toolCallId,
toolCallId: scraped.toolCallId,
status: scraped.failed ? "failed" : "completed",
};
}
return null;
} }
/** /**
@@ -276,9 +267,7 @@ async function observeSubagent(
lastTimestamp = timestamp; lastTimestamp = timestamp;
Object.assign(runtime, readRuntime(entry)); Object.assign(runtime, readRuntime(entry));
usage = readUsage(entry) ?? usage; usage = readUsage(entry) ?? usage;
if (entry.type === "assistant") { childTerminalStatus = readChildTerminalStatus(entry, childTerminalStatus);
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
recordReplayToolOwners(toolOwners, entry, link.id); recordReplayToolOwners(toolOwners, entry, link.id);
for (const item of await convertEntry(entry)) { for (const item of await convertEntry(entry)) {
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) }); timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
@@ -308,6 +297,30 @@ async function observeSubagent(
return observations; return observations;
} }
/** Advance the child's fallback outcome without retaining transcript entries. */
function readChildTerminalStatus(
entry: ClaudeReplayEntry,
previous: ProviderSubagentStatus | null,
): ProviderSubagentStatus | null {
if (entry.type === "assistant") {
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
if (entry.type !== "user") return previous;
const texts = readUserTexts(entry);
if (texts.length === 0) return previous;
return texts.some(isClaudeInterruptPlaceholderText) ? "canceled" : null;
}
/** Tool results carry no user text and do not change the child's terminal status. */
function readUserTexts(entry: ClaudeReplayEntry): string[] {
const content = entry.message?.content;
if (typeof content === "string") return [content];
if (!Array.isArray(content)) return [];
return content.flatMap((block: { type?: unknown; text?: unknown } | null) =>
block?.type === "text" && typeof block.text === "string" ? [block.text] : [],
);
}
function recordReplayToolOwners( function recordReplayToolOwners(
owners: Map<string, string>, owners: Map<string, string>,
entry: ClaudeReplayEntry, entry: ClaudeReplayEntry,
@@ -0,0 +1,23 @@
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
/**
* The descriptor has no paused or pending state, and neither is terminal, so both read as
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
*/
export function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
switch (status) {
case "pending":
case "running":
case "paused":
return "running";
case "completed":
return "completed";
case "failed":
return "failed";
case "killed":
case "stopped":
return "canceled";
default:
return undefined;
}
}
@@ -407,19 +407,21 @@ export function mapTaskNotificationSystemRecordToToolCall(
} }
export function readTaskNotificationToolUseIdFromHistoryRecord(record: unknown): string | null { export function readTaskNotificationToolUseIdFromHistoryRecord(record: unknown): string | null {
return readTaskNotificationFromHistoryRecord(record)?.toolUseId ?? null;
}
export function readTaskNotificationFromHistoryRecord(
record: unknown,
): TaskNotificationEnvelope | null {
const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record); const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record);
if (!parsedRecord.success) { if (!parsedRecord.success) return null;
return null;
}
if (parsedRecord.data.type === "user" && parsedRecord.data.message) { if (parsedRecord.data.type === "user" && parsedRecord.data.message) {
return ( return parseTaskNotificationFromUserContent({
parseTaskNotificationFromUserContent({ content: parsedRecord.data.message.content,
content: parsedRecord.data.message.content, messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id, });
})?.toolUseId ?? null
);
} }
return parseTaskNotificationFromSystemRecord(record)?.toolUseId ?? null; return parseTaskNotificationFromSystemRecord(record);
} }
export function coerceTaskNotificationHistoryRecordToSystemMessage( export function coerceTaskNotificationHistoryRecordToSystemMessage(
@@ -461,9 +461,19 @@ test.each([
process.platform === "win32" process.platform === "win32"
? `${windowsPackage}-${process.arch}@${cliPackage.slice(cliPackage.lastIndexOf("@") + 1)}` ? `${windowsPackage}-${process.arch}@${cliPackage.slice(cliPackage.lastIndexOf("@") + 1)}`
: cliPackage; : cliPackage;
// npm abandons a stalled registry download only after its 5 minute default, past this
// test's budget, and the killed install keeps the fixture locked. Retry stalls sooner.
await execCommand( await execCommand(
"npm", "npm",
["install", "--prefix", root, "--no-audit", "--no-fund", installPackage], [
"install",
"--prefix",
root,
"--no-audit",
"--no-fund",
"--fetch-timeout=30000",
installPackage,
],
{ {
cwd: root, cwd: root,
timeout: 180_000, timeout: 180_000,
@@ -0,0 +1,105 @@
import { expect, test } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AgentStorage, parseStoredAgentRecord } from "../agent-storage.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { createRestartRecovery } from "./index.js";
test("recovery saves intent before normalization, clears before dispatch, and resumes each agent independently", async () => {
const root = await mkdtemp(join(tmpdir(), "restart-recovery-"));
try {
const storage = new AgentStorage(root, createTestLogger());
for (const [id, lastStatus] of [
["broken", "running"],
["stuck", "running"],
["working", "running"],
["idle", "idle"],
["initializing", "initializing"],
] as const) {
await storage.upsert(
parseStoredAgentRecord({
id,
lastStatus,
provider: "codex",
cwd: root,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
);
}
const dispatched: string[] = [];
const recovery = createRestartRecovery({
storage,
settings: () => ({ enabled: true, prompt: "Continue verbatim.\n" }),
eligible: async () => true,
dispatch: async (id, prompt) => {
expect((await storage.get(id))?.resumeOnStartup).toBe(false);
if (id === "broken") throw new Error("unavailable");
if (id === "stuck") return new Promise(() => {});
dispatched.push(id + prompt);
},
report: () => {},
});
await recovery.prepareStartup();
expect((await storage.get("working"))?.resumeOnStartup).toBe(true);
expect((await storage.get("working"))?.lastStatus).toBe("closed");
recovery.resumePending();
const running = new AbortController().signal;
await recovery.waitForResume("broken", running);
await recovery.waitForResume("working", running);
await recovery.waitForResume("idle", running);
expect(dispatched).toEqual(["workingContinue verbatim.\n"]);
const stopped = new AbortController();
stopped.abort();
await expect(recovery.waitForResume("stuck", stopped.signal)).rejects.toBeDefined();
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("opt-out consumes pending recovery and excludes archived, private, owned and archived-workspace agents", async () => {
const root = await mkdtemp(join(tmpdir(), "restart-recovery-"));
try {
const storage = new AgentStorage(root, createTestLogger());
for (const extra of [
{ id: "running" },
{ id: "archived", archivedAt: "today" },
{ id: "private", internal: true },
{ id: "owned", owner: { kind: "daemon", daemonId: "hub", executionId: "run" } },
{ id: "workspace", workspaceId: "archived" },
]) {
await storage.upsert(
parseStoredAgentRecord({
lastStatus: "running",
provider: "codex",
cwd: root,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...extra,
}),
);
}
let enabled = true;
const dispatched: string[] = [];
const recovery = createRestartRecovery({
storage,
settings: () => ({ enabled, prompt: "Resume working." }),
eligible: async (record) => record.workspaceId !== "archived",
dispatch: async (id) => {
dispatched.push(id);
},
report: () => {},
});
await recovery.captureRunningAgents();
expect((await storage.get("running"))?.resumeOnStartup).toBe(true);
enabled = false;
await recovery.prepareStartup();
enabled = true;
recovery.resumePending();
expect(dispatched).toEqual([]);
expect((await storage.list()).every((record) => !record.resumeOnStartup)).toBe(true);
} finally {
await rm(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,81 @@
import type { AgentStorage, StoredAgentRecord } from "../agent-storage.js";
interface Dependencies {
storage: AgentStorage;
settings(): { enabled: boolean; prompt: string };
eligible(record: StoredAgentRecord): Promise<boolean>;
dispatch(agentId: string, prompt: string): Promise<unknown>;
report(agentId: string, error: unknown): void;
}
export function createRestartRecovery(deps: Dependencies) {
// Candidates are chosen at startup; each settles once its resume was sent or abandoned.
const resuming = new Map<string, { promise: Promise<void>; resolve(): void }>();
const eligible = async (record: StoredAgentRecord) =>
!record.archivedAt && !record.internal && !record.owner && (await deps.eligible(record));
async function resume(agentId: string) {
const record = await deps.storage.get(agentId);
if (!record?.resumeOnStartup) return;
await deps.storage.upsert({ ...record, resumeOnStartup: false });
if (!deps.settings().enabled || !(await eligible(record))) return;
await deps.dispatch(agentId, deps.settings().prompt);
}
return {
/** Choose candidates before anything else reads or repairs persisted running status. */
async prepareStartup() {
for (const record of await deps.storage.list()) {
const pending =
deps.settings().enabled &&
(record.resumeOnStartup === true || record.lastStatus === "running") &&
(await eligible(record));
await deps.storage.upsert({
...record,
resumeOnStartup: pending,
lastStatus:
record.lastStatus === "running" || record.lastStatus === "initializing"
? "closed"
: record.lastStatus,
});
if (pending) {
let resolve!: () => void;
const promise = new Promise<void>((settle) => {
resolve = settle;
});
resuming.set(record.id, { promise, resolve });
}
}
},
async captureRunningAgents() {
await deps.storage.flush();
for (const record of await deps.storage.list()) {
if (record.lastStatus === "running" && (await eligible(record)))
await deps.storage.upsert({ ...record, resumeOnStartup: deps.settings().enabled });
}
},
/** Resume every candidate in the background; a slow provider holds back only its agent. */
resumePending() {
for (const [agentId, settled] of resuming) {
void resume(agentId)
.catch((error) => deps.report(agentId, error))
.finally(() => {
resuming.delete(agentId);
settled.resolve();
});
}
},
/** Wait until this agent's resume was sent or abandoned. Others return at once. */
async waitForResume(agentId: string, signal: AbortSignal) {
const settled = resuming.get(agentId);
if (!settled) return;
let abort!: () => void;
const aborted = new Promise<never>((_resolve, reject) => {
abort = () => reject(signal.reason);
if (signal.aborted) abort();
else signal.addEventListener("abort", abort, { once: true });
});
try {
await Promise.race([settled.promise, aborted]);
} finally {
signal.removeEventListener("abort", abort);
}
},
};
}
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider";
import pino from "pino";
import { PluginAgentClientRegistry } from "../plugin-provider.js";
import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { buildProviderRegistry } from "../provider-registry.js";
if (!global.gc) throw new Error("Run with --expose-gc");
const logger = pino({ level: "silent" });
const plugins = new PluginAgentClientRegistry(logger);
const root = await mkdtemp(join(tmpdir(), "plugin-stream-memory-"));
const emitters: Array<(event: ProviderEvent) => void> = [];
const ids: string[] = [];
const registrations: ProviderRegistration[] = Array.from({ length: 3 }, (_, index) => ({
id: `memory-${index}`,
label: "Memory fixture",
async connect() {
let listener: (event: ProviderEvent) => void = () => {};
const capabilities = ["prompt.message", "session.persistence"] as const;
return {
version: 1,
capabilities,
onEvent(callback) {
listener = callback;
// Plugin IPC delivers independent JSON strings, rather than shared ropes.
emitters[index] = (event) => listener(JSON.parse(JSON.stringify(event)));
return () => {
listener = () => {};
};
},
async send(input) {
if (input.type === "session.open") {
ids[index] = input.sessionId;
listener({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
cwd: "/tmp",
capabilities,
restoration: "core",
persistence: { version: 1, data: { index } },
});
listener({
type: "session.ready",
requestId: input.requestId,
sessionId: input.sessionId,
});
} else if (input.type === "catalog") {
listener({
type: "catalog",
requestId: input.requestId,
catalog: { models: [], modes: [], thinkingOptions: [] },
});
} else if (input.type === "session.close") {
listener({ type: "session.closed", sessionId: input.sessionId });
listener({ type: "request.completed", requestId: input.requestId });
}
},
async close() {},
};
},
}));
plugins.replace(registrations);
const store = new InMemoryAgentTimelineStore();
const manager = new AgentManager({
clients: plugins.clients(),
providerDefinitions: buildProviderRegistry(logger, { pluginProviders: plugins.definitions() }),
registry: new AgentStorage(root, logger),
timelineStore: store,
logger,
});
const agents = await Promise.all(
Object.keys(plugins.clients()).map((provider) =>
manager.createAgent({ provider, cwd: root }, undefined, {}),
),
);
const sessions = agents.map((agent) => agent.session!);
agents.forEach((_agent, index) => {
emitters[index]!({
type: "session.opened",
sessionId: `child-${index}`,
parentSessionId: ids[index]!,
capabilities: [],
restoration: "parent",
cwd: root,
});
});
global.gc();
const before = process.memoryUsage().heapUsed;
const steps = 4;
const chunks = 200;
const expected = new Map<string, string>();
for (let step = 0; step < steps; step++) {
for (let index = 0; index < sessions.length; index++) {
const text = `${index}:${step}:` + "推理".repeat(15000) + "a\uD83D\uDE00b";
expected.set(`${index}:${step}`, text);
for (let chunk = 1; chunk <= chunks; chunk++) {
for (const sessionId of [ids[index]!, `child-${index}`]) {
emitters[index]!({
type: "timeline.item",
sessionId,
item: {
type: "reasoning",
id: `reason-${step}`,
text: text.slice(0, Math.ceil((chunk * text.length) / chunks)),
},
});
}
}
emitters[index]!({
type: "timeline.item",
sessionId: ids[index]!,
item: {
type: "tool_call",
id: `tool-${step}`,
callId: `tool-${step}`,
name: "read",
status: "completed",
error: null,
detail: { type: "unknown", input: null, output: "输出".repeat(16000) },
},
});
emitters[index]!({
type: "timeline.item",
sessionId: `child-${index}`,
item: {
type: "assistant_message",
id: `answer-${step}`,
text: "Child step complete.",
},
});
}
// Drain the manager's event queue; terminal tool states flush its real coalescer.
await delay(80);
}
global.gc();
const retained = process.memoryUsage().heapUsed - before;
assert(retained < 24 * 1024 * 1024, `Retained heap exceeded 24 MiB: ${retained} bytes`);
for (let index = 0; index < sessions.length; index++) {
const items = store.getItems(agents[index]!.id);
assert.equal(items.length, steps * 2);
for (let step = 0; step < steps; step++) {
const item = items[step * 2]!;
assert.equal(item.type === "reasoning" && item.text, expected.get(`${index}:${step}`));
}
const children = manager.listProviderSubagents(agents[index]!.id);
assert.equal(children.length, 1);
let rootItems = 0;
let childItems = 0;
for await (const event of sessions[index]!.streamHistory()) {
if (event.type === "timeline") rootItems++;
if (event.type === "provider_subagent" && event.event.type === "timeline") childItems++;
}
assert.equal(rootItems, steps * 2);
assert.equal(childItems, steps * 2);
await manager.archiveAgent(agents[index]!.id);
assert.equal(store.has(agents[index]!.id), false);
assert.equal(
manager.listAgents().some((agent) => agent.id === agents[index]!.id),
false,
);
}
await plugins.shutdown();
await manager.flushForShutdown();
await rm(root, { recursive: true, force: true });
global.gc();
const closed = process.memoryUsage().heapUsed - before;
// Keep closed session references alive, as callers can retain them after archive.
assert.equal(sessions.length, 3);
process.stdout.write(JSON.stringify({ retained, closed }));
@@ -323,7 +323,7 @@ export class TimelineProjection {
this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq); this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq);
} }
append(row: AgentTimelineRow): void { append(row: AgentTimelineRow): ProjectedTimelineRow {
const rowIndex = this.appendProjected(row); const rowIndex = this.appendProjected(row);
const last = this.sourceRanges.at(-1); const last = this.sourceRanges.at(-1);
if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) { if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) {
@@ -331,6 +331,7 @@ export class TimelineProjection {
} else { } else {
this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex }); this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex });
} }
return { ...this.rows[rowIndex] };
} }
private appendProjected(row: AgentTimelineRow): number { private appendProjected(row: AgentTimelineRow): number {
@@ -1069,7 +1069,7 @@ function probeWebSocketConnection(
}); });
} }
test("startup closes stale active records before serving status without resuming sessions", async () => { test("disabled recovery closes stale active records without resuming sessions", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-stale-status-")); const root = await mkdtemp(path.join(os.tmpdir(), "paseo-stale-status-"));
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const storagePath = path.join(root, ".paseo", "agents"); const storagePath = path.join(root, ".paseo", "agents");
@@ -1121,6 +1121,7 @@ test("startup closes stale active records before serving status without resuming
let sessionsCreated = 0; let sessionsCreated = 0;
const handle = await createTestPaseoDaemon({ const handle = await createTestPaseoDaemon({
paseoHomeRoot: root, paseoHomeRoot: root,
resumeOnStartup: false,
agentClients: createTestAgentClients({ agentClients: createTestAgentClients({
beforeCreateSession: async () => { beforeCreateSession: async () => {
sessionsCreated++; sessionsCreated++;
@@ -1131,6 +1132,7 @@ test("startup closes stale active records before serving status without resuming
try { try {
const expected = records.map((record) => ({ const expected = records.map((record) => ({
...record, ...record,
resumeOnStartup: false,
lastStatus: lastStatus:
record.lastStatus === "running" || record.lastStatus === "initializing" record.lastStatus === "running" || record.lastStatus === "initializing"
? "closed" ? "closed"
+22 -11
View File
@@ -1,3 +1,5 @@
import { createRestartRecovery } from "./agent/restart-recovery/index.js";
import { sendPromptToAgent } from "./agent/agent-prompt.js";
import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js"; import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js";
import { import {
createFinishSubscriptions, createFinishSubscriptions,
@@ -464,6 +466,8 @@ export interface PaseoDaemonConfig {
downloadTokenTtlMs?: number; downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap; agentProviderSettings?: AgentProviderRuntimeSettingsMap;
idleRuntimeCleanup?: boolean; idleRuntimeCleanup?: boolean;
resumeOnStartup?: boolean;
resumePrompt?: string;
providerCatalogRefreshTimeoutMs?: number; providerCatalogRefreshTimeoutMs?: number;
metadataGeneration?: { metadataGeneration?: {
providers?: Array<{ providers?: Array<{
@@ -594,6 +598,8 @@ function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): Mutab
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false, enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false,
idleRuntimeCleanup: config.idleRuntimeCleanup !== false, idleRuntimeCleanup: config.idleRuntimeCleanup !== false,
resumeOnStartup: config.resumeOnStartup !== false,
resumePrompt: config.resumePrompt ?? "Resume working.",
appendSystemPrompt: config.appendSystemPrompt ?? "", appendSystemPrompt: config.appendSystemPrompt ?? "",
pluginsEnabled: config.pluginsEnabled ?? false, pluginsEnabled: config.pluginsEnabled ?? false,
plugins: config.plugins ?? {}, plugins: config.plugins ?? {},
@@ -1036,7 +1042,19 @@ export async function createPaseoDaemon(
agentStorage, agentStorage,
); );
await agentStorage.initialize(); await agentStorage.initialize();
await closeStaleAgentRecords(agentStorage); const restartRecovery = createRestartRecovery({
storage: agentStorage,
settings: () => ({
enabled: daemonConfigStore.get().resumeOnStartup !== false,
prompt: daemonConfigStore.get().resumePrompt ?? "Resume working.",
}),
eligible: async (record) =>
!record.workspaceId || !(await workspaceRegistry?.get(record.workspaceId))?.archivedAt,
dispatch: (agentId, prompt) =>
sendPromptToAgent({ agentId, prompt, agentManager, agentStorage, logger, unarchive: false }),
report: (agentId, err) => logger.error({ agentId, err }, "Agent restart recovery failed"),
});
await restartRecovery.prepareStartup();
logger.info({ elapsed: elapsed() }, "Agent storage initialized"); logger.info({ elapsed: elapsed() }, "Agent storage initialized");
await bootstrapWorkspaceRegistries({ await bootstrapWorkspaceRegistries({
serverId, serverId,
@@ -1470,6 +1488,7 @@ export async function createPaseoDaemon(
...createFinishObservation({ ...createFinishObservation({
agentManager, agentManager,
agentStorage, agentStorage,
waitForResume: restartRecovery.waitForResume,
serverId, serverId,
logger, logger,
messages: () => { messages: () => {
@@ -1869,6 +1888,7 @@ export async function createPaseoDaemon(
pluginRuntime.bindPaseoSessionHost(wsServer); pluginRuntime.bindPaseoSessionHost(wsServer);
await pluginRuntime.start(); await pluginRuntime.start();
providerSnapshotManager.settlePluginProviders(); providerSnapshotManager.settlePluginProviders();
restartRecovery.resumePending();
await finishSubscriptions.start(); await finishSubscriptions.start();
wsServer.beginAcceptingConnections(); wsServer.beginAcceptingConnections();
const relaySecurityLogger = logger.child({ module: "relay-security" }); const relaySecurityLogger = logger.child({ module: "relay-security" });
@@ -1963,6 +1983,7 @@ export async function createPaseoDaemon(
await finishSubscriptions.close(); await finishSubscriptions.close();
await wsServer?.agentMessages.close(); await wsServer?.agentMessages.close();
agentManager.prepareForShutdown(); agentManager.prepareForShutdown();
await restartRecovery.captureRunningAgents();
await closeAllAgents(logger, agentManager); await closeAllAgents(logger, agentManager);
await withTimeout({ await withTimeout({
promise: pluginRuntime.drainEvents(), promise: pluginRuntime.drainEvents(),
@@ -2029,16 +2050,6 @@ export async function createPaseoDaemon(
}; };
} }
async function closeStaleAgentRecords(agentStorage: AgentStorage): Promise<void> {
// No provider runtimes are loaded at startup. Repair interrupted
// shutdowns before any status consumer sees the persisted active states.
for (const record of await agentStorage.list()) {
if (record.lastStatus === "running" || record.lastStatus === "initializing") {
await agentStorage.upsert({ ...record, lastStatus: "closed" });
}
}
}
/** /**
* Closing an agent asks its provider to close the session and waits for the * Closing an agent asks its provider to close the session and waits for the
* answer. A provider that never answers must not hold the daemon open, so a * answer. A provider that never answers must not hold the daemon open, so a
+10
View File
@@ -590,6 +590,8 @@ export type ResolvedDaemonSettings = Pick<
| "relayRequireProtectedSecurity" | "relayRequireProtectedSecurity"
| "appBaseUrl" | "appBaseUrl"
| "idleRuntimeCleanup" | "idleRuntimeCleanup"
| "resumeOnStartup"
| "resumePrompt"
| "providerCatalogRefreshTimeoutMs" | "providerCatalogRefreshTimeoutMs"
| "metadataGeneration" | "metadataGeneration"
| "providerOverrides" | "providerOverrides"
@@ -677,6 +679,7 @@ export function resolveReloadableConfig(
relayEnabled: relay.enabled, relayEnabled: relay.enabled,
relayRequireProtectedSecurity: relay.requireProtectedSecurity, relayRequireProtectedSecurity: relay.requireProtectedSecurity,
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true, idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
...resolveRestartRecoveryConfig(persisted.agents),
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs, providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
metadataGeneration: persisted.agents?.metadataGeneration, metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides: extractProviderOverrides( providerOverrides: extractProviderOverrides(
@@ -827,3 +830,10 @@ function resolveSpeechOverrideControlledPaths(
if (env.PASEO_LOCAL_MODELS_DIR !== undefined) paths.push("providers.local.modelsDir"); if (env.PASEO_LOCAL_MODELS_DIR !== undefined) paths.push("providers.local.modelsDir");
return paths; return paths;
} }
function resolveRestartRecoveryConfig(agents: PersistedConfig["agents"]) {
return {
resumeOnStartup: agents?.resumeOnStartup ?? true,
resumePrompt: agents?.resumePrompt ?? "Resume working.",
};
}
@@ -940,6 +940,15 @@ describe("DaemonConfigStore", () => {
await store.patch({ idleRuntimeCleanup: true }); await store.patch({ idleRuntimeCleanup: true });
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true); expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
await store.patch({ resumeOnStartup: false, resumePrompt: "Keep going.\n" });
expect(loadPersistedConfig(paseoHome).agents).toMatchObject({
resumeOnStartup: false,
resumePrompt: "Keep going.\n",
});
expect(loadConfig(paseoHome, { env: {} })).toMatchObject({
resumeOnStartup: false,
resumePrompt: "Keep going.\n",
});
}); });
test("patch persists custom ACP provider overrides into config.json", async () => { test("patch persists custom ACP provider overrides into config.json", async () => {
@@ -29,6 +29,8 @@ interface SupportedMutableConfigPatch {
autoArchiveAfterMerge?: boolean; autoArchiveAfterMerge?: boolean;
enableTerminalAgentHooks?: boolean; enableTerminalAgentHooks?: boolean;
idleRuntimeCleanup?: boolean; idleRuntimeCleanup?: boolean;
resumeOnStartup?: boolean;
resumePrompt?: string;
appendSystemPrompt?: string; appendSystemPrompt?: string;
terminalProfiles?: MutableDaemonConfig["terminalProfiles"]; terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
agentProfiles?: MutableDaemonConfig["agentProfiles"]; agentProfiles?: MutableDaemonConfig["agentProfiles"];
@@ -193,6 +195,8 @@ const RELOADABLE_PATHS = [
"daemon.agentProfiles", "daemon.agentProfiles",
"app.baseUrl", "app.baseUrl",
"agents.idleRuntimeCleanup", "agents.idleRuntimeCleanup",
"agents.resumeOnStartup",
"agents.resumePrompt",
"agents.providers", "agents.providers",
"agents.catalogRefreshTimeoutMs", "agents.catalogRefreshTimeoutMs",
"agents.metadataGeneration", "agents.metadataGeneration",
@@ -218,6 +222,8 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
["daemon.agentProfiles", "agentProfiles"], ["daemon.agentProfiles", "agentProfiles"],
["app.baseUrl", "app.baseUrl"], ["app.baseUrl", "app.baseUrl"],
["agents.idleRuntimeCleanup", "idleRuntimeCleanup"], ["agents.idleRuntimeCleanup", "idleRuntimeCleanup"],
["agents.resumeOnStartup", "resumeOnStartup"],
["agents.resumePrompt", "resumePrompt"],
["agents.providers", "providers"], ["agents.providers", "providers"],
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"], ["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
["agents.metadataGeneration", "metadataGeneration"], ["agents.metadataGeneration", "metadataGeneration"],
@@ -292,6 +298,8 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut
...(patch.enableTerminalAgentHooks !== undefined ...(patch.enableTerminalAgentHooks !== undefined
? { enableTerminalAgentHooks: patch.enableTerminalAgentHooks } ? { enableTerminalAgentHooks: patch.enableTerminalAgentHooks }
: {}), : {}),
...(patch.resumeOnStartup !== undefined ? { resumeOnStartup: patch.resumeOnStartup } : {}),
...(patch.resumePrompt !== undefined ? { resumePrompt: patch.resumePrompt } : {}),
...(patch.idleRuntimeCleanup !== undefined ...(patch.idleRuntimeCleanup !== undefined
? { idleRuntimeCleanup: patch.idleRuntimeCleanup } ? { idleRuntimeCleanup: patch.idleRuntimeCleanup }
: {}), : {}),
@@ -702,6 +710,7 @@ function mergeMutableAgentPatch(
patch.providers === undefined && patch.providers === undefined &&
patch.metadataGeneration === undefined && patch.metadataGeneration === undefined &&
patch.skills === undefined && patch.skills === undefined &&
!hasRestartRecoveryPatch(patch) &&
patch.idleRuntimeCleanup === undefined && patch.idleRuntimeCleanup === undefined &&
removeProviders.length === 0 removeProviders.length === 0
) { ) {
@@ -735,6 +744,7 @@ function mergeMutableAgentPatch(
next["skills"] = { selection: patch.skills.selection }; next["skills"] = { selection: patch.skills.selection };
} }
Object.assign(next, restartRecoveryPatch(patch));
if (patch.idleRuntimeCleanup !== undefined) { if (patch.idleRuntimeCleanup !== undefined) {
next["idleRuntimeCleanup"] = patch.idleRuntimeCleanup; next["idleRuntimeCleanup"] = patch.idleRuntimeCleanup;
} }
@@ -771,3 +781,13 @@ function mergeMutableDaemonPatch(
if (patch.agentProfiles !== undefined) next.agentProfiles = patch.agentProfiles; if (patch.agentProfiles !== undefined) next.agentProfiles = patch.agentProfiles;
return Object.keys(next).length > 0 ? next : undefined; return Object.keys(next).length > 0 ? next : undefined;
} }
function hasRestartRecoveryPatch(patch: SupportedMutableConfigPatch) {
return patch.resumeOnStartup !== undefined || patch.resumePrompt !== undefined;
}
function restartRecoveryPatch(patch: SupportedMutableConfigPatch) {
return {
...(patch.resumeOnStartup !== undefined ? { resumeOnStartup: patch.resumeOnStartup } : {}),
...(patch.resumePrompt !== undefined ? { resumePrompt: patch.resumePrompt } : {}),
};
}
@@ -76,10 +76,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { DaemonClient } from "../test-utils/daemon-client.js"; import { DaemonClient } from "../test-utils/daemon-client.js";
async function restartFixture(root: string) { async function restartFixture(
root: string,
settings: { resumeOnStartup?: boolean; resumePrompt?: string } = {},
fixture: { holdResume?: boolean } = {},
) {
const child = fork( const child = fork(
fileURLToPath(new URL("../test-utils/finish-subscription-daemon.ts", import.meta.url)), fileURLToPath(new URL("../test-utils/finish-subscription-daemon.ts", import.meta.url)),
[root], [root, JSON.stringify(settings), JSON.stringify(fixture)],
{ {
execArgv: ["--conditions=source", "--import", "tsx"], execArgv: ["--conditions=source", "--import", "tsx"],
stdio: ["ignore", "pipe", "pipe", "ipc"], stdio: ["ignore", "pipe", "pipe", "ipc"],
@@ -183,7 +187,25 @@ test.each([false, true])(
await expect.poll(() => readPrompts(root), { timeout: 15000 }).toHaveLength(1); await expect.poll(() => readPrompts(root), { timeout: 15000 }).toHaveLength(1);
const prompts = await readPrompts(root); const prompts = await readPrompts(root);
expect(prompts[0]).toContain(`Agent ${childId}`); expect(prompts[0]).toContain(`Agent ${childId}`);
expect(prompts[0]).toContain("was closed"); expect(prompts[0]).not.toContain("was closed");
const recoveredPrompts = (await readFile(path.join(root, "all-prompts.jsonl"), "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(recoveredPrompts.filter((entry) => entry.prompt === "Resume working.")).toHaveLength(
1,
);
const restored = recoveredPrompts.find((entry) => entry.prompt === "Resume working.").config;
const original = recoveredPrompts.find((entry) =>
String(entry.prompt).includes("HOLD_CHILD"),
).config;
expect(restored).toMatchObject({
provider: original.provider,
model: original.model,
modeId: original.modeId,
cwd: original.cwd,
});
await expect await expect
.poll( .poll(
async () => async () =>
@@ -196,7 +218,7 @@ test.each([false, true])(
client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" }); client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" });
await client.connect(); await client.connect();
await client.fetchAgents({ subscribe: {} }); await client.fetchAgents({ subscribe: {} });
expect((await client.fetchAgent(childId))?.agent.status).toBe("closed"); expect((await client.fetchAgent(childId))?.agent.status).toBe("idle");
await client.close(); await client.close();
client = undefined; client = undefined;
await stopFixture(second.child, false); await stopFixture(second.child, false);
@@ -220,3 +242,120 @@ test.each([false, true])(
}, },
60000, 60000,
); );
test.each([false, true])(
"host restart recovery settings (enabled=%s)",
async (enabled) => {
const root = await mkdtemp(path.join(tmpdir(), "resume-settings-process-"));
const processes: ChildProcess[] = [];
let client: DaemonClient | undefined;
const settings = { resumeOnStartup: enabled, resumePrompt: "Continue exactly.\n" };
try {
const first = await restartFixture(root, settings);
processes.push(first.child);
client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" });
await client.connect();
const working = await client.createAgent({
provider: "claude",
cwd: first.cwd,
modeId: "full-access",
});
const idle = await client.createAgent({
provider: "claude",
cwd: first.cwd,
modeId: "full-access",
});
await client.sendMessage(working.id, "HOLD_CHILD");
await expect
.poll(
async () =>
(
await new AgentStorage(path.join(first.paseoHome, "agents"), createTestLogger()).get(
working.id,
)
)?.lastStatus,
)
.toBe("running");
const original = await new AgentStorage(
path.join(first.paseoHome, "agents"),
createTestLogger(),
).get(working.id);
await client.close();
client = undefined;
await stopFixture(first.child, false);
const second = await restartFixture(root, settings);
processes.push(second.child);
client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" });
await client.connect();
const storage = new AgentStorage(path.join(second.paseoHome, "agents"), createTestLogger());
// Resume runs in the background; it is done once the saved intent is consumed.
await expect.poll(async () => (await storage.get(working.id))?.resumeOnStartup).toBe(false);
const resumePrompts = async () =>
(await readFile(path.join(root, "all-prompts.jsonl"), "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line))
.filter((entry) => entry.prompt === settings.resumePrompt);
await expect.poll(resumePrompts).toHaveLength(enabled ? 1 : 0);
expect((await client.fetchAgent(idle.id))?.agent.status).toBe("closed");
const restored = await storage.get(working.id);
expect(restored?.persistence?.sessionId).toBe(original?.persistence?.sessionId);
} finally {
await client?.close();
for (const child of processes.toReversed()) await stopFixture(child, true);
await rm(root, { recursive: true, force: true });
}
},
60000,
);
test("a provider that is slow to reopen does not hold daemon startup", async () => {
const root = await mkdtemp(path.join(tmpdir(), "resume-background-process-"));
const processes: ChildProcess[] = [];
let client: DaemonClient | undefined;
try {
const first = await restartFixture(root);
processes.push(first.child);
client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" });
await client.connect();
const working = await client.createAgent({
provider: "claude",
cwd: first.cwd,
modeId: "full-access",
});
await client.sendMessage(working.id, "HOLD_CHILD");
await expect
.poll(
async () =>
(
await new AgentStorage(path.join(first.paseoHome, "agents"), createTestLogger()).get(
working.id,
)
)?.lastStatus,
)
.toBe("running");
await client.close();
client = undefined;
await stopFixture(first.child, false);
const second = await restartFixture(root, {}, { holdResume: true });
processes.push(second.child);
client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" });
await client.connect();
expect((await client.fetchAgent(working.id))?.agent.id).toBe(working.id);
second.child.send("release-resume");
await expect
.poll(async () =>
(await readFile(path.join(root, "all-prompts.jsonl"), "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line).prompt)
.filter((prompt) => prompt === "Resume working."),
)
.toHaveLength(1);
} finally {
await client?.close();
for (const child of processes.toReversed()) await stopFixture(child, true);
await rm(root, { recursive: true, force: true });
}
}, 30000);
@@ -60,7 +60,7 @@ export interface AttemptFirstAgentBranchAutoNameResult {
} }
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps { export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
lifecycle?: Pick<PluginLifecycle, "wait">; lifecycle?: Pick<PluginLifecycle, "wait" | "before">;
workspaceGitService: WorkspaceGitService; workspaceGitService: WorkspaceGitService;
workspaceProvisioning: Pick<WorkspaceProvisioningService, "createWorkspaceForWorktree">; workspaceProvisioning: Pick<WorkspaceProvisioningService, "createWorkspaceForWorktree">;
} }
@@ -348,6 +348,8 @@ export const PersistedConfigSchema = z
.object({ .object({
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
idleRuntimeCleanup: z.boolean().optional(), idleRuntimeCleanup: z.boolean().optional(),
resumeOnStartup: z.boolean().optional(),
resumePrompt: z.string().optional(),
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(), catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
metadataGeneration: AgentMetadataGenerationSchema.optional(), metadataGeneration: AgentMetadataGenerationSchema.optional(),
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(), skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
@@ -730,7 +730,7 @@ export default function contribute(server: PluginServerContext) {
await service.stopAllPlugins(); await service.stopAllPlugins();
} }
}, },
30_000, 60_000,
); );
it("keeps the running commit when a Git update build command fails", async () => { it("keeps the running commit when a Git update build command fails", async () => {
@@ -1323,8 +1323,8 @@ export default function contribute(plugin: unknown) {
describe("npm plugin installation", () => { describe("npm plugin installation", () => {
let registry: Awaited<ReturnType<typeof startNpmRegistry>>; let registry: Awaited<ReturnType<typeof startNpmRegistry>>;
// Packing the registry starts eleven real npm processes. Give fixture construction its // Give real npm fixture construction its own setup budget so it does not consume
// own setup budget so Windows process startup does not consume the installation journey. // the installation journey on Windows.
beforeAll(async () => { beforeAll(async () => {
const packages = npmPluginPackages(); const packages = npmPluginPackages();
const fixture = packages[1]; const fixture = packages[1];
@@ -463,7 +463,7 @@ ${options.hooks ?? ""}
test("a clean daemon shutdown leaves a completed plugin-provider agent without an error", async () => { test("a clean daemon shutdown leaves a completed plugin-provider agent without an error", async () => {
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-shutdown-")); const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-shutdown-"));
onTestFinished(() => rm(root, { recursive: true, force: true })); onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
const pluginDirectory = await createProviderPlugin(root); const pluginDirectory = await createProviderPlugin(root);
const workspace = path.join(root, "workspace"); const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
@@ -507,7 +507,7 @@ test("a clean daemon shutdown leaves a completed plugin-provider agent without a
test("reloading a plugin leaves a completed plugin-provider agent without an error", async () => { test("reloading a plugin leaves a completed plugin-provider agent without an error", async () => {
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-reload-")); const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-reload-"));
onTestFinished(() => rm(root, { recursive: true, force: true })); onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
const pluginDirectory = await createProviderPlugin(root); const pluginDirectory = await createProviderPlugin(root);
const workspace = path.join(root, "workspace"); const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
@@ -556,7 +556,7 @@ test("reloading a plugin leaves a completed plugin-provider agent without an err
test("a provider that never acknowledges session.close does not hold the daemon open", async () => { test("a provider that never acknowledges session.close does not hold the daemon open", async () => {
const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-stuck-close-")); const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-stuck-close-"));
onTestFinished(() => rm(root, { recursive: true, force: true })); onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
const pluginDirectory = await createProviderPlugin(root, { acknowledgeClose: false }); const pluginDirectory = await createProviderPlugin(root, { acknowledgeClose: false });
const workspace = path.join(root, "workspace"); const workspace = path.join(root, "workspace");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
@@ -752,9 +752,36 @@ test.each([false, true])(
], ],
{ cwd: repository, stdio: "pipe" }, { cwd: repository, stdio: "pipe" },
); );
execFileSync("git", ["checkout", "-b", "plugin-base"], { cwd: repository, stdio: "pipe" });
await writeFile(path.join(repository, "from-plugin-base.txt"), "base");
execFileSync("git", ["add", "."], { cwd: repository, stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"plugin base",
],
{ cwd: repository, stdio: "pipe" },
);
execFileSync("git", ["checkout", "main"], { cwd: repository, stdio: "pipe" });
const orderFile = path.join(root, "order.jsonl"); const orderFile = path.join(root, "order.jsonl");
const pluginDirectory = await createProviderPlugin(root, { const pluginDirectory = await createProviderPlugin(root, {
acknowledgeClose: true,
hooks: ` hooks: `
server.addHook("worktree.create.before", async ({ request }) => {
const { appendFile } = await import("node:fs/promises");
await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.before", request }) + "\\n");
return { ...request, path: ${JSON.stringify(path.join(root, "elsewhere"))} + "/" + request.slug,
branch: request.branch === "accept-hook" ? "plugin-branch" : request.branch,
baseRef: request.branch === "accept-hook" ? "plugin-base" : request.baseRef };
});
server.addHook("worktree.create.after", async ({ worktree }) => { server.addHook("worktree.create.after", async ({ worktree }) => {
const { appendFile, writeFile } = await import("node:fs/promises"); const { appendFile, writeFile } = await import("node:fs/promises");
await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.after", worktree }) + "\\n"); await appendFile(${JSON.stringify(orderFile)}, JSON.stringify({ hook: "worktree.create.after", worktree }) + "\\n");
@@ -778,8 +805,11 @@ test.each([false, true])(
return request; return request;
});`, });`,
}); });
const daemon = await createTestPaseoDaemon(); let daemon = await createTestPaseoDaemon({
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); paseoHomeRoot: path.join(root, "state"),
cleanup: false,
});
let client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try { try {
await client.connect(); await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true }); await client.patchDaemonConfig({ pluginsEnabled: true });
@@ -805,6 +835,23 @@ test.each([false, true])(
}); });
expect(created.error).toBeNull(); expect(created.error).toBeNull();
const cwd = created.workspace!.workspaceDirectory; const cwd = created.workspace!.workspaceDirectory;
expect(created.workspace!.gitRuntime?.currentBranch).toBe("plugin-branch");
const persisted = JSON.parse(
await readFile(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"),
);
expect(persisted).toContainEqual(
expect.objectContaining({
workspaceId: created.workspace!.id,
branch: "plugin-branch",
baseBranch: "refs/heads/plugin-base",
worktreeRoot: cwd,
}),
);
expect(cwd).toBe(path.join(await realpath(root), "elsewhere", "accept-hook"));
expect(await readFile(path.join(cwd, "from-plugin-base.txt"), "utf8")).toBe("base");
expect(
execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8" }).trim(),
).toBe("plugin-branch");
expect(await readFile(path.join(cwd, "plugin-ready.txt"), "utf8")).toBe("ready"); expect(await readFile(path.join(cwd, "plugin-ready.txt"), "utf8")).toBe("ready");
const firstAgent = withFirstAgentContext const firstAgent = withFirstAgentContext
? created.agent! ? created.agent!
@@ -835,18 +882,40 @@ test.each([false, true])(
.split("\n") .split("\n")
.map((line) => JSON.parse(line)); .map((line) => JSON.parse(line));
expect(records.map((record) => record.hook)).toEqual([ expect(records.map((record) => record.hook)).toEqual([
"worktree.create.before",
"worktree.create.after", "worktree.create.after",
"workspace.create.after", "workspace.create.after",
"agent.create.before", "agent.create.before",
]); ]);
expect({ expect({
...records[0].worktree, ...records[1].worktree,
sourceCheckout: await realpath(records[0].worktree.sourceCheckout), sourceCheckout: await realpath(records[1].worktree.sourceCheckout),
}).toEqual({ }).toEqual({
path: cwd, path: cwd,
branch: "accept-hook", branch: "plugin-branch",
sourceCheckout: await realpath(repository), sourceCheckout: await realpath(repository),
}); });
expect((await client.fetchWorkspaces()).entries.map((workspace) => workspace.id)).toContain(
created.workspace!.id,
);
await client.archiveWorkspace(created.workspace!.id);
await expect(stat(cwd)).rejects.toMatchObject({ code: "ENOENT" });
const hookRecordsBeforeRestore = await readFile(orderFile, "utf8");
await client.close();
await daemon.close();
daemon = await createTestPaseoDaemon({
paseoHomeRoot: path.join(root, "state"),
cleanup: false,
pluginsEnabled: true,
plugins: {
"shutdown-provider-plugin": { source: "directory", path: pluginDirectory, enabled: true },
},
});
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.restoreWorkspace(created.workspace!.id);
expect(await readFile(path.join(cwd, "from-plugin-base.txt"), "utf8")).toBe("base");
expect(await readFile(orderFile, "utf8")).toBe(hookRecordsBeforeRestore);
const beforeFailure = await client.fetchWorkspaces(); const beforeFailure = await client.fetchWorkspaces();
const worktreesBeforeFailure = execFileSync("git", ["worktree", "list", "--porcelain"], { const worktreesBeforeFailure = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repository, cwd: repository,
@@ -882,12 +951,176 @@ test.each([false, true])(
} finally { } finally {
await client.close(); await client.close();
await daemon.close(); await daemon.close();
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
} }
}, },
60_000, 60_000,
); );
test("worktree before hook rejects unsafe destinations with plugin attribution before creating branches", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-before-invalid-"));
const repository = path.join(root, "repository");
execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: repository, stdio: "pipe" },
);
const occupied = path.join(root, "occupied");
await mkdir(occupied);
const destinations = [
occupied,
"relative/path",
repository,
path.join(repository, ".git", "worktree"),
root,
path.parse(root).root,
(await import("node:os")).homedir(),
];
const pluginDirectory = await createProviderPlugin(root, {
acknowledgeClose: true,
hooks: `
server.addHook("worktree.create.before", async ({ request }) => {
if (request.branch === "throws") throw new Error("creation denied");
if (request.branch === "readonly") return { ...request, repoRoot: "/different-repository" };
return { ...request, path: ${JSON.stringify(destinations)}[Number(request.branch.slice(4))] };
});`,
});
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pluginDirectory);
for (const branchName of [
...destinations.map((_, index) => `bad-${index}`),
"throws",
"readonly",
]) {
const created = await client.createWorkspace({
source: { kind: "worktree", cwd: repository, branchName, worktreeSlug: branchName },
});
expect(created.workspace).toBeNull();
expect(created.error).toContain(
"Plugin shutdown-provider-plugin worktree.create.before failed:",
);
expect(
execFileSync("git", ["branch", "--format=%(refname:short)"], {
cwd: repository,
encoding: "utf8",
}).trim(),
).toBe("main");
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repository,
encoding: "utf8",
}).match(/^worktree /gm),
).toHaveLength(1);
}
expect((await client.fetchWorkspaces()).entries).toEqual([]);
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 60_000);
test("the placement example creates a sibling worktree, opens its first agent there, lists and archives it", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-placement-example-"));
const repository = path.join(root, "repository");
execFileSync("git", ["init", "-b", "main", repository], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: repository, stdio: "pipe" },
);
const providerDirectory = await createProviderPlugin(root, {
acknowledgeClose: true,
reportOpen: true,
});
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(providerDirectory);
const installed = await client.installDirectoryPlugin(
fileURLToPath(new URL("../../../../../plugin-examples/worktree-placement", import.meta.url)),
);
expect(installed.status).toBe("running");
const created = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repository,
branchName: "feature/x",
worktreeSlug: "feature-x",
},
});
expect(created.error).toBeNull();
const cwd = path.join(await realpath(root), "repository.feature-x");
expect(created.workspace!.workspaceDirectory).toBe(cwd);
expect((await stat(cwd)).isDirectory()).toBe(true);
expect((await client.fetchWorkspaces()).entries.map((workspace) => workspace.id)).toContain(
created.workspace!.id,
);
const worktrees = await client.getPaseoWorktreeList({ cwd: repository });
expect(worktrees.error).toBeNull();
expect(worktrees.worktrees).toContainEqual(
expect.objectContaining({ worktreePath: cwd, branchName: "feature/x" }),
);
const collision = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repository,
branchName: "feature/x",
worktreeSlug: "another-slug",
},
});
expect(collision.workspace).toBeNull();
expect(collision.error).toContain("Plugin worktree-placement worktree.create.before failed:");
expect(collision.error).toContain("already exists");
const agent = await client.createAgent({
provider: PROVIDER_ID,
cwd,
workspaceId: created.workspace!.id,
});
expect(agent.cwd).toBe(cwd);
const logs = await client.getPluginLogs("shutdown-provider-plugin");
const opened = logs
.filter((entry) => entry.message.startsWith('{"hook":"provider.open"'))
.map((entry) => JSON.parse(entry.message));
expect(opened.map((entry) => entry.config.cwd)).toContain(cwd);
await client.archiveWorkspace(created.workspace!.id);
await expect(stat(cwd)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
}, 60_000);
test("worktree includes reach the first agent without copying tracked or unignored files", async () => { test("worktree includes reach the first agent without copying tracked or unignored files", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-include-")); const root = await mkdtemp(path.join(tmpdir(), "paseo-worktree-include-"));
const repository = path.join(root, "repository"); const repository = path.join(root, "repository");
@@ -1071,6 +1304,6 @@ test("worktree includes reach the first agent without copying tracked or unignor
} finally { } finally {
await client.close(); await client.close();
await daemon.close(); await daemon.close();
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
} }
}, 60_000); }, 60_000);
@@ -1,7 +1,7 @@
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { createPaseoApi } from "@getpaseo/client"; import { createPaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { PluginHookHandlers } from "./index.js"; import { validateBeforeResult, PluginHookHandlers } from "./index.js";
const paseo = createPaseoApi( const paseo = createPaseoApi(
new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }), new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }),
@@ -239,3 +239,17 @@ test("a waiting after hook propagates failures and stops later handlers", async
).rejects.toThrow("cannot initialize worktree"); ).rejects.toThrow("cannot initialize worktree");
expect(received).toEqual(["/worktree"]); expect(received).toEqual(["/worktree"]);
}); });
test("checkout-branch hooks reject a branch-off baseRef", async () => {
const request = {
kind: "checkout-branch" as const,
repoRoot: "/repo",
sourceCheckout: "/repo",
branch: "feature",
slug: "feature",
path: "/work/feature",
};
await expect(
validateBeforeResult("worktree.create.before", request, { ...request, baseRef: "main" }),
).rejects.toThrow(/baseRef/);
});
@@ -1,3 +1,4 @@
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import { import {
createLifecycleRegistration, createLifecycleRegistration,
pluginHookRegistry, pluginHookRegistry,
@@ -37,7 +38,56 @@ export type PluginBeforeResults = PluginWireBeforeRequests & {
"agent.open.before": SessionOpenWireRequest & { mcpServerOwners?: Record<string, string> }; "agent.open.before": SessionOpenWireRequest & { mcpServerOwners?: Record<string, string> };
}; };
const worktreeRequestFields = {
repoRoot: z.string(),
sourceCheckout: z.string(),
branch: z.string().min(1),
slug: z.string().refine((value) => validateBranchSlug(value).valid, "Invalid worktree slug"),
path: z.string().min(1),
};
const changeRequestFields = {
headRef: z.string().min(1),
headRepositoryOwner: z.string().optional(),
checkoutRefs: z
.array(z.object({ remoteName: z.string().optional(), remoteRef: z.string() }))
.optional(),
pushRemoteUrl: z.string().optional(),
trackOriginHead: z.boolean().optional(),
};
const worktreeRequestSchema = z.discriminatedUnion("kind", [
z
.object({ ...worktreeRequestFields, kind: z.literal("branch-off"), baseRef: z.string().min(1) })
.strict(),
z
.object({
...worktreeRequestFields,
kind: z.literal("checkout-branch"),
})
.strict(),
z
.object({
...worktreeRequestFields,
...changeRequestFields,
kind: z.literal("checkout-change-request"),
baseRef: z.string().min(1),
forge: z.string(),
changeRequestNumber: z.number().int().positive(),
headRepository: z.string().optional(),
})
.strict(),
z
.object({
...worktreeRequestFields,
...changeRequestFields,
kind: z.literal("checkout-github-pr"),
baseRef: z.string().min(1),
githubPrNumber: z.number().int().positive(),
})
.strict(),
]);
const beforeSchemas = { const beforeSchemas = {
"worktree.create.before": worktreeRequestSchema,
"agent.create.before": CreateAgentRequestMessageSchema.pick({ "agent.create.before": CreateAgentRequestMessageSchema.pick({
config: true, config: true,
env: true, env: true,
@@ -162,11 +212,11 @@ export function publishAgentStream(
} }
} }
export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>( export async function validateBeforeResult<Name extends keyof PluginWireBeforeRequests>(
name: Name, name: Name,
input: PluginWireBeforeRequests[Name], input: PluginWireBeforeRequests[Name],
output: unknown, output: unknown,
): PluginWireBeforeRequests[Name] { ): Promise<PluginWireBeforeRequests[Name]> {
const result = validateBeforeRequest(name, output); const result = validateBeforeRequest(name, output);
if (name === "agent.open.before") { if (name === "agent.open.before") {
const previous = beforeSchemas["agent.open.before"].parse(input); const previous = beforeSchemas["agent.open.before"].parse(input);
@@ -191,6 +241,13 @@ export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests
throw new Error("agent.create.before hooks cannot change the workspace directory"); throw new Error("agent.create.before hooks cannot change the workspace directory");
} }
} }
if (name === "worktree.create.before") {
const previous = beforeSchemas["worktree.create.before"].parse(input);
const next = beforeSchemas["worktree.create.before"].parse(result);
if (previous.repoRoot !== next.repoRoot || previous.sourceCheckout !== next.sourceCheckout) {
throw new Error("worktree.create.before hooks cannot change repoRoot or sourceCheckout");
}
}
return result; return result;
} }
@@ -244,7 +301,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
{ request: structuredClone(request) }, { request: structuredClone(request) },
{ paseo, signal: controller.signal }, { paseo, signal: controller.signal },
); );
if (result !== undefined) request = validateBeforeResult(hookName, request, result); if (result !== undefined) request = await validateBeforeResult(hookName, request, result);
} }
return request; return request;
} }
@@ -283,7 +340,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
if (result === undefined) continue; if (result === undefined) continue;
const local = sessionOpenLocalResultSchema.parse(result); const local = sessionOpenLocalResultSchema.parse(result);
const metadata = local.tools.map(serializeAgentTool); const metadata = local.tools.map(serializeAgentTool);
request = validateBeforeResult("agent.open.before", request, { ...local, tools: metadata }); request = await validateBeforeResult("agent.open.before", request, {
...local,
tools: metadata,
});
tools = local.tools; tools = local.tools;
} }
context.signal.throwIfAborted(); context.signal.throwIfAborted();
@@ -1,3 +1,4 @@
import { validateWorktreePath } from "../../utils/worktree-path.js";
import type { PluginHookPayload } from "@getpaseo/plugin/server"; import type { PluginHookPayload } from "@getpaseo/plugin/server";
import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js"; import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js";
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js"; import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
@@ -582,7 +583,14 @@ export class PluginRuntime {
name, name,
input: name === "agent.open.before" ? { ...request, tools: [], mcpServers: {} } : request, input: name === "agent.open.before" ? { ...request, tools: [], mcpServers: {} } : request,
}); });
request = validateBeforeResult(name, request, output); request = await validateBeforeResult(name, request, output);
if (name === "worktree.create.before") {
const worktree = request as PluginWireBeforeRequests["worktree.create.before"];
worktree.path = await validateWorktreePath({
candidate: worktree.path,
repoPath: worktree.repoRoot,
});
}
if (name === "agent.open.before") { if (name === "agent.open.before") {
const opened = request as SessionOpenWireRequest; const opened = request as SessionOpenWireRequest;
this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers); this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers);
@@ -5991,6 +5991,9 @@ test("archive_workspace_request archives a worktree-kind workspace and removes t
projectId, projectId,
cwd: worktree.worktreePath, cwd: worktree.worktreePath,
kind: "worktree", kind: "worktree",
worktreeRoot: worktree.worktreePath,
mainRepoRoot: repoDir,
isPaseoOwnedWorktree: true,
displayName: "worktree-kind-archive", displayName: "worktree-kind-archive",
createdAt: "2026-03-01T12:00:00.000Z", createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z",
@@ -4,6 +4,7 @@ import {
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
realpathSync, realpathSync,
readFileSync,
rmSync, rmSync,
statSync, statSync,
writeFileSync, writeFileSync,
@@ -476,3 +477,43 @@ test("restore rejects a branch checked out elsewhere without inventing another b
}).trim(), }).trim(),
).toBe("* feature"); ).toBe("* feature");
}); });
test("restores a user-created worktree at its recorded path outside Paseo's root", async () => {
const { tempDir, repoDir } = createGitRepository();
const worktreeRoot = join(realpathSync(tempDir), "external-worktree");
execFileSync("git", ["worktree", "add", "-b", "external-feature", worktreeRoot], {
cwd: repoDir,
stdio: "pipe",
});
const workspace = createWorkspace({
cwd: worktreeRoot,
worktreeRoot,
mainRepoRoot: repoDir,
branch: "external-feature",
baseBranch: "main",
isPaseoOwnedWorktree: false,
});
execFileSync("git", ["worktree", "remove", worktreeRoot], { cwd: repoDir, stdio: "pipe" });
const unarchived: string[] = [];
const service = createWorkspaceRecoveryService({
paseoHome: join(tempDir, "paseo-home"),
getWorkspace: async () => workspace,
getProject: async () => createProject({ rootPath: repoDir }),
isDirectory: async (target) => existsSync(target) && statSync(target).isDirectory(),
unarchiveWorkspace: async (record) => {
unarchived.push(record.workspaceId);
},
});
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
workspaceId: workspace.workspaceId,
action: "restore",
});
expect(readFileSync(join(worktreeRoot, "README.md"), "utf8").trim()).toBe("initial");
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: worktreeRoot,
encoding: "utf8",
}).trim(),
).toBe("external-feature");
expect(unarchived).toEqual([workspace.workspaceId]);
});
@@ -166,6 +166,7 @@ export function createWorkspaceRecoveryService(deps: {
} }
let previousWorktreePath = workspace.worktreeRoot; let previousWorktreePath = workspace.worktreeRoot;
let hasRecordedWorktreePath = Boolean(previousWorktreePath);
if (!previousWorktreePath) { if (!previousWorktreePath) {
// COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110 // COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110
// lack durable backing placement; remove filesystem discovery after 2027-01-17. // lack durable backing placement; remove filesystem discovery after 2027-01-17.
@@ -176,6 +177,7 @@ export function createWorkspaceRecoveryService(deps: {
previousWorktreePath = ownership.allowed previousWorktreePath = ownership.allowed
? (ownership.worktreePath ?? workspace.cwd) ? (ownership.worktreePath ?? workspace.cwd)
: workspace.cwd; : workspace.cwd;
hasRecordedWorktreePath = ownership.allowed;
} }
let recreatedWorktreePath: string; let recreatedWorktreePath: string;
@@ -187,6 +189,7 @@ export function createWorkspaceRecoveryService(deps: {
paseoHome: deps.paseoHome, paseoHome: deps.paseoHome,
worktreesRoot: deps.worktreesRoot, worktreesRoot: deps.worktreesRoot,
...(hasRecordedWorktreePath ? { worktreePath: previousWorktreePath } : {}),
}); });
recreatedWorktreePath = result.worktreePath; recreatedWorktreePath = result.worktreePath;
} catch (error) { } catch (error) {
@@ -67,6 +67,7 @@ interface FakeAgentSessionOptions {
export interface TestAgentClientOptions { export interface TestAgentClientOptions {
beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>; beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>;
beforeResumeSession?: (handle: AgentPersistenceHandle) => Promise<void>;
closeSession?: () => Promise<void>; closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>; beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
@@ -1254,6 +1255,7 @@ class FakeAgentClient implements AgentClient {
overrides?: Partial<AgentSessionConfig>, overrides?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext, _launchContext?: AgentLaunchContext,
): Promise<AgentSession> { ): Promise<AgentSession> {
await this.options.beforeResumeSession?.(handle);
const cfg: AgentSessionConfig = { const cfg: AgentSessionConfig = {
provider: this.provider, provider: this.provider,
cwd: overrides?.cwd ?? process.cwd(), cwd: overrides?.cwd ?? process.cwd(),
@@ -9,18 +9,24 @@ const root = process.argv[2];
if (!root || !process.send) throw new Error("Expected isolated test home and IPC parent"); if (!root || !process.send) throw new Error("Expected isolated test home and IPC parent");
await mkdir(join(root, "static"), { recursive: true }); await mkdir(join(root, "static"), { recursive: true });
const held = Promise.withResolvers<void>(); const held = Promise.withResolvers<void>();
const fixture = JSON.parse(process.argv[4] ?? "{}") as { holdResume?: boolean };
const resumeReleased = Promise.withResolvers<void>();
if (!fixture.holdResume) resumeReleased.resolve();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
paseoHomeRoot: root, paseoHomeRoot: root,
...JSON.parse(process.argv[3] ?? "{}"),
staticDir: join(root, "static"), staticDir: join(root, "static"),
logger: pino({ level: "warn" }, pino.destination(join(root, "fixture.log"))), logger: pino({ level: "warn" }, pino.destination(join(root, "fixture.log"))),
cleanup: false, cleanup: false,
mcpEnabled: true, mcpEnabled: true,
agentClients: createTestAgentClients({ agentClients: createTestAgentClients({
onStartTurn: (prompt) => { onStartTurn: (prompt, config) => {
void appendFile(join(root, "all-prompts.jsonl"), `${JSON.stringify({ prompt, config })}\n`);
if (String(prompt).includes('kind="agent-notification"')) { if (String(prompt).includes('kind="agent-notification"')) {
void appendFile(join(root, "parent-prompts.jsonl"), `${JSON.stringify(prompt)}\n`); void appendFile(join(root, "parent-prompts.jsonl"), `${JSON.stringify(prompt)}\n`);
} }
}, },
beforeResumeSession: () => resumeReleased.promise,
beforeTurnComplete: (prompt) => beforeTurnComplete: (prompt) =>
String(prompt).includes("HOLD_CHILD") ? held.promise : Promise.resolve(), String(prompt).includes("HOLD_CHILD") ? held.promise : Promise.resolve(),
}), }),
@@ -28,6 +34,7 @@ const daemon = await createTestPaseoDaemon({
process.send({ port: daemon.port, paseoHome: daemon.paseoHome, cwd: daemon.staticDir }); process.send({ port: daemon.port, paseoHome: daemon.paseoHome, cwd: daemon.staticDir });
process.on("message", (message) => { process.on("message", (message) => {
if (message === "finish") held.resolve(); if (message === "finish") held.resolve();
if (message === "release-resume") resumeReleased.resolve();
if (message === "stop") { if (message === "stop") {
void daemon.close().then( void daemon.close().then(
() => process.exit(0), () => process.exit(0),
@@ -52,6 +52,8 @@ interface TestPaseoDaemonOptions {
trustedProxies?: PaseoDaemonConfig["trustedProxies"]; trustedProxies?: PaseoDaemonConfig["trustedProxies"];
agentProfiles?: AgentProfile[]; agentProfiles?: AgentProfile[];
autoArchiveAfterMerge?: boolean; autoArchiveAfterMerge?: boolean;
resumeOnStartup?: boolean;
resumePrompt?: string;
pluginsEnabled?: PaseoDaemonConfig["pluginsEnabled"]; pluginsEnabled?: PaseoDaemonConfig["pluginsEnabled"];
builtinPlugins?: BuiltinPluginLoader; builtinPlugins?: BuiltinPluginLoader;
plugins?: PaseoDaemonConfig["plugins"]; plugins?: PaseoDaemonConfig["plugins"];
@@ -226,6 +228,8 @@ async function prepareTestDaemonConfig(
downloadTokenTtlMs: options.downloadTokenTtlMs, downloadTokenTtlMs: options.downloadTokenTtlMs,
agentProfiles: options.agentProfiles, agentProfiles: options.agentProfiles,
autoArchiveAfterMerge: options.autoArchiveAfterMerge, autoArchiveAfterMerge: options.autoArchiveAfterMerge,
resumeOnStartup: options.resumeOnStartup,
resumePrompt: options.resumePrompt,
pluginsEnabled: options.pluginsEnabled, pluginsEnabled: options.pluginsEnabled,
plugins: options.plugins, plugins: options.plugins,
}; };
@@ -2013,6 +2013,7 @@ export class VoiceAssistantWebSocketServer {
skillManagement: true, skillManagement: true,
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26. // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
idleRuntimeCleanupSetting: true, idleRuntimeCleanupSetting: true,
resumeOnStartupSetting: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true, "terminal-restore-modes": true,
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
@@ -7,7 +7,9 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import type { ForgeService } from "../services/forge-service.js"; import type { ForgeService } from "../services/forge-service.js";
import { createRealpathAwarePathMatcher } from "../utils/path.js"; import { createRealpathAwarePathMatcher } from "../utils/path.js";
import { createWorktree, type WorktreeConfig } from "../utils/worktree.js"; import { getPaseoWorktreeMetadataPath } from "../utils/worktree-metadata.js";
import { archiveCommand } from "./worktree/commands.js";
import { createWorktree, listPaseoWorktrees, type WorktreeConfig } from "../utils/worktree.js";
import type { ManagedAgent } from "./agent/agent-manager.js"; import type { ManagedAgent } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { WorkspaceGitService } from "./workspace-git-service.js";
@@ -190,6 +192,54 @@ function assertArchiveResult(
} }
describe("archiveByScope", () => { describe("archiveByScope", () => {
test.each(["workspace", "worktree"] as const)(
"%s archive preserves a restored external worktree despite its marker",
async (kind) => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const recordedPath = path.join(tempDir, "external");
execFileSync("git", ["worktree", "add", "-b", "external", recordedPath], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["worktree", "remove", recordedPath], { cwd: repoDir, stdio: "pipe" });
const restored = await createWorktree({
cwd: repoDir,
paseoHome,
worktreeSlug: "external",
worktreePath: recordedPath,
source: { kind: "restore", branchName: "external", baseRef: "main" },
});
expect(existsSync(await getPaseoWorktreeMetadataPath(restored.worktreePath))).toBe(true);
const workspaceId = "external-workspace";
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{
workspaceId,
cwd: restored.worktreePath,
kind: "worktree",
worktreeRoot: restored.worktreePath,
mainRepoRoot: repoDir,
isPaseoOwnedWorktree: false,
},
],
}),
{
scope:
kind === "workspace"
? { kind, workspaceId }
: { kind, targetPath: restored.worktreePath },
requestId: "archive-external",
},
);
expect(result.removedDirectory).toBe(false);
expect(existsSync(restored.worktreePath)).toBe(true);
expect(result.archivedWorkspaceIds).toEqual([workspaceId]);
},
);
test("workspace scope archives the record and removes the directory on last reference", async () => { test("workspace scope archives the record and removes the directory on last reference", async () => {
const { tempDir, repoDir } = createGitRepo(); const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo"); const paseoHome = path.join(tempDir, ".paseo");
@@ -220,6 +270,120 @@ describe("archiveByScope", () => {
expect(existsSync(worktree.worktreePath)).toBe(false); expect(existsSync(worktree.worktreePath)).toBe(false);
}); });
test("workspace scope archives the record but leaves a plugin-placed directory whose marker is gone", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createWorktree({
cwd: repoDir,
worktreeSlug: "unproven-workspace",
source: { kind: "branch-off", baseBranch: "main", branchName: "unproven-workspace" },
paseoHome,
worktreePath: path.join(tempDir, "placed-worktree"),
});
// Remove the ownership marker: no proof remains for this path.
rmSync(await getPaseoWorktreeMetadataPath(worktree.worktreePath), { force: true });
const workspaceId = "ws-unproven";
const logs: Array<{ targetPath: string; msg: string }> = [];
const dependencies = createArchiveDeps({
paseoHome,
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
});
dependencies.sessionLogger = pino(
{ level: "warn" },
{
write(message) {
logs.push(JSON.parse(message));
},
},
);
const result = await archiveByScope(dependencies, {
scope: { kind: "workspace", workspaceId },
requestId: "req-unproven-workspace",
});
assertArchiveResult(result, { archivedWorkspaceIds: [workspaceId], removedDirectory: false });
expect(result.directoryLeftBehind?.path).toBe(worktree.worktreePath);
expect(result.directoryLeftBehind?.reason).toContain("no ownership proof");
expect(existsSync(worktree.worktreePath)).toBe(true);
expect(logs).toContainEqual(
expect.objectContaining({
targetPath: worktree.worktreePath,
msg: expect.stringContaining("no ownership proof"),
}),
);
});
test("archives a plugin-placed worktree by its discovered directory name", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktreePath = path.join(tempDir, "sibling-feature");
await createWorktree({
cwd: repoDir,
paseoHome,
worktreePath,
worktreeSlug: "original-slug",
source: { kind: "branch-off", branchName: "feature", baseBranch: "main" },
});
const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] });
const result = await archiveCommand(
{
...dependencies,
workspaceGitService: {
...dependencies.workspaceGitService,
listWorktrees: (cwd) => listPaseoWorktrees({ cwd, paseoHome }),
},
},
{
repoRoot: repoDir,
worktreeSlug: "sibling-feature",
scope: "worktree",
requestId: "slug-archive",
},
);
expect(result).toEqual({ ok: true, removedAgents: [] });
expect(existsSync(worktreePath)).toBe(false);
});
test("workspace scope deletes a plugin-placed directory on registry proof even with the marker gone", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createWorktree({
cwd: repoDir,
worktreeSlug: "registry-proven-workspace",
source: {
kind: "branch-off",
baseBranch: "main",
branchName: "registry-proven-workspace",
},
paseoHome,
worktreePath: path.join(tempDir, "placed-worktree"),
});
rmSync(await getPaseoWorktreeMetadataPath(worktree.worktreePath), { force: true });
const workspaceId = "ws-registry-proven";
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{
workspaceId,
cwd: worktree.worktreePath,
kind: "worktree",
isPaseoOwnedWorktree: true,
worktreeRoot: worktree.worktreePath,
mainRepoRoot: repoDir,
},
],
}),
{ scope: { kind: "workspace", workspaceId }, requestId: "req-registry-proven-workspace" },
);
assertArchiveResult(result, { archivedWorkspaceIds: [workspaceId], removedDirectory: true });
expect(result.directoryLeftBehind).toBeUndefined();
expect(existsSync(worktree.worktreePath)).toBe(false);
});
test("workspace scope runs teardown while keeping a directory referenced by a sibling", async () => { test("workspace scope runs teardown while keeping a directory referenced by a sibling", async () => {
const { tempDir, repoDir } = createGitRepo(); const { tempDir, repoDir } = createGitRepo();
writeFileSync( writeFileSync(
@@ -69,6 +69,10 @@ export interface ArchiveResult {
archivedAgentIds: string[]; archivedAgentIds: string[];
archivedWorkspaceIds: string[]; archivedWorkspaceIds: string[];
removedDirectory: boolean; removedDirectory: boolean;
// Set when the record archived but its worktree directory was left on
// disk because no ownership proof held for it. The directory is never
// deleted in that case; the path and reason surface here and in the log.
directoryLeftBehind?: { path: string; reason: string };
} }
export interface ArchiveByScopeRequest { export interface ArchiveByScopeRequest {
@@ -92,6 +96,15 @@ export async function requireActiveWorkspaceForArchive(
interface BackingDirectory { interface BackingDirectory {
path: string; path: string;
isPaseoOwnedWorktree: boolean; isPaseoOwnedWorktree: boolean;
// True when the directory backs a worktree workspace (or a worktree-scope
// archive), so a failed ownership proof means a Paseo worktree directory
// is being left behind rather than a user's local checkout being ignored.
expectsPaseoOwnership: boolean;
// True only when this backing came from a workspace registry record
// matched to this path (the record is itself an ownership proof, passed
// through to deletePaseoWorktree). Disk-derived backings stay false:
// they must prove ownership on disk.
registryProven: boolean;
mainRepoRoot: string | null; mainRepoRoot: string | null;
paseoWorktreesRoot: string | null; paseoWorktreesRoot: string | null;
} }
@@ -141,6 +154,7 @@ async function archiveByScopeWithPriority(
} }
let removedDirectory = false; let removedDirectory = false;
let directoryLeftBehind: ArchiveResult["directoryLeftBehind"];
try { try {
if (targetWorkspaceIds.length > 0) { if (targetWorkspaceIds.length > 0) {
@@ -168,18 +182,21 @@ async function archiveByScopeWithPriority(
} }
if (target.backing !== null) { if (target.backing !== null) {
removedDirectory = await maybeRemoveDirectory( const removal = await maybeRemoveDirectory(
dependencies, dependencies,
request, request,
target, target,
archivedWorkspaceIds, archivedWorkspaceIds,
); );
removedDirectory = removal.removedDirectory;
directoryLeftBehind = removal.directoryLeftBehind;
} }
return { return {
archivedAgentIds: Array.from(archivedAgents), archivedAgentIds: Array.from(archivedAgents),
archivedWorkspaceIds, archivedWorkspaceIds,
removedDirectory, removedDirectory,
...(directoryLeftBehind ? { directoryLeftBehind } : {}),
}; };
} finally { } finally {
if (targetWorkspaceIds.length > 0) { if (targetWorkspaceIds.length > 0) {
@@ -226,14 +243,16 @@ async function resolveArchiveTarget(
}), }),
) )
).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null); ).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null);
const persistedMainRepoRoot = targetWorkspaces.find( const recordedId = await dependencies.findWorkspaceIdForCwd(backing.path);
(workspace) => workspace.mainRepoRoot, const recorded = recordedId ? await dependencies.getWorkspace?.(recordedId) : null;
)?.mainRepoRoot; const record =
targetWorkspaces.find((workspace) => workspace.kind === "worktree") ??
(recorded && matchesBackingDirectory(recorded.worktreeRoot ?? recorded.cwd)
? recorded
: null) ??
targetWorkspaces[0];
return { return {
backing: { backing: record ? await resolveWorkspaceBackingDirectory(record, dependencies) : backing,
...backing,
mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot,
},
teardownTargets: teardownTargets:
targetWorkspaces.length > 0 targetWorkspaces.length > 0
? targetWorkspaces.map((workspace) => ({ ? targetWorkspaces.map((workspace) => ({
@@ -271,10 +290,22 @@ async function resolveWorkspaceBackingDirectory(
workspace: ActiveWorkspaceRef, workspace: ActiveWorkspaceRef,
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">, dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
): Promise<BackingDirectory> { ): Promise<BackingDirectory> {
if (workspace.isPaseoOwnedWorktree === false) {
return {
path: resolve(workspace.worktreeRoot ?? workspace.cwd),
isPaseoOwnedWorktree: false,
expectsPaseoOwnership: false,
registryProven: false,
mainRepoRoot: workspace.mainRepoRoot ?? null,
paseoWorktreesRoot: null,
};
}
if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) { if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) {
return { return {
path: resolve(workspace.worktreeRoot), path: resolve(workspace.worktreeRoot),
isPaseoOwnedWorktree: true, isPaseoOwnedWorktree: true,
expectsPaseoOwnership: true,
registryProven: true,
mainRepoRoot: workspace.mainRepoRoot, mainRepoRoot: workspace.mainRepoRoot,
paseoWorktreesRoot: null, paseoWorktreesRoot: null,
}; };
@@ -283,6 +314,8 @@ async function resolveWorkspaceBackingDirectory(
return { return {
path: resolve(workspace.cwd), path: resolve(workspace.cwd),
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
expectsPaseoOwnership: false,
registryProven: false,
mainRepoRoot: workspace.mainRepoRoot ?? null, mainRepoRoot: workspace.mainRepoRoot ?? null,
paseoWorktreesRoot: null, paseoWorktreesRoot: null,
}; };
@@ -309,6 +342,8 @@ async function resolveBackingDirectory(
return { return {
path: resolve(ownership.allowed && ownership.worktreePath ? ownership.worktreePath : cwd), path: resolve(ownership.allowed && ownership.worktreePath ? ownership.worktreePath : cwd),
isPaseoOwnedWorktree: ownership.allowed, isPaseoOwnedWorktree: ownership.allowed,
expectsPaseoOwnership: true,
registryProven: false,
mainRepoRoot: ownership.repoRoot ?? null, mainRepoRoot: ownership.repoRoot ?? null,
paseoWorktreesRoot: ownership.worktreeRoot ?? null, paseoWorktreesRoot: ownership.worktreeRoot ?? null,
}; };
@@ -352,10 +387,28 @@ async function maybeRemoveDirectory(
request: Pick<ArchiveByScopeRequest, "requestId">, request: Pick<ArchiveByScopeRequest, "requestId">,
target: ArchiveTarget, target: ArchiveTarget,
archivedWorkspaceIds: string[], archivedWorkspaceIds: string[],
): Promise<boolean> { ): Promise<{
removedDirectory: boolean;
directoryLeftBehind?: { path: string; reason: string };
}> {
const backing = target.backing; const backing = target.backing;
if (!backing?.isPaseoOwnedWorktree) { if (!backing?.isPaseoOwnedWorktree) {
return false; // Missing ownership proof for a worktree-backed directory: the record
// still archives, but the directory is left in place and surfaced.
// Never delete an unproven directory.
if (backing?.expectsPaseoOwnership) {
const reason =
"no ownership proof (not under the Paseo worktrees root, no paseo/worktree.json marker, no registry record)";
dependencies.sessionLogger?.warn(
{ targetPath: backing.path, requestId: request.requestId },
`Worktree directory left in place during archive: ${reason}`,
);
return {
removedDirectory: false,
directoryLeftBehind: { path: backing.path, reason },
};
}
return { removedDirectory: false };
} }
const archivedWorkspaceIdSet = new Set(archivedWorkspaceIds); const archivedWorkspaceIdSet = new Set(archivedWorkspaceIds);
@@ -382,7 +435,7 @@ async function maybeRemoveDirectory(
{ err: error, targetPath: backing.path, requestId: request.requestId }, { err: error, targetPath: backing.path, requestId: request.requestId },
"Worktree teardown failed during archive; workspace already archived", "Worktree teardown failed during archive; workspace already archived",
); );
return false; return { removedDirectory: false };
} }
throw error; throw error;
} }
@@ -396,7 +449,7 @@ async function maybeRemoveDirectory(
dependencies, dependencies,
)) ))
) { ) {
return false; return { removedDirectory: false };
} }
try { try {
@@ -407,16 +460,17 @@ async function maybeRemoveDirectory(
worktreesRoot: backing.paseoWorktreesRoot ?? undefined, worktreesRoot: backing.paseoWorktreesRoot ?? undefined,
paseoHome: dependencies.paseoHome, paseoHome: dependencies.paseoHome,
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
registryProven: backing.registryProven,
}); });
dependencies.workspaceGitService.invalidateWorktrees(); dependencies.workspaceGitService.invalidateWorktrees();
dependencies.github.invalidate({ cwd: backing.path }); dependencies.github.invalidate({ cwd: backing.path });
return true; return { removedDirectory: true };
} catch (error) { } catch (error) {
dependencies.sessionLogger?.warn( dependencies.sessionLogger?.warn(
{ err: error, targetPath: backing.path, requestId: request.requestId }, { err: error, targetPath: backing.path, requestId: request.requestId },
"Worktree disk removal failed during archive; workspace already archived", "Worktree disk removal failed during archive; workspace already archived",
); );
return false; return { removedDirectory: false };
} }
} }
@@ -1171,10 +1171,14 @@ describe("WorkspaceGitServiceImpl", () => {
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(service.getMetrics().fetchInFlightCount).toBe(1);
}); });
releaseFetch.resolve(); releaseFetch.resolve();
await flushPromises(); // The refresh debounce starts when the fetch result is applied, not when it is released.
await vi.waitFor(() => {
expect(service.getMetrics().fetchInFlightCount).toBe(0);
});
await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1); expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);

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