mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1467121b16 | ||
|
|
2f0fb2a8ba | ||
|
|
83ed52669d | ||
|
|
5cfe8f1218 | ||
|
|
38506ed372 | ||
|
|
abc97e0545 | ||
|
|
33faacc055 | ||
|
|
8537709e72 | ||
|
|
9fb7808191 |
+17
-6
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
|
sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw=
|
||||||
|
|||||||
Generated
+28
-28
@@ -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
@@ -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,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": {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
-6
@@ -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;
|
||||||
|
|||||||
@@ -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: "تحرير الذاكرة بعد ساعة من الخمول.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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é.",
|
||||||
|
|||||||
@@ -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 時間のアイドル状態の後にメモリを解放します。",
|
||||||
|
|||||||
@@ -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시간 동안 활동이 없으면 메모리를 확보합니다.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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: "Освобождает память после часа бездействия.",
|
||||||
|
|||||||
@@ -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],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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,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,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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type {
|
|||||||
PluginHookContext,
|
PluginHookContext,
|
||||||
PluginHookWorkspace,
|
PluginHookWorkspace,
|
||||||
PluginHookWorktree,
|
PluginHookWorktree,
|
||||||
|
PluginWorktreeCreateRequest,
|
||||||
PluginHookAgent,
|
PluginHookAgent,
|
||||||
PluginSessionOpenRequest,
|
PluginSessionOpenRequest,
|
||||||
PluginTurnOutcome,
|
PluginTurnOutcome,
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+41
@@ -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
Reference in New Issue
Block a user