mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f9e18bf6 | ||
|
|
5f8b9db679 |
+7
-17
@@ -1,17 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
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
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
@@ -21,8 +10,10 @@ Voice settings now let you configure speech providers and download local models
|
||||
|
||||
### 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))
|
||||
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||
- 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))
|
||||
- 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))
|
||||
- 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 a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||
@@ -32,9 +23,10 @@ Voice settings now let you configure speech providers and download local models
|
||||
|
||||
### 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))
|
||||
- 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))
|
||||
- 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))
|
||||
@@ -60,12 +52,10 @@ 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 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 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 inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- 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 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))
|
||||
|
||||
### Notes
|
||||
|
||||
+5
-17
@@ -1,17 +1,6 @@
|
||||
# 更新日志
|
||||
|
||||
尚未发布。以下变更已合入 `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
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
|
||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||
|
||||
@@ -22,7 +11,7 @@
|
||||
### 新增
|
||||
|
||||
- 插件现在可以为特定 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), [#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))
|
||||
- 新增已完成对话轮次的折叠和展开功能 ([#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))
|
||||
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
- 语音设置新增可下载的本地语音模型,支持使用 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))
|
||||
@@ -32,9 +21,10 @@
|
||||
|
||||
### 改进
|
||||
|
||||
- 新增采用 `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 窗格中新增返回父 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))
|
||||
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||
@@ -60,12 +50,10 @@
|
||||
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||
- 修复强调、链接和标题中行内代码的字号问题 ([#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))
|
||||
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#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))
|
||||
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#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))
|
||||
|
||||
### 说明
|
||||
|
||||
+2
-24
@@ -12,26 +12,6 @@ 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.
|
||||
|
||||
## 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
|
||||
|
||||
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
|
||||
@@ -159,10 +139,8 @@ caller, child, and host into one watch.
|
||||
|
||||
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
|
||||
the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
|
||||
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
|
||||
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
|
||||
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
|
||||
reporting after detachment.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-AmMp/4ugSUKXTL6H0oJaGJMztVGiYCPXeWteQaaaguw=
|
||||
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37590,7 +37590,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39793,12 +39793,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/server": "0.14.0-beta.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/server": "0.14.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -40045,10 +40045,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/relay": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -40060,7 +40060,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40304,7 +40304,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40350,7 +40350,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -40379,15 +40379,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40397,8 +40397,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40420,7 +40420,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40447,7 +40447,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -40668,16 +40668,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/highlight": "0.14.0-beta.2",
|
||||
"@getpaseo/plugin": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/relay": "0.14.0-beta.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/highlight": "0.14.0-beta.1",
|
||||
"@getpaseo/plugin": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41598,7 +41598,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43208,7 +43208,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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,26 +204,19 @@ test.describe("Projects settings", () => {
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("projects-settings-empty-");
|
||||
const client = await connectSeedClient();
|
||||
let projectViewKey: string | null = null;
|
||||
let projectId: string | null = null;
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
projectViewKey = await addProjectFromSidebar(page, repo.path);
|
||||
await openProjectSettingsFromSidebar(page, projectViewKey);
|
||||
projectId = await addProjectFromSidebar(page, repo.path);
|
||||
await openProjectSettingsFromSidebar(page, projectId);
|
||||
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
|
||||
} finally {
|
||||
if (projectViewKey) {
|
||||
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);
|
||||
if (projectId) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
|
||||
@@ -30,10 +30,6 @@ import {
|
||||
addDirectHostFromSettings,
|
||||
expectHostOnlineWithoutError,
|
||||
expectIdleAgentRelease,
|
||||
editRestartRecoveryPrompt,
|
||||
expectRestartRecoveryEnabled,
|
||||
expectRestartRecoveryPrompt,
|
||||
setRestartRecoveryEnabled,
|
||||
setIdleAgentRelease,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
@@ -256,20 +252,3 @@ test.describe("Settings host page", () => {
|
||||
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,8 +415,19 @@ test.describe("Settings — compact master-detail", () => {
|
||||
const workspace = await withWorkspace({ prefix: "host-picker-settings-back-" });
|
||||
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), workspace.workspaceId);
|
||||
|
||||
await openWorkspace(page, workspace);
|
||||
const worktreeSetupCallout = page.locator('[data-testid^="worktree-setup-callout-"]');
|
||||
|
||||
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();
|
||||
// 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.getByRole("button", { name: /Open .* settings/ }).click();
|
||||
|
||||
|
||||
@@ -53,18 +53,11 @@ async function quickDragFirstRowAfterSecond(
|
||||
await page.mouse.move(source.x, source.y + 7);
|
||||
await expect(trailingScrim).toHaveCount(0);
|
||||
await page.mouse.move(target.x, target.y, { steps: 4 });
|
||||
await finishSidebarDrag(page);
|
||||
await page.mouse.up();
|
||||
|
||||
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> {
|
||||
const before = await rowTestIds(rows);
|
||||
const source = await visibleBoundingBox(rows.nth(0));
|
||||
@@ -73,7 +66,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.down();
|
||||
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
|
||||
await finishSidebarDrag(page);
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(before);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { createCreationScenario } from "../support/helpers/creation";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const DRAG_DISTANCE = -40;
|
||||
const TOLERANCE_PX = 2;
|
||||
|
||||
function splitDividers(page: Page): Locator {
|
||||
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function dividerX(divider: Locator): Promise<number> {
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
return box.x;
|
||||
}
|
||||
|
||||
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + deltaX, y, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
const input = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
await input.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
|
||||
});
|
||||
}
|
||||
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
} finally {
|
||||
await input.detach();
|
||||
}
|
||||
}
|
||||
|
||||
async function splitIntoFourPanes(page: Page): Promise<void> {
|
||||
for (const expectedDividers of [1, 2, 3]) {
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(splitDividers(page)).toHaveCount(expectedDividers);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectDividersTrackPointer(
|
||||
page: Page,
|
||||
distance = DRAG_DISTANCE,
|
||||
drag = dragDivider,
|
||||
): Promise<void> {
|
||||
for (const index of [0, 1, 2]) {
|
||||
const divider = splitDividers(page).nth(index);
|
||||
const before = await dividerX(divider);
|
||||
await drag(divider, distance);
|
||||
await expect
|
||||
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
|
||||
message: `divider ${index + 1} should stay under the pointer`,
|
||||
})
|
||||
.toBeLessThanOrEqual(TOLERANCE_PX);
|
||||
}
|
||||
}
|
||||
|
||||
for (const hasTouch of [false, true]) {
|
||||
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
|
||||
test.use({ hasTouch });
|
||||
|
||||
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await splitIntoFourPanes(page);
|
||||
await expectDividersTrackPointer(
|
||||
page,
|
||||
DRAG_DISTANCE,
|
||||
hasTouch ? dragDividerWithTouch : dragDivider,
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const creation = await createCreationScenario(page);
|
||||
try {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Reply briefly.", "Create");
|
||||
await creation.expectAssistantReply();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await splitIntoFourPanes(page);
|
||||
await expectDividersTrackPointer(page);
|
||||
await page
|
||||
.getByTestId("workspace-explorer-toggle")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
|
||||
await page
|
||||
.getByTestId("workspace-explorer-toggle")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
|
||||
await page.reload();
|
||||
await expect(splitDividers(page)).toHaveCount(3);
|
||||
await expectDividersTrackPointer(page);
|
||||
} finally {
|
||||
await creation.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,26 @@
|
||||
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,41 +560,3 @@ export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
||||
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",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -84,6 +84,7 @@ import { getDesktopHost } from "@/desktop/host";
|
||||
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-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 { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
@@ -486,6 +487,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<DownloadToast />
|
||||
<RosettaCalloutSource />
|
||||
<UpdateCalloutSource />
|
||||
<DaemonOutdatedCalloutSource />
|
||||
<LegacyAgentSkillsMigration />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenterRootActions />
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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,6 +40,7 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
@@ -605,6 +606,8 @@ function MobileSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot nativeOnly />
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleSettings={handleSettings}
|
||||
@@ -784,7 +787,7 @@ function DesktopSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
<SidebarCalloutSlot nativeOnly={isNative} />
|
||||
|
||||
<SidebarFooter
|
||||
handleOpenProject={handleOpenProject}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ResizeHandle({
|
||||
const { theme } = useUnistyles();
|
||||
const finePointer = useHasFinePointer();
|
||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||
const touchDragRef = useRef<ResizeHandleDrag | null>(null);
|
||||
const touchStateRef = useRef<PointerState | null>(null);
|
||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
@@ -151,23 +151,30 @@ export function ResizeHandle({
|
||||
const touchGesture = useMemo(() => {
|
||||
const gesture = Gesture.Pan()
|
||||
.runOnJS(true)
|
||||
.onBegin(() => setDragging(true))
|
||||
.onStart(() => {
|
||||
touchDragRef.current = startResizeHandleDrag({
|
||||
sizes,
|
||||
index,
|
||||
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
||||
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
||||
});
|
||||
.onBegin((event) => {
|
||||
setDragging(true);
|
||||
touchStateRef.current = {
|
||||
containerSize,
|
||||
pointerStart: direction === "horizontal" ? event.absoluteX : event.absoluteY,
|
||||
drag: startResizeHandleDrag({
|
||||
sizes,
|
||||
index,
|
||||
preview: (nextSizes) => onPreviewResizeSplit(groupId, nextSizes),
|
||||
commit: (nextSizes) => onResizeSplit(groupId, nextSizes),
|
||||
}),
|
||||
};
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (containerSize <= 0) return;
|
||||
const translation = direction === "horizontal" ? event.translationX : event.translationY;
|
||||
touchDragRef.current?.move(translation / containerSize);
|
||||
const state = touchStateRef.current;
|
||||
if (!state || state.containerSize <= 0) return;
|
||||
// Gesture Handler resets web translation when the pan activates. Measure from
|
||||
// the initial press so the activation distance is included on every platform.
|
||||
const pointerCurrent = direction === "horizontal" ? event.absoluteX : event.absoluteY;
|
||||
state.drag.move((pointerCurrent - state.pointerStart) / state.containerSize);
|
||||
})
|
||||
.onEnd(() => touchDragRef.current?.finish())
|
||||
.onEnd(() => touchStateRef.current?.drag.finish())
|
||||
.onFinalize(() => {
|
||||
touchDragRef.current = null;
|
||||
touchStateRef.current = null;
|
||||
setDragging(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export type DaemonOutdatedUpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "updating"; phase: string }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export interface ResolveDaemonOutdatedCalloutInput {
|
||||
serverId: string;
|
||||
hostLabel: string;
|
||||
appVersion: string | null;
|
||||
daemonVersion: string | null;
|
||||
supportsSelfUpdate: boolean;
|
||||
desktopManaged: boolean;
|
||||
terminalsSurviveRestart: boolean;
|
||||
isConnected: boolean;
|
||||
update: DaemonOutdatedUpdateState;
|
||||
}
|
||||
|
||||
export interface DaemonOutdatedUpdateAction {
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonOutdatedCalloutDescriptor {
|
||||
id: "daemon-outdated";
|
||||
dismissalKey: string;
|
||||
priority: number;
|
||||
title: string;
|
||||
description: string[];
|
||||
variant: "default" | "error";
|
||||
updateAction: DaemonOutdatedUpdateAction | null;
|
||||
testID: "daemon-outdated-callout";
|
||||
}
|
||||
|
||||
interface ParsedVersion {
|
||||
core: [number, number, number];
|
||||
prerelease: string[];
|
||||
}
|
||||
|
||||
const VERSION_PATTERN =
|
||||
/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/i;
|
||||
const NUMERIC_IDENTIFIER = /^\d+$/;
|
||||
|
||||
function parseVersion(value: string | null): ParsedVersion | null {
|
||||
const match = value?.trim().match(VERSION_PATTERN);
|
||||
if (!match) return null;
|
||||
return {
|
||||
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
||||
prerelease: match[4] ? match[4].split(".") : [],
|
||||
};
|
||||
}
|
||||
|
||||
function compareIdentifiers(left: string, right: string): number {
|
||||
const isLeftNumeric = NUMERIC_IDENTIFIER.test(left);
|
||||
const isRightNumeric = NUMERIC_IDENTIFIER.test(right);
|
||||
if (isLeftNumeric && isRightNumeric) return Number(left) - Number(right);
|
||||
if (isLeftNumeric) return -1;
|
||||
if (isRightNumeric) return 1;
|
||||
if (left < right) return -1;
|
||||
if (left > right) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Semver precedence: a prerelease sorts before its release (0.12.0-beta.1 < 0.12.0).
|
||||
function compareVersions(left: ParsedVersion, right: ParsedVersion): number {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const difference = left.core[index] - right.core[index];
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
||||
return right.prerelease.length - left.prerelease.length;
|
||||
}
|
||||
const sharedLength = Math.min(left.prerelease.length, right.prerelease.length);
|
||||
for (let index = 0; index < sharedLength; index += 1) {
|
||||
const difference = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return left.prerelease.length - right.prerelease.length;
|
||||
}
|
||||
|
||||
function resolveUpdateAction(input: ResolveDaemonOutdatedCalloutInput): DaemonOutdatedUpdateAction {
|
||||
if (input.update.status === "updating") {
|
||||
return { label: input.update.phase, disabled: true };
|
||||
}
|
||||
return { label: i18n.t("daemonOutdated.update"), disabled: !input.isConnected };
|
||||
}
|
||||
|
||||
function formatVersion(version: ParsedVersion): string {
|
||||
const core = version.core.join(".");
|
||||
return version.prerelease.length > 0 ? `${core}-${version.prerelease.join(".")}` : core;
|
||||
}
|
||||
|
||||
// A daemon that keeps terminals across restarts does not keep them across a Paseo Desktop update
|
||||
// on Windows, which closes them. server_info does not name the host's platform, so the line says
|
||||
// both.
|
||||
function resolveTerminalsLine(input: ResolveDaemonOutdatedCalloutInput): string {
|
||||
if (!input.terminalsSurviveRestart) return i18n.t("daemonOutdated.terminalsClose");
|
||||
if (input.desktopManaged) return i18n.t("daemonOutdated.desktopUpdateTerminals");
|
||||
return i18n.t("daemonOutdated.terminalsStayOpen");
|
||||
}
|
||||
|
||||
function resolveDescription(
|
||||
input: ResolveDaemonOutdatedCalloutInput,
|
||||
versions: { daemon: ParsedVersion; app: ParsedVersion },
|
||||
): string[] {
|
||||
if (input.update.status === "failed") return [input.update.message];
|
||||
|
||||
const lines = [
|
||||
i18n.t("daemonOutdated.versions", {
|
||||
host: input.hostLabel,
|
||||
daemonVersion: `v${formatVersion(versions.daemon)}`,
|
||||
appVersion: `v${formatVersion(versions.app)}`,
|
||||
}),
|
||||
];
|
||||
if (input.desktopManaged) {
|
||||
lines.push(i18n.t("daemonOutdated.desktopManaged"));
|
||||
} else if (!input.supportsSelfUpdate) {
|
||||
lines.push(i18n.t("daemonOutdated.selfUpdateUnsupported"));
|
||||
}
|
||||
lines.push(resolveTerminalsLine(input));
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function resolveDaemonOutdatedCallout(
|
||||
input: ResolveDaemonOutdatedCalloutInput,
|
||||
): DaemonOutdatedCalloutDescriptor | null {
|
||||
const app = parseVersion(input.appVersion);
|
||||
const daemon = parseVersion(input.daemonVersion);
|
||||
if (!app || !daemon) return null;
|
||||
if (compareVersions(daemon, app) >= 0) return null;
|
||||
|
||||
// The update RPC refuses desktop-managed daemons; those update with Paseo Desktop on that computer.
|
||||
const canSelfUpdate = input.supportsSelfUpdate && !input.desktopManaged;
|
||||
const isFailed = input.update.status === "failed";
|
||||
|
||||
return {
|
||||
id: "daemon-outdated",
|
||||
dismissalKey: `daemon-outdated:${input.serverId}:${formatVersion(daemon)}`,
|
||||
priority: 250,
|
||||
title: isFailed ? i18n.t("daemonOutdated.updateFailedTitle") : i18n.t("daemonOutdated.title"),
|
||||
description: resolveDescription(input, { daemon, app }),
|
||||
variant: isFailed ? "error" : "default",
|
||||
updateAction: canSelfUpdate ? resolveUpdateAction(input) : null,
|
||||
testID: "daemon-outdated-callout",
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./sidebar-callout-slot.native";
|
||||
@@ -1,3 +0,0 @@
|
||||
export function SidebarCalloutSlot() {
|
||||
return null;
|
||||
}
|
||||
@@ -91,7 +91,7 @@ describe("SidebarCalloutSlot", () => {
|
||||
root?.render(
|
||||
<SidebarCalloutProvider>
|
||||
<RegisterCallout />
|
||||
<SidebarCalloutSlot />
|
||||
<SidebarCalloutSlot nativeOnly={false} />
|
||||
</SidebarCalloutProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
@@ -105,7 +105,7 @@ describe("SidebarCalloutSlot", () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarCalloutProvider>
|
||||
<SidebarCalloutSlot />
|
||||
<SidebarCalloutSlot nativeOnly={false} />
|
||||
</SidebarCalloutProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
+6
-2
@@ -2,10 +2,14 @@ import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
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 (
|
||||
<View style={styles.slot} collapsable={false}>
|
||||
<SidebarCalloutViewport />
|
||||
<SidebarCalloutViewport nativeOnly={nativeOnly} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
||||
import {
|
||||
collectAllPanes,
|
||||
createDefaultLayout,
|
||||
selectExplorerSidebarPaneId,
|
||||
useWorkspaceLayoutStore,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
@@ -183,8 +184,8 @@ export function SplitContainer({
|
||||
} | null>(null);
|
||||
const maximizedPaneId =
|
||||
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
|
||||
const explorerSidebarPaneId = useWorkspaceLayoutStore(
|
||||
(state) => state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? null,
|
||||
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
|
||||
selectExplorerSidebarPaneId(state, workspaceKey),
|
||||
);
|
||||
|
||||
const mainRoot = useMemo(
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
showSidebarCallout,
|
||||
type SidebarCalloutEntry,
|
||||
type SidebarCalloutOptions,
|
||||
type SidebarCalloutSelectionState,
|
||||
type SidebarCalloutState,
|
||||
unregisterSidebarCallout,
|
||||
} from "./sidebar-callout-state";
|
||||
@@ -37,7 +38,7 @@ export interface SidebarCalloutsApi {
|
||||
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
|
||||
|
||||
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
|
||||
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
|
||||
const SidebarCalloutStateContext = createContext<SidebarCalloutSelectionState | null>(null);
|
||||
|
||||
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
|
||||
void AsyncStorage.setItem(
|
||||
@@ -113,11 +114,10 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
|
||||
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
|
||||
|
||||
return (
|
||||
<SidebarCalloutApiContext.Provider value={api}>
|
||||
<SidebarCalloutStateContext.Provider value={activeCallout}>
|
||||
<SidebarCalloutStateContext.Provider value={state}>
|
||||
{children}
|
||||
</SidebarCalloutStateContext.Provider>
|
||||
</SidebarCalloutApiContext.Provider>
|
||||
@@ -132,12 +132,20 @@ export function useSidebarCallouts(): SidebarCalloutsApi {
|
||||
return api;
|
||||
}
|
||||
|
||||
export function useActiveSidebarCallout(): SidebarCalloutEntry | null {
|
||||
return useContext(SidebarCalloutStateContext);
|
||||
export function useActiveSidebarCallout(
|
||||
options: { nativeOnly?: boolean } = {},
|
||||
): SidebarCalloutEntry | null {
|
||||
const state = useContext(SidebarCalloutStateContext);
|
||||
const nativeOnly = options.nativeOnly === true;
|
||||
return useMemo(
|
||||
() => (state ? selectActiveSidebarCallout(state, { nativeOnly }) : null),
|
||||
[nativeOnly, state],
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarCalloutViewport() {
|
||||
const activeCallout = useActiveSidebarCallout();
|
||||
/** The active callout. `nativeOnly` limits it to callouts marked `native`, for native apps and compact sidebars. */
|
||||
export function SidebarCalloutViewport({ nativeOnly = false }: { nativeOnly?: boolean }) {
|
||||
const activeCallout = useActiveSidebarCallout({ nativeOnly });
|
||||
const api = useSidebarCallouts();
|
||||
if (!activeCallout) {
|
||||
return null;
|
||||
|
||||
@@ -31,6 +31,30 @@ describe("sidebar callout state", () => {
|
||||
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", () => {
|
||||
let state = createSidebarCalloutState();
|
||||
state = showSidebarCallout(state, {
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface SidebarCalloutOptions {
|
||||
priority?: number;
|
||||
onDismiss?: () => void;
|
||||
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 {
|
||||
@@ -129,10 +131,19 @@ export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCallout
|
||||
return { ...state, callouts: [] };
|
||||
}
|
||||
|
||||
export type SidebarCalloutSelectionState = Pick<
|
||||
SidebarCalloutState,
|
||||
"callouts" | "dismissedKeys" | "dismissalStorageLoaded"
|
||||
>;
|
||||
|
||||
export function selectActiveSidebarCallout(
|
||||
state: Pick<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
|
||||
state: SidebarCalloutSelectionState,
|
||||
options: { nativeOnly?: boolean } = {},
|
||||
): SidebarCalloutEntry | null {
|
||||
const visibleCallouts = state.callouts.filter((entry) => {
|
||||
if (options.nativeOnly && entry.native !== true) {
|
||||
return false;
|
||||
}
|
||||
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
|
||||
if (!dismissalKey) {
|
||||
return true;
|
||||
|
||||
@@ -2058,6 +2058,18 @@ export const ar: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "رفض",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "الخدمة قديمة",
|
||||
versions: "يشغّل {{host}} الخدمة {{daemonVersion}}. إصدار هذا التطبيق {{appVersion}}.",
|
||||
terminalsStayOpen: "يعيد التحديث تشغيل الخدمة. تبقى الطرفيات مفتوحة.",
|
||||
terminalsClose: "يعيد التحديث تشغيل الخدمة ويغلق الطرفيات المفتوحة.",
|
||||
desktopUpdateTerminals:
|
||||
"يُبقي تحديث Paseo Desktop الطرفيات مفتوحة على macOS وLinux، ويغلقها على Windows.",
|
||||
desktopManaged: "يدير Paseo Desktop هذه الخدمة. حدّث Paseo على ذلك الحاسوب.",
|
||||
selfUpdateUnsupported: "لا يمكن لهذه الخدمة تحديث نفسها. حدّث Paseo على ذلك الحاسوب.",
|
||||
update: "تحديث",
|
||||
updateFailedTitle: "فشل تحديث الخدمة",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "لا توجد بيانات للسياق",
|
||||
accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق",
|
||||
@@ -2778,14 +2790,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connect to this host to manage agents",
|
||||
resumeOnStartup: {
|
||||
title: "استئناف الوكلاء بعد إعادة التشغيل",
|
||||
info: "إعادة فتح الوكلاء الذين كانوا يعملون عند توقف المضيف وإرسال طلب الاستئناف. ينطبق ذلك بعد إعادة التشغيل العادية والتعطّل.",
|
||||
hint: "متابعة العمل المنقطع تلقائيًا.",
|
||||
prompt: "طلب الاستئناف",
|
||||
edit: "تحرير",
|
||||
editAccessibilityLabel: "تعديل الطلب",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "تحرير الوكلاء الخاملين",
|
||||
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
|
||||
|
||||
@@ -2083,6 +2083,18 @@ export const en = {
|
||||
sidebarCallout: {
|
||||
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: {
|
||||
noData: "No context data",
|
||||
accessibilityNoData: "Context window: No context data",
|
||||
@@ -2870,14 +2882,6 @@ export const en = {
|
||||
},
|
||||
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: {
|
||||
title: "Release idle agents",
|
||||
hint: "Free up memory after an hour of inactivity.",
|
||||
|
||||
@@ -2106,6 +2106,19 @@ export const es: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
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: {
|
||||
noData: "No hay datos de contexto",
|
||||
accessibilityNoData: "Ventana de contexto: No hay datos de contexto",
|
||||
@@ -2800,14 +2813,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
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: {
|
||||
title: "Liberar agentes inactivos",
|
||||
hint: "Libera memoria tras una hora de inactividad.",
|
||||
|
||||
@@ -2105,6 +2105,19 @@ export const fr: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
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: {
|
||||
noData: "Aucune donnée de contexte",
|
||||
accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte",
|
||||
@@ -2803,14 +2816,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
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: {
|
||||
title: "Libérer les agents inactifs",
|
||||
hint: "Libère de la mémoire après une heure d’inactivité.",
|
||||
|
||||
@@ -2078,6 +2078,20 @@ export const ja: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "閉じる",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "デーモンが古くなっています",
|
||||
versions: "{{host}}のデーモンは{{daemonVersion}}です。このアプリは{{appVersion}}です。",
|
||||
terminalsStayOpen: "更新するとデーモンが再起動します。ターミナルは開いたままです。",
|
||||
terminalsClose: "更新するとデーモンが再起動し、開いているターミナルは閉じられます。",
|
||||
desktopUpdateTerminals:
|
||||
"Paseo Desktop を更新すると、macOS と Linux ではターミナルは開いたままですが、Windows では閉じられます。",
|
||||
desktopManaged:
|
||||
"このデーモンはPaseo Desktopによって管理されています。そのコンピューターでPaseoを更新してください。",
|
||||
selfUpdateUnsupported:
|
||||
"このデーモンは自身を更新できません。そのコンピューターでPaseoを更新してください。",
|
||||
update: "更新",
|
||||
updateFailedTitle: "デーモンを更新できませんでした",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "コンテキストデータがありません",
|
||||
accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません",
|
||||
@@ -2767,14 +2781,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "エージェントを管理するにはこのホストに接続してください",
|
||||
resumeOnStartup: {
|
||||
title: "再起動後にエージェントを再開",
|
||||
info: "ホストの停止時に作業中だったエージェントを開き直し、再開メッセージを送信します。通常の再起動とクラッシュの両方に適用されます。",
|
||||
hint: "中断された作業を自動的に続行します。",
|
||||
prompt: "再開メッセージ",
|
||||
edit: "編集",
|
||||
editAccessibilityLabel: "メッセージを編集",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "アイドル状態のエージェントを解放",
|
||||
hint: "1 時間のアイドル状態の後にメモリを解放します。",
|
||||
|
||||
@@ -2068,6 +2068,20 @@ export const ko: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "닫기",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "데몬이 오래되었습니다",
|
||||
versions: "{{host}}의 데몬은 {{daemonVersion}}입니다. 이 앱은 {{appVersion}}입니다.",
|
||||
terminalsStayOpen: "업데이트하면 데몬이 다시 시작됩니다. 터미널은 열린 상태로 유지됩니다.",
|
||||
terminalsClose: "업데이트하면 데몬이 다시 시작되고 열려 있는 터미널이 닫힙니다.",
|
||||
desktopUpdateTerminals:
|
||||
"Paseo Desktop을 업데이트하면 macOS와 Linux에서는 터미널이 열린 상태로 유지되고, Windows에서는 닫힙니다.",
|
||||
desktopManaged:
|
||||
"이 데몬은 Paseo Desktop에서 관리됩니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
|
||||
selfUpdateUnsupported:
|
||||
"이 데몬은 스스로 업데이트할 수 없습니다. 해당 컴퓨터에서 Paseo를 업데이트하세요.",
|
||||
update: "업데이트",
|
||||
updateFailedTitle: "데몬 업데이트 실패",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "컨텍스트 데이터 없음",
|
||||
accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음",
|
||||
@@ -2755,14 +2769,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
|
||||
resumeOnStartup: {
|
||||
title: "재시작 후 에이전트 재개",
|
||||
info: "호스트가 중지될 때 작업 중이던 에이전트를 다시 열고 재개 메시지를 보냅니다. 정상 재시작과 비정상 종료 후 모두 적용됩니다.",
|
||||
hint: "중단된 작업을 자동으로 계속합니다.",
|
||||
prompt: "재개 메시지",
|
||||
edit: "편집",
|
||||
editAccessibilityLabel: "메시지 편집",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "유휴 에이전트 해제",
|
||||
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
|
||||
|
||||
@@ -2091,6 +2091,19 @@ export const ptBR: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
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: {
|
||||
noData: "Sem dados de contexto",
|
||||
accessibilityNoData: "Janela de contexto: sem dados de contexto",
|
||||
@@ -2782,14 +2795,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
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: {
|
||||
title: "Liberar agentes ociosos",
|
||||
hint: "Libera memória após uma hora de inatividade.",
|
||||
|
||||
@@ -2091,6 +2091,18 @@ export const ru: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "Закрыть",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "Демон устарел",
|
||||
versions: "На {{host}} работает демон {{daemonVersion}}. Версия приложения: {{appVersion}}.",
|
||||
terminalsStayOpen: "Обновление перезапустит демон. Терминалы останутся открытыми.",
|
||||
terminalsClose: "Обновление перезапустит демон и закроет открытые терминалы.",
|
||||
desktopUpdateTerminals:
|
||||
"Обновление Paseo Desktop оставляет терминалы открытыми в macOS и Linux и закрывает их в Windows.",
|
||||
desktopManaged: "Этим демоном управляет Paseo Desktop. Обновите Paseo на том компьютере.",
|
||||
selfUpdateUnsupported: "Этот демон не может обновиться сам. Обновите Paseo на том компьютере.",
|
||||
update: "Обновить",
|
||||
updateFailedTitle: "Не удалось обновить демон",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "Нет данных о контексте",
|
||||
accessibilityNoData: "Контекстное окно: нет данных о контексте",
|
||||
@@ -2792,14 +2804,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
|
||||
resumeOnStartup: {
|
||||
title: "Возобновлять агентов после перезапуска",
|
||||
info: "Открывает агентов, которые работали при остановке хоста, и отправляет сообщение для продолжения. Применяется после обычного перезапуска и сбоя.",
|
||||
hint: "Автоматически продолжать прерванную работу.",
|
||||
prompt: "Сообщение для продолжения",
|
||||
edit: "Редактировать",
|
||||
editAccessibilityLabel: "Изменить сообщение",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "Освобождать неактивных агентов",
|
||||
hint: "Освобождает память после часа бездействия.",
|
||||
|
||||
@@ -2036,6 +2036,18 @@ export const zhCN: TranslationResources = {
|
||||
sidebarCallout: {
|
||||
dismiss: "关闭",
|
||||
},
|
||||
daemonOutdated: {
|
||||
title: "守护进程版本过旧",
|
||||
versions: "{{host}} 运行的守护进程为 {{daemonVersion}},此应用为 {{appVersion}}。",
|
||||
terminalsStayOpen: "更新会重启守护进程,终端将保持打开。",
|
||||
terminalsClose: "更新会重启守护进程,并关闭已打开的终端。",
|
||||
desktopUpdateTerminals:
|
||||
"更新 Paseo Desktop 时,macOS 和 Linux 上的终端保持打开,Windows 上的终端会被关闭。",
|
||||
desktopManaged: "此守护进程由 Paseo Desktop 管理。请在那台电脑上更新 Paseo。",
|
||||
selfUpdateUnsupported: "此守护进程无法自行更新。请在那台电脑上更新 Paseo。",
|
||||
update: "更新",
|
||||
updateFailedTitle: "守护进程更新失败",
|
||||
},
|
||||
contextWindow: {
|
||||
noData: "暂无上下文数据",
|
||||
accessibilityNoData: "上下文窗口:暂无上下文数据",
|
||||
@@ -2713,14 +2725,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "连接到这个 Host 以管理 Agent",
|
||||
resumeOnStartup: {
|
||||
title: "重启后恢复智能体",
|
||||
info: "重新打开主机停止时正在工作的智能体,并发送恢复提示。适用于正常重启和崩溃后的恢复。",
|
||||
hint: "自动继续被中断的工作。",
|
||||
prompt: "恢复提示",
|
||||
edit: "编辑",
|
||||
editAccessibilityLabel: "编辑提示",
|
||||
},
|
||||
idleRuntimeCleanup: {
|
||||
title: "释放空闲 Agent",
|
||||
hint: "空闲一小时后释放内存。",
|
||||
|
||||
@@ -65,7 +65,6 @@ import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { BrowserToolsOptInCard } from "./browser-tools-card";
|
||||
import { RestartRecoveryCard } from "./restart-recovery-card";
|
||||
import { IdleRuntimeCleanupCard } from "./idle-runtime-cleanup-card";
|
||||
import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle";
|
||||
|
||||
@@ -293,7 +292,6 @@ export function HostAgentsPage({ serverId }: { serverId: string }) {
|
||||
<InjectPaseoToolsCard serverId={serverId} />
|
||||
<BrowserToolsOptInCard serverId={serverId} />
|
||||
<AppendSystemPromptCard serverId={serverId} />
|
||||
<RestartRecoveryCard serverId={serverId} />
|
||||
<IdleRuntimeCleanupCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
) : (
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
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",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/server": "0.14.0-beta.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/server": "0.14.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -47,8 +47,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/relay": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -77,8 +77,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -88,8 +88,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -237,9 +237,6 @@ export const MutableDaemonConfigSchema = z
|
||||
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported.
|
||||
// Persisted host-wide value, applied live by the daemon.
|
||||
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(""),
|
||||
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
||||
agentProfiles: z.array(AgentProfileSchema).optional(),
|
||||
@@ -265,8 +262,6 @@ export const MutableDaemonConfigPatchSchema = z
|
||||
autoArchiveAfterMerge: z.boolean().optional(),
|
||||
enableTerminalAgentHooks: z.boolean().optional(),
|
||||
idleRuntimeCleanup: z.boolean().optional(),
|
||||
resumeOnStartup: z.boolean().optional(),
|
||||
resumePrompt: z.string().optional(),
|
||||
appendSystemPrompt: z.string().optional(),
|
||||
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
||||
agentProfiles: z.array(AgentProfileSchema).optional(),
|
||||
@@ -3736,8 +3731,6 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
skillManagement: z.boolean().optional(),
|
||||
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
|
||||
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.
|
||||
"terminal-restore-modes": z.boolean().optional(),
|
||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/highlight": "0.14.0-beta.2",
|
||||
"@getpaseo/plugin": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/relay": "0.14.0-beta.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/highlight": "0.14.0-beta.1",
|
||||
"@getpaseo/plugin": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -135,7 +135,6 @@ export async function ensureAgentLoaded(
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
env: record.launchEnv,
|
||||
});
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
}
|
||||
|
||||
@@ -2929,92 +2929,6 @@ 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 () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -421,8 +421,6 @@ interface ManagedAgentBase {
|
||||
owner?: AgentOwner;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
config: AgentSessionConfig;
|
||||
/** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */
|
||||
launchEnv?: Record<string, string>;
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -1401,7 +1399,6 @@ export class AgentManager {
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
launchEnv: options.env ? { ...options.env } : undefined,
|
||||
historyPrimed: true,
|
||||
});
|
||||
if (!agent.internal) {
|
||||
@@ -1490,11 +1487,10 @@ export class AgentManager {
|
||||
: resumeOptions;
|
||||
const purpose = currentResumeOptions?.purpose ?? "interactive";
|
||||
|
||||
const launchEnv = record?.launchEnv;
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
mergedConfig,
|
||||
resolvedAgentId,
|
||||
{ purpose, env: launchEnv },
|
||||
{ purpose },
|
||||
);
|
||||
const client = this.requireClient(handle.provider);
|
||||
const available = await client.isAvailable();
|
||||
@@ -1509,7 +1505,7 @@ export class AgentManager {
|
||||
client,
|
||||
storedConfig.cwd,
|
||||
paseoToolPolicy,
|
||||
launchEnv,
|
||||
undefined,
|
||||
{
|
||||
reason: "resume",
|
||||
purpose,
|
||||
@@ -1528,7 +1524,6 @@ export class AgentManager {
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
...options,
|
||||
launchEnv,
|
||||
persistence: handle,
|
||||
restoring: true,
|
||||
});
|
||||
@@ -1666,7 +1661,6 @@ export class AgentManager {
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
refreshConfig,
|
||||
agentId,
|
||||
{ env: existing.launchEnv },
|
||||
);
|
||||
const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId);
|
||||
const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId);
|
||||
@@ -1675,7 +1669,7 @@ export class AgentManager {
|
||||
client,
|
||||
storedConfig.cwd,
|
||||
paseoToolPolicy,
|
||||
existing.launchEnv,
|
||||
undefined,
|
||||
{
|
||||
reason: "refresh",
|
||||
purpose: "interactive",
|
||||
@@ -1726,7 +1720,6 @@ export class AgentManager {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
launchEnv: existing.launchEnv,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
lastUserMessageAt: existing.lastUserMessageAt,
|
||||
@@ -3770,7 +3763,6 @@ export class AgentManager {
|
||||
publishWhenReady?: boolean;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
launchEnv?: Record<string, string>;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
let registered = false;
|
||||
@@ -3956,7 +3948,6 @@ export class AgentManager {
|
||||
persistence?: AgentPersistenceHandle;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
launchEnv?: Record<string, string>;
|
||||
}
|
||||
| undefined;
|
||||
}): ActiveManagedAgent {
|
||||
@@ -3967,7 +3958,6 @@ export class AgentManager {
|
||||
cwd: config.cwd,
|
||||
workspaceId: options?.workspaceId,
|
||||
owner: options?.owner,
|
||||
launchEnv: options?.launchEnv,
|
||||
session,
|
||||
capabilities: session.capabilities,
|
||||
config,
|
||||
|
||||
@@ -95,7 +95,6 @@ export function toStoredAgentRecord(
|
||||
lastStatus: agent.lifecycle,
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
|
||||
runtimeInfo,
|
||||
features: normalizeFeatures(agent.features),
|
||||
persistence,
|
||||
|
||||
@@ -54,10 +54,8 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
title: z.string().nullable().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
lastStatus: AgentStatusSchema.default("closed"),
|
||||
resumeOnStartup: z.boolean().optional(),
|
||||
lastModeId: z.string().nullable().optional(),
|
||||
config: SERIALIZABLE_CONFIG_SCHEMA,
|
||||
launchEnv: z.record(z.string(), z.string()).optional(),
|
||||
runtimeInfo: z
|
||||
.object({
|
||||
provider: z.string(),
|
||||
@@ -261,8 +259,6 @@ export class AgentStorage {
|
||||
if (existing && existing.archivedAt !== undefined) {
|
||||
record.archivedAt = existing.archivedAt;
|
||||
}
|
||||
record.resumeOnStartup =
|
||||
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { FinishObservation } from "../index.js";
|
||||
export function createFinishObservation(input: {
|
||||
agentManager: AgentManager;
|
||||
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;
|
||||
hosts(): ToolHosts;
|
||||
serverId: string;
|
||||
@@ -26,7 +24,6 @@ export function createFinishObservation(input: {
|
||||
hosts: input.hosts,
|
||||
local: {
|
||||
async wait(agentId, signal) {
|
||||
await input.waitForResume(agentId, signal);
|
||||
await input.messages().waitForDispatch(agentId, signal);
|
||||
signal.throwIfAborted();
|
||||
const live = agentManager.getAgent(agentId);
|
||||
@@ -55,7 +52,6 @@ export function createFinishObservation(input: {
|
||||
};
|
||||
},
|
||||
async observe(agentId, handler, signal, onError) {
|
||||
await input.waitForResume(agentId, signal);
|
||||
const stored = await agentStorage.get(agentId);
|
||||
let updates = Promise.resolve();
|
||||
const publish = (snapshot: AgentSnapshotPayload | null, event?: AgentStreamEvent) => {
|
||||
|
||||
@@ -881,7 +881,6 @@ async function createAgentMcpServer(
|
||||
logger: options.logger,
|
||||
...createFinishObservation({
|
||||
...options,
|
||||
waitForResume: async () => {},
|
||||
messages: () => messages,
|
||||
serverId: options.serverId ?? "self",
|
||||
hosts: () => {
|
||||
|
||||
@@ -887,68 +887,6 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("keeps an extraArgs settings file when Paseo sets Claude flag settings", async () => {
|
||||
const settingsDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-"));
|
||||
const settingsPath = path.join(settingsDir, "settings.json");
|
||||
await fs.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({ env: { ENABLE_TOOL_SEARCH: "false" }, permissions: { deny: ["Monitor"] } }),
|
||||
);
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-5-5",
|
||||
providerOptions: {
|
||||
extraArgs: { chrome: null, settings: settingsPath },
|
||||
},
|
||||
});
|
||||
|
||||
await session.startTurn("hello");
|
||||
|
||||
const options = queryFactory.mock.calls[0]?.[0].options;
|
||||
expect(options?.extraArgs).toEqual({ chrome: null });
|
||||
expect(options?.settings).toEqual({
|
||||
env: { ENABLE_TOOL_SEARCH: "false" },
|
||||
permissions: { deny: ["Monitor"] },
|
||||
fastMode: false,
|
||||
});
|
||||
await session.close();
|
||||
await fs.rm(settingsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("keeps inline extraArgs settings JSON for internal agents", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
internal: true,
|
||||
providerOptions: {
|
||||
extraArgs: { settings: '{"env":{"ENABLE_TOOL_SEARCH":"false"}}' },
|
||||
},
|
||||
});
|
||||
|
||||
await session.startTurn("hello");
|
||||
|
||||
const options = queryFactory.mock.calls[0]?.[0].options;
|
||||
expect(options?.extraArgs).toEqual({});
|
||||
expect(options?.settings).toEqual({
|
||||
env: { ENABLE_TOOL_SEARCH: "false" },
|
||||
disableAllHooks: true,
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("lists fast mode only for supported Opus models", async () => {
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
parseClaudeCodeVersion,
|
||||
resolveClaudeDisabledThinkingForModel,
|
||||
} from "./model-manifest.js";
|
||||
import { expandTilde } from "../../../../utils/path.js";
|
||||
import { parsePartialJsonObject } from "./partial-json.js";
|
||||
import { withClaudeReplayHistory, type ClaudeReplayHistory } from "./transcript-history.js";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
@@ -649,40 +648,6 @@ function readClaudeFastModeSetting(settings: ClaudeOptions["settings"]): boolean
|
||||
return typeof settings.fastMode === "boolean" ? settings.fastMode : null;
|
||||
}
|
||||
|
||||
type ClaudeSettingsObject = NonNullable<Exclude<ClaudeOptions["settings"], string>>;
|
||||
|
||||
// Mirrors `claude --settings`: the value is inline JSON or a path to a JSON file,
|
||||
// resolved against the session cwd like the Claude process would.
|
||||
async function readClaudeCliSettings(value: string, cwd: string): Promise<ClaudeSettingsObject> {
|
||||
const trimmed = value.trim();
|
||||
const isInlineJson = trimmed.startsWith("{");
|
||||
const source = isInlineJson ? "--settings JSON" : path.resolve(cwd, expandTilde(trimmed));
|
||||
let raw = trimmed;
|
||||
if (!isInlineJson) {
|
||||
try {
|
||||
raw = await fsPromises.readFile(source, "utf8");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read Claude settings file ${source}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Claude ${source} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`Claude ${source} must contain a JSON object`);
|
||||
}
|
||||
return parsed as ClaudeSettingsObject;
|
||||
}
|
||||
|
||||
function mergeClaudeSettings(
|
||||
settings: ClaudeOptions["settings"],
|
||||
updates: NonNullable<Exclude<ClaudeOptions["settings"], string>>,
|
||||
@@ -3645,7 +3610,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
|
||||
const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode });
|
||||
const sdkEnv = this.harnessEnvironment;
|
||||
assertClaudeModeCanRun(permissionMode, sdkEnv);
|
||||
|
||||
@@ -3730,10 +3695,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return base;
|
||||
}
|
||||
|
||||
private async buildSettingsOptions(
|
||||
private buildSettingsOptions(
|
||||
providerOptions: ClaudeProviderOptions,
|
||||
input: { ultracode: boolean },
|
||||
): Promise<Pick<ClaudeOptions, "settings" | "extraArgs"> | Record<string, never>> {
|
||||
): Pick<ClaudeOptions, "settings"> | Record<string, never> {
|
||||
const fastMode = this.resolveFastModeSetting();
|
||||
// Internal agents do daemon work such as naming a branch, so the user's and
|
||||
// project's hooks must not run for them.
|
||||
@@ -3741,21 +3706,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (fastMode === null && !input.ultracode && !disableAllHooks) {
|
||||
return {};
|
||||
}
|
||||
const updates = {
|
||||
...(fastMode === null ? {} : { fastMode }),
|
||||
...(input.ultracode ? { ultracode: true } : {}),
|
||||
...(disableAllHooks ? { disableAllHooks: true } : {}),
|
||||
};
|
||||
// The SDK replaces extraArgs.settings with Options.settings, so a user's
|
||||
// `--settings` would be dropped whenever Paseo sets flag settings. Fold it in.
|
||||
const { settings: extraArgsSettings, ...extraArgs } = providerOptions.extraArgs ?? {};
|
||||
if (typeof extraArgsSettings !== "string") {
|
||||
return { settings: mergeClaudeSettings(providerOptions.settings, updates) };
|
||||
}
|
||||
const cliSettings = await readClaudeCliSettings(extraArgsSettings, this.config.cwd);
|
||||
return {
|
||||
extraArgs,
|
||||
settings: { ...cliSettings, ...providerOptions.settings, ...updates },
|
||||
settings: mergeClaudeSettings(providerOptions.settings, {
|
||||
...(fastMode === null ? {} : { fastMode }),
|
||||
...(input.ultracode ? { ultracode: true } : {}),
|
||||
...(disableAllHooks ? { disableAllHooks: true } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
test("the real SDK passes the user's settings file contents alongside Paseo flags", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-argv-"));
|
||||
const capturePath = path.join(cwd, "argv.json");
|
||||
const binaryPath = path.join(cwd, "capture.mjs");
|
||||
const settingsPath = path.join(cwd, "settings.json");
|
||||
const userSettings = {
|
||||
env: { ENABLE_TOOL_SEARCH: "false" },
|
||||
permissions: { deny: ["Monitor"] },
|
||||
};
|
||||
try {
|
||||
// A CLI fixture records the real SDK's arguments without making a model request.
|
||||
await fs.writeFile(
|
||||
binaryPath,
|
||||
`import fs from "node:fs"; fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(process.argv.slice(2)));`,
|
||||
);
|
||||
await fs.writeFile(settingsPath, JSON.stringify(userSettings));
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
resolveBinary: async () => binaryPath,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
model: "claude-opus-5-5",
|
||||
providerOptions: { extraArgs: { settings: settingsPath } },
|
||||
});
|
||||
try {
|
||||
await session.startTurn("hello");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
const argv: string[] = JSON.parse(await fs.readFile(capturePath, "utf8"));
|
||||
const settingsIndex = argv.indexOf("--settings");
|
||||
expect(settingsIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(JSON.parse(argv[settingsIndex + 1]!)).toEqual({ ...userSettings, fastMode: false });
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -461,19 +461,9 @@ test.each([
|
||||
process.platform === "win32"
|
||||
? `${windowsPackage}-${process.arch}@${cliPackage.slice(cliPackage.lastIndexOf("@") + 1)}`
|
||||
: 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(
|
||||
"npm",
|
||||
[
|
||||
"install",
|
||||
"--prefix",
|
||||
root,
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--fetch-timeout=30000",
|
||||
installPackage,
|
||||
],
|
||||
["install", "--prefix", root, "--no-audit", "--no-fund", installPackage],
|
||||
{
|
||||
cwd: root,
|
||||
timeout: 180_000,
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1069,7 +1069,7 @@ function probeWebSocketConnection(
|
||||
});
|
||||
}
|
||||
|
||||
test("disabled recovery closes stale active records without resuming sessions", async () => {
|
||||
test("startup closes stale active records before serving status without resuming sessions", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-stale-status-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const storagePath = path.join(root, ".paseo", "agents");
|
||||
@@ -1121,7 +1121,6 @@ test("disabled recovery closes stale active records without resuming sessions",
|
||||
let sessionsCreated = 0;
|
||||
const handle = await createTestPaseoDaemon({
|
||||
paseoHomeRoot: root,
|
||||
resumeOnStartup: false,
|
||||
agentClients: createTestAgentClients({
|
||||
beforeCreateSession: async () => {
|
||||
sessionsCreated++;
|
||||
@@ -1132,7 +1131,6 @@ test("disabled recovery closes stale active records without resuming sessions",
|
||||
try {
|
||||
const expected = records.map((record) => ({
|
||||
...record,
|
||||
resumeOnStartup: false,
|
||||
lastStatus:
|
||||
record.lastStatus === "running" || record.lastStatus === "initializing"
|
||||
? "closed"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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 {
|
||||
createFinishSubscriptions,
|
||||
@@ -466,8 +464,6 @@ export interface PaseoDaemonConfig {
|
||||
downloadTokenTtlMs?: number;
|
||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||
idleRuntimeCleanup?: boolean;
|
||||
resumeOnStartup?: boolean;
|
||||
resumePrompt?: string;
|
||||
providerCatalogRefreshTimeoutMs?: number;
|
||||
metadataGeneration?: {
|
||||
providers?: Array<{
|
||||
@@ -598,8 +594,6 @@ function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): Mutab
|
||||
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
|
||||
enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false,
|
||||
idleRuntimeCleanup: config.idleRuntimeCleanup !== false,
|
||||
resumeOnStartup: config.resumeOnStartup !== false,
|
||||
resumePrompt: config.resumePrompt ?? "Resume working.",
|
||||
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
||||
pluginsEnabled: config.pluginsEnabled ?? false,
|
||||
plugins: config.plugins ?? {},
|
||||
@@ -1042,19 +1036,7 @@ export async function createPaseoDaemon(
|
||||
agentStorage,
|
||||
);
|
||||
await agentStorage.initialize();
|
||||
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();
|
||||
await closeStaleAgentRecords(agentStorage);
|
||||
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
|
||||
await bootstrapWorkspaceRegistries({
|
||||
serverId,
|
||||
@@ -1488,7 +1470,6 @@ export async function createPaseoDaemon(
|
||||
...createFinishObservation({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
waitForResume: restartRecovery.waitForResume,
|
||||
serverId,
|
||||
logger,
|
||||
messages: () => {
|
||||
@@ -1888,7 +1869,6 @@ export async function createPaseoDaemon(
|
||||
pluginRuntime.bindPaseoSessionHost(wsServer);
|
||||
await pluginRuntime.start();
|
||||
providerSnapshotManager.settlePluginProviders();
|
||||
restartRecovery.resumePending();
|
||||
await finishSubscriptions.start();
|
||||
wsServer.beginAcceptingConnections();
|
||||
const relaySecurityLogger = logger.child({ module: "relay-security" });
|
||||
@@ -1983,7 +1963,6 @@ export async function createPaseoDaemon(
|
||||
await finishSubscriptions.close();
|
||||
await wsServer?.agentMessages.close();
|
||||
agentManager.prepareForShutdown();
|
||||
await restartRecovery.captureRunningAgents();
|
||||
await closeAllAgents(logger, agentManager);
|
||||
await withTimeout({
|
||||
promise: pluginRuntime.drainEvents(),
|
||||
@@ -2050,6 +2029,16 @@ 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
|
||||
* answer. A provider that never answers must not hold the daemon open, so a
|
||||
|
||||
@@ -590,8 +590,6 @@ export type ResolvedDaemonSettings = Pick<
|
||||
| "relayRequireProtectedSecurity"
|
||||
| "appBaseUrl"
|
||||
| "idleRuntimeCleanup"
|
||||
| "resumeOnStartup"
|
||||
| "resumePrompt"
|
||||
| "providerCatalogRefreshTimeoutMs"
|
||||
| "metadataGeneration"
|
||||
| "providerOverrides"
|
||||
@@ -679,7 +677,6 @@ export function resolveReloadableConfig(
|
||||
relayEnabled: relay.enabled,
|
||||
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
|
||||
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
|
||||
...resolveRestartRecoveryConfig(persisted.agents),
|
||||
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides: extractProviderOverrides(
|
||||
@@ -830,10 +827,3 @@ function resolveSpeechOverrideControlledPaths(
|
||||
if (env.PASEO_LOCAL_MODELS_DIR !== undefined) paths.push("providers.local.modelsDir");
|
||||
return paths;
|
||||
}
|
||||
|
||||
function resolveRestartRecoveryConfig(agents: PersistedConfig["agents"]) {
|
||||
return {
|
||||
resumeOnStartup: agents?.resumeOnStartup ?? true,
|
||||
resumePrompt: agents?.resumePrompt ?? "Resume working.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -940,15 +940,6 @@ describe("DaemonConfigStore", () => {
|
||||
|
||||
await store.patch({ idleRuntimeCleanup: 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 () => {
|
||||
|
||||
@@ -29,8 +29,6 @@ interface SupportedMutableConfigPatch {
|
||||
autoArchiveAfterMerge?: boolean;
|
||||
enableTerminalAgentHooks?: boolean;
|
||||
idleRuntimeCleanup?: boolean;
|
||||
resumeOnStartup?: boolean;
|
||||
resumePrompt?: string;
|
||||
appendSystemPrompt?: string;
|
||||
terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
|
||||
agentProfiles?: MutableDaemonConfig["agentProfiles"];
|
||||
@@ -195,8 +193,6 @@ const RELOADABLE_PATHS = [
|
||||
"daemon.agentProfiles",
|
||||
"app.baseUrl",
|
||||
"agents.idleRuntimeCleanup",
|
||||
"agents.resumeOnStartup",
|
||||
"agents.resumePrompt",
|
||||
"agents.providers",
|
||||
"agents.catalogRefreshTimeoutMs",
|
||||
"agents.metadataGeneration",
|
||||
@@ -222,8 +218,6 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
|
||||
["daemon.agentProfiles", "agentProfiles"],
|
||||
["app.baseUrl", "app.baseUrl"],
|
||||
["agents.idleRuntimeCleanup", "idleRuntimeCleanup"],
|
||||
["agents.resumeOnStartup", "resumeOnStartup"],
|
||||
["agents.resumePrompt", "resumePrompt"],
|
||||
["agents.providers", "providers"],
|
||||
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
|
||||
["agents.metadataGeneration", "metadataGeneration"],
|
||||
@@ -298,8 +292,6 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut
|
||||
...(patch.enableTerminalAgentHooks !== undefined
|
||||
? { enableTerminalAgentHooks: patch.enableTerminalAgentHooks }
|
||||
: {}),
|
||||
...(patch.resumeOnStartup !== undefined ? { resumeOnStartup: patch.resumeOnStartup } : {}),
|
||||
...(patch.resumePrompt !== undefined ? { resumePrompt: patch.resumePrompt } : {}),
|
||||
...(patch.idleRuntimeCleanup !== undefined
|
||||
? { idleRuntimeCleanup: patch.idleRuntimeCleanup }
|
||||
: {}),
|
||||
@@ -710,7 +702,6 @@ function mergeMutableAgentPatch(
|
||||
patch.providers === undefined &&
|
||||
patch.metadataGeneration === undefined &&
|
||||
patch.skills === undefined &&
|
||||
!hasRestartRecoveryPatch(patch) &&
|
||||
patch.idleRuntimeCleanup === undefined &&
|
||||
removeProviders.length === 0
|
||||
) {
|
||||
@@ -744,7 +735,6 @@ function mergeMutableAgentPatch(
|
||||
next["skills"] = { selection: patch.skills.selection };
|
||||
}
|
||||
|
||||
Object.assign(next, restartRecoveryPatch(patch));
|
||||
if (patch.idleRuntimeCleanup !== undefined) {
|
||||
next["idleRuntimeCleanup"] = patch.idleRuntimeCleanup;
|
||||
}
|
||||
@@ -781,13 +771,3 @@ function mergeMutableDaemonPatch(
|
||||
if (patch.agentProfiles !== undefined) next.agentProfiles = patch.agentProfiles;
|
||||
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,14 +76,10 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
|
||||
async function restartFixture(
|
||||
root: string,
|
||||
settings: { resumeOnStartup?: boolean; resumePrompt?: string } = {},
|
||||
fixture: { holdResume?: boolean } = {},
|
||||
) {
|
||||
async function restartFixture(root: string) {
|
||||
const child = fork(
|
||||
fileURLToPath(new URL("../test-utils/finish-subscription-daemon.ts", import.meta.url)),
|
||||
[root, JSON.stringify(settings), JSON.stringify(fixture)],
|
||||
[root],
|
||||
{
|
||||
execArgv: ["--conditions=source", "--import", "tsx"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
@@ -187,25 +183,7 @@ test.each([false, true])(
|
||||
await expect.poll(() => readPrompts(root), { timeout: 15000 }).toHaveLength(1);
|
||||
const prompts = await readPrompts(root);
|
||||
expect(prompts[0]).toContain(`Agent ${childId}`);
|
||||
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,
|
||||
});
|
||||
|
||||
expect(prompts[0]).toContain("was closed");
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
@@ -218,7 +196,7 @@ test.each([false, true])(
|
||||
client = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws`, appVersion: "0.13.0" });
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
expect((await client.fetchAgent(childId))?.agent.status).toBe("idle");
|
||||
expect((await client.fetchAgent(childId))?.agent.status).toBe("closed");
|
||||
await client.close();
|
||||
client = undefined;
|
||||
await stopFixture(second.child, false);
|
||||
@@ -242,120 +220,3 @@ test.each([false, true])(
|
||||
},
|
||||
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);
|
||||
|
||||
@@ -348,8 +348,6 @@ export const PersistedConfigSchema = z
|
||||
.object({
|
||||
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
||||
idleRuntimeCleanup: z.boolean().optional(),
|
||||
resumeOnStartup: z.boolean().optional(),
|
||||
resumePrompt: z.string().optional(),
|
||||
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
|
||||
metadataGeneration: AgentMetadataGenerationSchema.optional(),
|
||||
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
|
||||
|
||||
@@ -730,7 +730,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
await service.stopAllPlugins();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("keeps the running commit when a Git update build command fails", async () => {
|
||||
|
||||
@@ -67,7 +67,6 @@ interface FakeAgentSessionOptions {
|
||||
|
||||
export interface TestAgentClientOptions {
|
||||
beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>;
|
||||
beforeResumeSession?: (handle: AgentPersistenceHandle) => Promise<void>;
|
||||
closeSession?: () => Promise<void>;
|
||||
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
|
||||
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
|
||||
@@ -1255,7 +1254,6 @@ class FakeAgentClient implements AgentClient {
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
await this.options.beforeResumeSession?.(handle);
|
||||
const cfg: AgentSessionConfig = {
|
||||
provider: this.provider,
|
||||
cwd: overrides?.cwd ?? process.cwd(),
|
||||
|
||||
@@ -9,24 +9,18 @@ const root = process.argv[2];
|
||||
if (!root || !process.send) throw new Error("Expected isolated test home and IPC parent");
|
||||
await mkdir(join(root, "static"), { recursive: true });
|
||||
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({
|
||||
paseoHomeRoot: root,
|
||||
...JSON.parse(process.argv[3] ?? "{}"),
|
||||
staticDir: join(root, "static"),
|
||||
logger: pino({ level: "warn" }, pino.destination(join(root, "fixture.log"))),
|
||||
cleanup: false,
|
||||
mcpEnabled: true,
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn: (prompt, config) => {
|
||||
void appendFile(join(root, "all-prompts.jsonl"), `${JSON.stringify({ prompt, config })}\n`);
|
||||
onStartTurn: (prompt) => {
|
||||
if (String(prompt).includes('kind="agent-notification"')) {
|
||||
void appendFile(join(root, "parent-prompts.jsonl"), `${JSON.stringify(prompt)}\n`);
|
||||
}
|
||||
},
|
||||
beforeResumeSession: () => resumeReleased.promise,
|
||||
beforeTurnComplete: (prompt) =>
|
||||
String(prompt).includes("HOLD_CHILD") ? held.promise : Promise.resolve(),
|
||||
}),
|
||||
@@ -34,7 +28,6 @@ const daemon = await createTestPaseoDaemon({
|
||||
process.send({ port: daemon.port, paseoHome: daemon.paseoHome, cwd: daemon.staticDir });
|
||||
process.on("message", (message) => {
|
||||
if (message === "finish") held.resolve();
|
||||
if (message === "release-resume") resumeReleased.resolve();
|
||||
if (message === "stop") {
|
||||
void daemon.close().then(
|
||||
() => process.exit(0),
|
||||
|
||||
@@ -52,8 +52,6 @@ interface TestPaseoDaemonOptions {
|
||||
trustedProxies?: PaseoDaemonConfig["trustedProxies"];
|
||||
agentProfiles?: AgentProfile[];
|
||||
autoArchiveAfterMerge?: boolean;
|
||||
resumeOnStartup?: boolean;
|
||||
resumePrompt?: string;
|
||||
pluginsEnabled?: PaseoDaemonConfig["pluginsEnabled"];
|
||||
builtinPlugins?: BuiltinPluginLoader;
|
||||
plugins?: PaseoDaemonConfig["plugins"];
|
||||
@@ -228,8 +226,6 @@ async function prepareTestDaemonConfig(
|
||||
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
||||
agentProfiles: options.agentProfiles,
|
||||
autoArchiveAfterMerge: options.autoArchiveAfterMerge,
|
||||
resumeOnStartup: options.resumeOnStartup,
|
||||
resumePrompt: options.resumePrompt,
|
||||
pluginsEnabled: options.pluginsEnabled,
|
||||
plugins: options.plugins,
|
||||
};
|
||||
|
||||
@@ -2013,7 +2013,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
skillManagement: true,
|
||||
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
|
||||
idleRuntimeCleanupSetting: true,
|
||||
resumeOnStartupSetting: true,
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": true,
|
||||
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
|
||||
|
||||
@@ -1171,14 +1171,10 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
expect(service.getMetrics().fetchInFlightCount).toBe(1);
|
||||
});
|
||||
|
||||
releaseFetch.resolve();
|
||||
// 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 flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -223,13 +223,7 @@ function runWorker(argv: string[]): void {
|
||||
...record,
|
||||
terminals: record.terminals.filter((entry) => sessionsById.has(entry.id)),
|
||||
};
|
||||
console.error("CI terminal record write attempted");
|
||||
try {
|
||||
writeTerminalWorkerRecord(args.paseoHome, record);
|
||||
} catch (error) {
|
||||
console.error("CI terminal record write failed:", (error as NodeJS.ErrnoException).code);
|
||||
throw error;
|
||||
}
|
||||
writeTerminalWorkerRecord(args.paseoHome, record);
|
||||
}
|
||||
|
||||
function removeOwnFiles(): void {
|
||||
|
||||
@@ -2,9 +2,7 @@ import { afterEach, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
@@ -45,8 +43,6 @@ import {
|
||||
listTerminalWorkerRecords,
|
||||
readTerminalWorkerRecord,
|
||||
terminalWorkerDirectory,
|
||||
terminalWorkerLogPath,
|
||||
terminalWorkerRecordPath,
|
||||
type TerminalWorkerRecord,
|
||||
} from "./terminal-worker-record.js";
|
||||
import { TERMINAL_WORKER_PROTOCOL_OVERRIDE_ENV } from "./terminal-worker-protocol.js";
|
||||
@@ -769,91 +765,52 @@ async function recordedTerminalPids(paseoHome: string): Promise<Map<string, numb
|
||||
return pids;
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
"never adopts a live worker's terminals after a kill cleanup it could not finish, even once the worker rewrites its record (held reader: %s)",
|
||||
async (holdReader) => {
|
||||
const paseoHome = createTemporaryHome();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-"));
|
||||
temporaryDirs.push(cwd);
|
||||
const first = await startRealWorkerManager({ paseoHome });
|
||||
manager = first;
|
||||
const exitRequestPath = join(cwd, "exit-requested");
|
||||
const exitObservedPath = join(cwd, "exit-observed");
|
||||
const ending = await first.createTerminal({
|
||||
workspaceId: "ws-unfinished-kill",
|
||||
cwd,
|
||||
...nodeTerminalCommand(`
|
||||
const { existsSync, writeFileSync } = require("node:fs");
|
||||
setInterval(() => {
|
||||
if (existsSync(${JSON.stringify(exitRequestPath)})) {
|
||||
writeFileSync(${JSON.stringify(exitObservedPath)}, "");
|
||||
process.exit(0);
|
||||
}
|
||||
}, 25);
|
||||
`),
|
||||
});
|
||||
const remaining = await first.createTerminal({
|
||||
workspaceId: "ws-unfinished-kill",
|
||||
cwd,
|
||||
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
|
||||
});
|
||||
const pids = await recordedTerminalPids(paseoHome);
|
||||
const endingPid = pids.get(ending.id)!;
|
||||
const remainingPid = pids.get(remaining.id)!;
|
||||
const [record] = await workerRecords(paseoHome);
|
||||
it("never adopts a live worker's terminals after a kill cleanup it could not finish, even once the worker rewrites its record", async () => {
|
||||
const paseoHome = createTemporaryHome();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-unfinished-kill-"));
|
||||
temporaryDirs.push(cwd);
|
||||
const first = await startRealWorkerManager({ paseoHome });
|
||||
manager = first;
|
||||
const ending = await first.createTerminal({
|
||||
workspaceId: "ws-unfinished-kill",
|
||||
cwd,
|
||||
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
|
||||
});
|
||||
const remaining = await first.createTerminal({
|
||||
workspaceId: "ws-unfinished-kill",
|
||||
cwd,
|
||||
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
|
||||
});
|
||||
const pids = await recordedTerminalPids(paseoHome);
|
||||
const endingPid = pids.get(ending.id)!;
|
||||
const remainingPid = pids.get(remaining.id)!;
|
||||
|
||||
// A kill cleanup whose process table read fails signals nothing and leaves the worker live.
|
||||
const inspector = createTerminalWorkerProcessInspector();
|
||||
await expect(
|
||||
reapTerminalWorkers({
|
||||
paseoHome,
|
||||
inspector: { ...inspector, listProcesses: () => Promise.reject(new Error("ps timed out")) },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError);
|
||||
await first.detach();
|
||||
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
|
||||
// A kill cleanup whose process table read fails signals nothing and leaves the worker live.
|
||||
const inspector = createTerminalWorkerProcessInspector();
|
||||
await expect(
|
||||
reapTerminalWorkers({
|
||||
paseoHome,
|
||||
inspector: { ...inspector, listProcesses: () => Promise.reject(new Error("ps timed out")) },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TerminalCleanupNotConfirmedError);
|
||||
await first.detach();
|
||||
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
|
||||
|
||||
// Diagnostic: keep the same read handle the poller uses open across the exit write.
|
||||
const recordFd = holdReader
|
||||
? openSync(terminalWorkerRecordPath(paseoHome, record!.pid), "r")
|
||||
: null;
|
||||
const attemptsBeforeExit =
|
||||
readFileSync(terminalWorkerLogPath(paseoHome), "utf8").match(
|
||||
/CI terminal record write attempted/g,
|
||||
)?.length ?? 0;
|
||||
try {
|
||||
writeFileSync(exitRequestPath, "");
|
||||
await waitForCondition(
|
||||
async () => !(await recordedTerminalPids(paseoHome)).has(ending.id),
|
||||
10000,
|
||||
);
|
||||
} catch (error) {
|
||||
const workerLog = readFileSync(terminalWorkerLogPath(paseoHome), "utf8");
|
||||
console.error("CI terminal exit diagnostics", {
|
||||
holdReader,
|
||||
attemptsAfterExit:
|
||||
(workerLog.match(/CI terminal record write attempted/g)?.length ?? 0) -
|
||||
attemptsBeforeExit,
|
||||
uncaughtExceptionLogged: workerLog.includes("Terminal worker uncaught exception"),
|
||||
exitObserved: existsSync(exitObservedPath),
|
||||
endingAlive: isProcessAlive(endingPid),
|
||||
remainingAlive: isProcessAlive(remainingPid),
|
||||
recordWriteErrors: workerLog.match(/CI terminal record write failed: \w+/g) ?? [],
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
if (recordFd !== null) closeSync(recordFd);
|
||||
}
|
||||
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
|
||||
// One terminal ends on its own: the live worker rewrites its record from memory.
|
||||
process.kill(endingPid, "SIGKILL");
|
||||
await waitForCondition(
|
||||
async () => !(await recordedTerminalPids(paseoHome)).has(ending.id),
|
||||
10000,
|
||||
);
|
||||
expect((await readUnconfirmedTerminalCleanup(paseoHome)).length).toBeGreaterThan(0);
|
||||
|
||||
manager = await startRealWorkerManager({ paseoHome });
|
||||
manager = await startRealWorkerManager({ paseoHome });
|
||||
|
||||
expect(manager.getTerminal(remaining.id)).toBeUndefined();
|
||||
expect(await manager.getTerminals(cwd)).toEqual([]);
|
||||
await waitForCondition(() => !isProcessAlive(remainingPid), 10000);
|
||||
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
|
||||
},
|
||||
);
|
||||
expect(manager.getTerminal(remaining.id)).toBeUndefined();
|
||||
expect(await manager.getTerminals(cwd)).toEqual([]);
|
||||
await waitForCondition(() => !isProcessAlive(remainingPid), 10000);
|
||||
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
|
||||
});
|
||||
|
||||
// The worker's own `ps`, delayed: a program that exits while the worker reads its birth time
|
||||
// lands its exit in the middle of creation. Only the worker launched while this PATH is set sees
|
||||
|
||||
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { writeFile, truncate, rm } from "node:fs/promises";
|
||||
import { measureMainThreadWork } from "../test-utils/main-thread-work.js";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
@@ -20,24 +21,6 @@ import {
|
||||
writePaseoConfigForEdit,
|
||||
} from "./paseo-config-file.js";
|
||||
|
||||
async function measureConfigRead(directory: string) {
|
||||
const fixture = fileURLToPath(
|
||||
new URL("./test-fixtures/config-responsiveness.ts", import.meta.url),
|
||||
);
|
||||
const { stdout, stderr } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
["--import", "tsx", fixture, directory],
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
process.stderr.write(stderr);
|
||||
return JSON.parse(stdout) as {
|
||||
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
||||
blockingMs: number;
|
||||
filesystemRequests: number;
|
||||
yields: number;
|
||||
};
|
||||
}
|
||||
|
||||
describe("paseo config file substrate", () => {
|
||||
let tempDir: string;
|
||||
|
||||
@@ -84,33 +67,6 @@ describe("paseo config file substrate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes unrelated test-runner CPU from the config read budget", async () => {
|
||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
||||
const unrelatedWork = new Promise<void>((resolve) => {
|
||||
setImmediate(() => {
|
||||
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
|
||||
process,
|
||||
"threadCpuUsage",
|
||||
);
|
||||
const start = threadCpuUsage();
|
||||
let elapsed: NodeJS.CpuUsage;
|
||||
do {
|
||||
elapsed = threadCpuUsage(start);
|
||||
} while (elapsed.user + elapsed.system < 60_000);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
|
||||
await unrelatedWork;
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
revision: await statPaseoConfigPath(tempDir),
|
||||
});
|
||||
expect(filesystemRequests).toBeGreaterThan(0);
|
||||
expect(blockingMs).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("keeps main-thread work below 20ms while admitting large configs", async () => {
|
||||
const config = {
|
||||
scripts: Object.fromEntries(
|
||||
@@ -119,7 +75,10 @@ describe("paseo config file substrate", () => {
|
||||
worktree: { setup: "npm ci" },
|
||||
};
|
||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
|
||||
const { result, blockingMs, filesystemRequests, yields } = await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests, yields } = await measureMainThreadWork(
|
||||
"project config 7000 scripts main-thread work",
|
||||
() => readPaseoConfigForEdit(tempDir),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
|
||||
expect(blockingMs).toBeLessThan(20);
|
||||
expect(filesystemRequests).toBeGreaterThan(0);
|
||||
@@ -131,7 +90,10 @@ describe("paseo config file substrate", () => {
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
|
||||
);
|
||||
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests } = await measureMainThreadWork(
|
||||
"project config 32MiB main-thread work",
|
||||
() => readPaseoConfigForEdit(tempDir),
|
||||
);
|
||||
expect(blockingMs).toBeLessThan(20);
|
||||
expect(filesystemRequests).toBeGreaterThan(0);
|
||||
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||
import { readPaseoConfigForEdit } from "../paseo-config-file.js";
|
||||
|
||||
// Keep Vitest transforms, reporting, and unrelated callbacks outside the measured
|
||||
// thread. Read the real file once, without warming the config snapshot or schema.
|
||||
const directory = process.argv[2];
|
||||
assert.ok(directory, "Config directory is required");
|
||||
await nextTurn();
|
||||
const measured = await measureMainThreadWork("project config main-thread work", () =>
|
||||
readPaseoConfigForEdit(directory),
|
||||
);
|
||||
process.stdout.write(JSON.stringify(measured));
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -513,12 +513,6 @@
|
||||
"idleRuntimeCleanup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resumeOnStartup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resumePrompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"catalogRefreshTimeoutMs": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.14.0-beta.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user