mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4dc36272c | ||
|
|
896f8efb64 | ||
|
|
cc8cd7e3cc | ||
|
|
8938fde3c7 | ||
|
|
fd1cf594b1 | ||
|
|
6c2a4e7ca6 | ||
|
|
c06ea2d2e1 | ||
|
|
a53ac6c802 | ||
|
|
852fc2cd07 | ||
|
|
8bd38fb236 | ||
|
|
7e5ad53491 | ||
|
|
a529578cd1 |
+11
-4
@@ -1,8 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.0-beta.1 - 2026-11-01
|
||||
## 0.15.1 - 2026-11-05
|
||||
|
||||
Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations.
|
||||
### Fixed
|
||||
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run `Get-CimInstance` ([#8810](https://github.com/getpaseo/paseo/pull/8810))
|
||||
|
||||
## 0.15.0 - 2026-11-03
|
||||
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
@@ -13,7 +19,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
|
||||
- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371))
|
||||
- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger))
|
||||
- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena))
|
||||
- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
|
||||
- Added an opt-in setting to resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
|
||||
- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348))
|
||||
- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
|
||||
- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso))
|
||||
@@ -64,6 +70,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
|
||||
- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285))
|
||||
- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
|
||||
- Fixed chat reconnecting and updating status being hidden behind the header on iOS and Android phones ([#8568](https://github.com/getpaseo/paseo/pull/8568))
|
||||
- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer))
|
||||
- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
|
||||
- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
@@ -74,7 +81,7 @@ Worktree setup can copy ignored files and use plugin-defined locations before ag
|
||||
### Notes
|
||||
|
||||
- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them
|
||||
- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents
|
||||
- Agent recovery is off by default; turn on **Resume agents after restart** in Host settings → Agents to send `Resume working.` or your own prompt
|
||||
- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
+11
-4
@@ -1,8 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.0-beta.1 - 2026-11-01
|
||||
## 0.15.1 - 2026-11-05
|
||||
|
||||
daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。
|
||||
### 修复
|
||||
|
||||
- 修复终端工作进程无法读取自身进程标识时 daemon 无法启动的问题;此前在 Windows PowerShell 5.1 无法运行 `Get-CimInstance` 的 Windows 机器上,Paseo 桌面版因此没有可用的 daemon ([#8810](https://github.com/getpaseo/paseo/pull/8810))
|
||||
|
||||
## 0.15.0 - 2026-11-03
|
||||
|
||||
daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。
|
||||
|
||||
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
|
||||
|
||||
@@ -13,7 +19,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
|
||||
- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371))
|
||||
- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger))
|
||||
- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena))
|
||||
- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
|
||||
- 新增可选设置,在主机重启后于原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127), [#8619](https://github.com/getpaseo/paseo/pull/8619),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
|
||||
- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348))
|
||||
- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
|
||||
- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso))
|
||||
@@ -64,6 +70,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
|
||||
- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285))
|
||||
- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
|
||||
- 修复了 iOS 和 Android 手机聊天中重新连接和更新状态被顶栏遮挡的问题 ([#8568](https://github.com/getpaseo/paseo/pull/8568))
|
||||
- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer))
|
||||
- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
|
||||
- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
@@ -74,7 +81,7 @@ worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用
|
||||
### 说明
|
||||
|
||||
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
|
||||
- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能
|
||||
- Agent 恢复默认关闭;在主机设置 → Agent 中开启 **重启后恢复智能体**,即可发送 `Resume working.` 或自定义提示词
|
||||
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
@@ -14,9 +14,10 @@ Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idl
|
||||
|
||||
## 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.
|
||||
Restart recovery is off by default. Enable `agents.resumeOnStartup` in host config or
|
||||
Host settings → Agents to resume interrupted agents after graceful shutdown or a crash. The daemon
|
||||
reopens the saved provider conversation and settings, then sends `Resume working.`. Customize
|
||||
the prompt with `agents.resumePrompt` or the same settings page.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
|
||||
Added
|
||||
- Insert known agent commands from replies into the Composer draft
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
|
||||
Added
|
||||
- Insert known agent commands from replies into the Composer draft
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
|
||||
Added
|
||||
- Insert known agent commands from replies into the Composer draft
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery.
|
||||
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
|
||||
Added
|
||||
- Insert known agent commands from replies into the Composer draft
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,4 @@
|
||||
Fixed
|
||||
- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run Get-CimInstance
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
||||
sha256-YLrbUckIiyV7esu2tmiKdLL0YHsvwqcA0Xe0gSeMCC0=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37632,7 +37632,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39800,12 +39800,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/server": "0.15.0-beta.1",
|
||||
"@getpaseo/client": "0.15.1",
|
||||
"@getpaseo/protocol": "0.15.1",
|
||||
"@getpaseo/server": "0.15.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -40052,10 +40052,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/relay": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.1",
|
||||
"@getpaseo/relay": "0.15.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -40067,7 +40067,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40311,7 +40311,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40357,7 +40357,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -40386,15 +40386,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/client": "0.15.1",
|
||||
"@getpaseo/protocol": "0.15.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40404,8 +40404,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/client": "0.15.1",
|
||||
"@getpaseo/protocol": "0.15.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40427,7 +40427,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40454,7 +40454,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -40675,16 +40675,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.15.0-beta.1",
|
||||
"@getpaseo/highlight": "0.15.0-beta.1",
|
||||
"@getpaseo/plugin": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/relay": "0.15.0-beta.1",
|
||||
"@getpaseo/client": "0.15.1",
|
||||
"@getpaseo/highlight": "0.15.1",
|
||||
"@getpaseo/plugin": "0.15.1",
|
||||
"@getpaseo/protocol": "0.15.1",
|
||||
"@getpaseo/relay": "0.15.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41631,7 +41631,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43241,7 +43241,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -257,12 +257,15 @@ test.describe("Settings host page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
|
||||
test("restart recovery defaults off and persists opt-in 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, false);
|
||||
await setRestartRecoveryEnabled(page, true);
|
||||
await page.reload();
|
||||
await expectRestartRecoveryEnabled(page, true);
|
||||
await expectRestartRecoveryPrompt(page, "Resume working.");
|
||||
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Open an existing compact chat, with the keyboard closed, then interrupt its host connection.
|
||||
// Hold that interruption externally (for example, stop a disposable SSH TCP forward).
|
||||
// Leave the daemon running. This test observes the public reconnecting state.
|
||||
// node placement.mjs <session> <platform> <coordinate-scale> <composer-label> <artifacts>
|
||||
// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1).
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv;
|
||||
assert(
|
||||
session && ["android", "ios"].includes(platform) && composerLabel && artifacts,
|
||||
"Pass all five arguments",
|
||||
);
|
||||
const scale = Number(coordinateScale);
|
||||
assert(scale > 0, "Pass a positive coordinate scale");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
function capture(name) {
|
||||
const snapshot = ad("snapshot", "--raw", "--json");
|
||||
writeFileSync(path.join(artifacts, `${name}.json`), snapshot);
|
||||
ad("screenshot", path.join(artifacts, `${name}.png`));
|
||||
const nodes = JSON.parse(snapshot).data.nodes;
|
||||
const header = nodes.find((node) => node.identifier === "floating-header");
|
||||
const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast");
|
||||
assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present");
|
||||
assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds");
|
||||
const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale;
|
||||
assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`);
|
||||
return toast.rect.y / scale;
|
||||
}
|
||||
|
||||
ad("wait", 'id="agent-reconnecting-toast"', "20000");
|
||||
const closedTop = capture("keyboard-closed");
|
||||
ad("press", `label=${JSON.stringify(composerLabel)}`);
|
||||
const opened = ad("snapshot", "--raw", "--json");
|
||||
const nodes = JSON.parse(opened).data.nodes;
|
||||
assert(
|
||||
nodes.some(
|
||||
(node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""),
|
||||
),
|
||||
"Use a real visible software keyboard; open Android sessions with --no-test-ime",
|
||||
);
|
||||
const openTop = capture("keyboard-open");
|
||||
assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast");
|
||||
console.log(
|
||||
"PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open",
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal, PortalHost } from "@gorhom/portal";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { EdgeFade } from "@/components/ui/edge-fade";
|
||||
|
||||
const OverlayClearanceContext = createContext(0);
|
||||
const OverlayHostContext = createContext<string | null>(null);
|
||||
|
||||
/** Keep status overlays above the fade without lifting the scrolling content. */
|
||||
export function FloatingHeaderOverlay({ children }: { children: ReactNode }) {
|
||||
const hostName = useContext(OverlayHostContext);
|
||||
const active = useRetainedPanelActive();
|
||||
if (!hostName) return children;
|
||||
return active ? <Portal hostName={hostName}>{children}</Portal> : null;
|
||||
}
|
||||
|
||||
/** Clearance for stationary overlays inside this layout, measured to the header bottom. */
|
||||
export function useFloatingHeaderOverlayClearance(): number {
|
||||
return useContext(OverlayClearanceContext);
|
||||
}
|
||||
|
||||
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
|
||||
const FADE_OVERHANG = 48;
|
||||
/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */
|
||||
@@ -20,15 +38,24 @@ interface FloatingHeaderLayoutProps {
|
||||
* same tree position in both modes, so toggling `floating` never remounts them.
|
||||
*/
|
||||
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
|
||||
const overlayHostName = useId();
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
|
||||
}, []);
|
||||
|
||||
const overlayClearance = floating ? headerHeight : 0;
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{floating ? null : header}
|
||||
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
|
||||
<View style={styles.content}>
|
||||
<OverlayClearanceContext.Provider value={overlayClearance}>
|
||||
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
|
||||
{children(floating ? headerHeight : 0)}
|
||||
</OverlayHostContext.Provider>
|
||||
</OverlayClearanceContext.Provider>
|
||||
</View>
|
||||
{floating ? (
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<EdgeFade
|
||||
@@ -37,11 +64,19 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
|
||||
backdrop="surfaceWorkspace"
|
||||
style={styles.fade}
|
||||
/>
|
||||
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
|
||||
<View
|
||||
testID="floating-header"
|
||||
collapsable={false}
|
||||
pointerEvents="box-none"
|
||||
onLayout={handleHeaderLayout}
|
||||
>
|
||||
{header}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.statusOverlay} pointerEvents="box-none">
|
||||
<PortalHost name={overlayHostName} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +90,13 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
statusOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
FloatingHeaderOverlay,
|
||||
useFloatingHeaderOverlayClearance,
|
||||
} from "@/components/floating-header";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
@@ -190,9 +194,11 @@ export function ToastViewport({
|
||||
return clearTimer;
|
||||
}, [clearTimer, durationMs, scheduleDismiss, toastId]);
|
||||
|
||||
const panelClearance = useFloatingHeaderOverlayClearance();
|
||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
|
||||
const topOffset =
|
||||
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
|
||||
|
||||
const content = (
|
||||
<View
|
||||
@@ -220,7 +226,7 @@ export function ToastViewport({
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
return content;
|
||||
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content;
|
||||
}
|
||||
|
||||
function ToastCard({
|
||||
|
||||
@@ -179,8 +179,8 @@ export function ComposerDock({
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
|
||||
</ComposerViewportContent>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
{overlay}
|
||||
</ComposerViewport>
|
||||
);
|
||||
}
|
||||
@@ -214,8 +214,8 @@ export function ComposerDock({
|
||||
{composer}
|
||||
</View>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
{overlay}
|
||||
</ComposerViewport>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import ts from "typescript";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("keeps dock overlays outside keyboard translation in every native layout", () => {
|
||||
const source = ts.createSourceFile(
|
||||
"dock.tsx",
|
||||
readFileSync(new URL("./index.native.tsx", import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let surfaces = 0;
|
||||
function visit(node: ts.Node) {
|
||||
if (
|
||||
ts.isJsxElement(node) &&
|
||||
node.openingElement.tagName.getText(source) === "KeyboardTranslateView"
|
||||
) {
|
||||
surfaces++;
|
||||
function assertStationary(child: ts.Node) {
|
||||
if (ts.isJsxExpression(child))
|
||||
expect(child.expression?.getText(source)).not.toBe("overlay");
|
||||
ts.forEachChild(child, assertStationary);
|
||||
}
|
||||
ts.forEachChild(node, assertStationary);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(source);
|
||||
expect(surfaces).toBe(2);
|
||||
});
|
||||
@@ -59,7 +59,7 @@ export function RestartRecoveryCard({ serverId }: { serverId: string }) {
|
||||
|
||||
if (!isConnected || !isSupported || !config) return null;
|
||||
|
||||
const isEnabled = config.resumeOnStartup !== false;
|
||||
const isEnabled = config.resumeOnStartup === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.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.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/server": "0.15.0-beta.1",
|
||||
"@getpaseo/client": "0.15.1",
|
||||
"@getpaseo/protocol": "0.15.1",
|
||||
"@getpaseo/server": "0.15.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -47,8 +47,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/relay": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.1",
|
||||
"@getpaseo/relay": "0.15.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.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.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -77,8 +77,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/client": "0.15.1",
|
||||
"@getpaseo/protocol": "0.15.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.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/client": "0.15.1",
|
||||
"@getpaseo/protocol": "0.15.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -56,6 +56,9 @@ function spawnRelayDevServer(port: number): ChildProcess {
|
||||
wranglerCliPath,
|
||||
"dev",
|
||||
"--local",
|
||||
// Local execution still inherits production vars; exercise the local Durable Object.
|
||||
"--var",
|
||||
"PASEO_RELAY_UPSTREAM:",
|
||||
"--ip",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.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.15.0-beta.1",
|
||||
"@getpaseo/highlight": "0.15.0-beta.1",
|
||||
"@getpaseo/plugin": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/relay": "0.15.0-beta.1",
|
||||
"@getpaseo/client": "0.15.1",
|
||||
"@getpaseo/highlight": "0.15.1",
|
||||
"@getpaseo/plugin": "0.15.1",
|
||||
"@getpaseo/protocol": "0.15.1",
|
||||
"@getpaseo/relay": "0.15.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -120,7 +120,6 @@ import {
|
||||
} from "../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { resolveResumeProcessCwd } from "./resume-process-cwd.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnvOverlay,
|
||||
@@ -166,6 +165,18 @@ function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Pr
|
||||
});
|
||||
}
|
||||
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(target)).isDirectory();
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -2872,8 +2883,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
// Loading an archived agent's history runs nothing in its working directory, which
|
||||
// may have been removed with its worktree. session/load still names the original
|
||||
// directory; only the process starts from the home directory.
|
||||
private resolveProcessCwd(): Promise<string> {
|
||||
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
|
||||
private async resolveProcessCwd(): Promise<string> {
|
||||
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
|
||||
return this.config.cwd;
|
||||
}
|
||||
return homedir();
|
||||
}
|
||||
|
||||
private async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
|
||||
@@ -5,13 +5,11 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||
@@ -25,7 +23,6 @@ import {
|
||||
PiRpcAgentSession,
|
||||
transformPiModels,
|
||||
} from "./agent.js";
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import { FakePi } from "./test-utils/fake-pi.js";
|
||||
import { createPiExtensionHost } from "./extensions/index.js";
|
||||
import { PiExtensionHost } from "./extensions/host.js";
|
||||
@@ -2722,7 +2719,6 @@ describe("PiRpcAgentClient", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-import-config-"));
|
||||
const cwd = path.join(root, "workspace");
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "20260103_session.jsonl");
|
||||
writeFileSync(
|
||||
@@ -3536,320 +3532,6 @@ describe("PiRpcAgentClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiRpcAgentClient history resume", () => {
|
||||
const BUILTIN_MCP_COMMAND = {
|
||||
name: "mcp",
|
||||
source: "extension",
|
||||
sourceInfo: { path: "builtin:mcp" },
|
||||
} as const;
|
||||
const PASEO_MCP_SERVERS = {
|
||||
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" },
|
||||
} as const;
|
||||
|
||||
interface ArchivedPiSession {
|
||||
handle: {
|
||||
provider: string;
|
||||
sessionId: string;
|
||||
nativeHandle: string;
|
||||
metadata: { cwd: string; model?: string };
|
||||
};
|
||||
sessionFile: string;
|
||||
header: Record<string, unknown>;
|
||||
body: string;
|
||||
originalBytes: Buffer;
|
||||
}
|
||||
|
||||
function createDeletedFolder(): string {
|
||||
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-deleted-cwd-"));
|
||||
rmSync(folder, { recursive: true, force: true });
|
||||
return folder;
|
||||
}
|
||||
|
||||
function createFileWhereFolderWas(): string {
|
||||
const folder = createDeletedFolder();
|
||||
writeFileSync(folder, "not a directory");
|
||||
onTestFinished(() => rmSync(folder, { force: true }));
|
||||
return folder;
|
||||
}
|
||||
|
||||
function createExistingFolder(): string {
|
||||
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-existing-cwd-"));
|
||||
onTestFinished(() => rmSync(folder, { recursive: true, force: true }));
|
||||
return folder;
|
||||
}
|
||||
|
||||
function writeArchivedSession(cwd: string, metadata: { model?: string } = {}): ArchivedPiSession {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-archived-session-"));
|
||||
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const sessionFile = path.join(dir, "2026-01-01_pi-session-1.jsonl");
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd,
|
||||
};
|
||||
const body = [
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
message: { role: "user", content: "Où est le café ☕ ?" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-2",
|
||||
parentId: "entry-1",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Dans la cuisine." }] },
|
||||
}),
|
||||
]
|
||||
.map((line) => `${line}\n`)
|
||||
.join("");
|
||||
writeFileSync(sessionFile, `${JSON.stringify(header)}\n${body}`);
|
||||
return {
|
||||
handle: {
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { cwd, ...metadata },
|
||||
},
|
||||
sessionFile,
|
||||
header,
|
||||
body,
|
||||
originalBytes: readFileSync(sessionFile),
|
||||
};
|
||||
}
|
||||
|
||||
function readSessionCopy(sessionFile: string): { header: unknown; body: string } {
|
||||
const content = readUtf8File(sessionFile);
|
||||
const headerEnd = content.indexOf("\n");
|
||||
return {
|
||||
header: JSON.parse(content.slice(0, headerEnd)),
|
||||
body: content.slice(headerEnd + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function resumeArchived(
|
||||
client: PiRpcAgentClient,
|
||||
archived: ArchivedPiSession,
|
||||
purpose: "history" | "interactive",
|
||||
overrides: Partial<AgentSessionConfig> = {},
|
||||
): Promise<AgentSession> {
|
||||
return client.resumeSession(archived.handle, overrides, undefined, { purpose });
|
||||
}
|
||||
|
||||
function writeFakePiProcess(): {
|
||||
command: [string, ...string[]];
|
||||
readLaunches: () => Array<{ cwd: string; session: string | null }>;
|
||||
} {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-process-"));
|
||||
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const scriptPath = path.join(dir, "fake-pi.cjs");
|
||||
const launchesPath = path.join(dir, "launches.jsonl");
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
`
|
||||
const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
const [launchesPath, ...piArgs] = process.argv.slice(2);
|
||||
const sessionFlag = piArgs.indexOf("--session");
|
||||
const sessionFile = sessionFlag === -1 ? null : piArgs[sessionFlag + 1];
|
||||
fs.appendFileSync(launchesPath, JSON.stringify({ cwd: process.cwd(), session: sessionFile }) + "\\n");
|
||||
if (sessionFile) {
|
||||
const header = JSON.parse(fs.readFileSync(sessionFile, "utf8").split("\\n", 1)[0]);
|
||||
if (header.cwd && !fs.existsSync(header.cwd)) {
|
||||
fs.writeSync(
|
||||
2,
|
||||
"Stored session working directory does not exist: " + header.cwd + "\\nSession file: " + sessionFile + "\\nCurrent working directory: " + process.cwd() + "\\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const models = [
|
||||
{ provider: "openrouter", id: "a" },
|
||||
{ provider: "openrouter", id: "b" },
|
||||
];
|
||||
const state = {
|
||||
sessionId: "pi-session-1",
|
||||
sessionFile,
|
||||
model: models[0],
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
};
|
||||
const commands = [${JSON.stringify(BUILTIN_MCP_COMMAND)}];
|
||||
function handle(request) {
|
||||
switch (request.type) {
|
||||
case "get_state":
|
||||
return state;
|
||||
case "get_commands":
|
||||
return { commands };
|
||||
case "get_available_models":
|
||||
return { models };
|
||||
case "set_model":
|
||||
fs.appendFileSync(
|
||||
sessionFile,
|
||||
JSON.stringify({ type: "model_change", id: "model-1", provider: request.provider, modelId: request.modelId }) + "\\n",
|
||||
);
|
||||
state.model = models.find((model) => model.id === request.modelId);
|
||||
return state.model;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
process.stdout.write(
|
||||
JSON.stringify({ id: request.id, type: "response", command: request.type, success: true, data: handle(request) }) + "\\n",
|
||||
);
|
||||
});
|
||||
`,
|
||||
);
|
||||
return {
|
||||
command: [process.execPath, scriptPath, launchesPath],
|
||||
readLaunches: () =>
|
||||
readUtf8File(launchesPath)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { cwd: string; session: string | null }),
|
||||
};
|
||||
}
|
||||
|
||||
test.each([
|
||||
["is gone", createDeletedFolder],
|
||||
["is now a file", createFileWhereFolderWas],
|
||||
])(
|
||||
"a history resume of an agent whose folder %s starts Pi from the home directory on a copy of its session",
|
||||
async (_folderState, createFolder) => {
|
||||
const archived = writeArchivedSession(createFolder());
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(1);
|
||||
const launch = pi.recordedLaunches[0]!;
|
||||
expect(launch.cwd).toBe(homedir());
|
||||
expect(launch.session).not.toBe(archived.sessionFile);
|
||||
expect(readSessionCopy(launch.session!)).toEqual({
|
||||
header: { ...archived.header, cwd: homedir() },
|
||||
body: archived.body,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
const sessionCopy = pi.recordedLaunches[0]!.session!;
|
||||
expect(existsSync(sessionCopy)).toBe(true);
|
||||
await session.close();
|
||||
|
||||
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
|
||||
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone persists the original session file while Pi reports the copy", async () => {
|
||||
const storedCwd = createDeletedFolder();
|
||||
const archived = writeArchivedSession(storedCwd);
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
onTestFinished(() => session.close());
|
||||
await session.getRuntimeInfo();
|
||||
|
||||
expect(pi.latestSession().state.sessionFile).toBe(pi.recordedLaunches[0]!.session);
|
||||
expect(session.describePersistence()).toMatchObject({
|
||||
nativeHandle: archived.sessionFile,
|
||||
metadata: { cwd: storedCwd },
|
||||
});
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone removes its session copy when Pi fails to start", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.getStateError = new Error("Pi RPC process exited with code 1 and signal null");
|
||||
});
|
||||
|
||||
await expect(resumeArchived(createClient(pi), archived, "history")).rejects.toThrow(
|
||||
"Pi RPC process exited with code 1",
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(1);
|
||||
expect(pi.recordedLaunches[0]!.session).not.toBe(archived.sessionFile);
|
||||
expect(existsSync(path.dirname(pi.recordedLaunches[0]!.session!))).toBe(false);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone checks MCP support from the home directory", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
pi.queueCommands([BUILTIN_MCP_COMMAND]);
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history", {
|
||||
mcpServers: PASEO_MCP_SERVERS,
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches.map((launch) => launch.cwd)).toEqual([homedir(), homedir()]);
|
||||
expect(session.capabilities.supportsMcpServers).toBe(true);
|
||||
});
|
||||
|
||||
test("an interactive resume of an agent whose folder is gone still fails in its stored folder", async () => {
|
||||
const storedCwd = createDeletedFolder();
|
||||
const archived = writeArchivedSession(storedCwd);
|
||||
const pi = new FakePi();
|
||||
|
||||
await expect(resumeArchived(createClient(pi), archived, "interactive")).rejects.toThrow(
|
||||
`Stored session working directory does not exist: ${storedCwd}`,
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder exists keeps the stored folder and session file", async () => {
|
||||
const storedCwd = createExistingFolder();
|
||||
const archived = writeArchivedSession(storedCwd);
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
|
||||
});
|
||||
|
||||
test("a history resume of an agent whose folder is gone opens its session in a real Pi process started from the home directory", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder(), { model: "openrouter/b" });
|
||||
const fakePiProcess = writeFakePiProcess();
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = new PiRpcAgentClient({
|
||||
logger,
|
||||
runtime: new PiCliRuntime({ logger, command: fakePiProcess.command }),
|
||||
});
|
||||
|
||||
const session = await resumeArchived(client, archived, "history", {
|
||||
mcpServers: PASEO_MCP_SERVERS,
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
const launches = fakePiProcess.readLaunches();
|
||||
const home = realpathSync.native(homedir());
|
||||
expect(launches.map((launch) => realpathSync.native(launch.cwd))).toEqual([home, home]);
|
||||
expect(session.capabilities.supportsMcpServers).toBe(true);
|
||||
const sessionCopy = launches[1]!.session!;
|
||||
expect(readUtf8File(sessionCopy)).toContain('"type":"model_change"');
|
||||
expect(session.describePersistence()?.nativeHandle).toBe(archived.sessionFile);
|
||||
|
||||
await session.close();
|
||||
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
|
||||
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformPiModels", () => {
|
||||
test("normalizes labels that include the upstream provider prefix", () => {
|
||||
expect(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mapCustomMessageToToolCall } from "../custom-message.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { basename, join, resolve as resolvePath } from "node:path";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { z } from "zod";
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentProvider,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
@@ -76,13 +75,8 @@ import {
|
||||
piExtensionRuntimeBridge,
|
||||
type PiExtensionEventOutput,
|
||||
} from "./extensions/index.js";
|
||||
import { resolveResumeProcessCwd } from "../resume-process-cwd.js";
|
||||
import { revertPiConversation } from "./rewind.js";
|
||||
import {
|
||||
copyPiSessionFileWithCwd,
|
||||
listPiImportableSessions,
|
||||
readPiImportSessionConfig,
|
||||
} from "./session-descriptor.js";
|
||||
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
|
||||
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
|
||||
import type {
|
||||
PiAgentSessionEvent,
|
||||
@@ -251,7 +245,6 @@ interface PiRpcAgentSessionOptions {
|
||||
initialState: PiSessionState;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
currentModeId?: string | null;
|
||||
persistedSessionFile?: string;
|
||||
cleanup?: () => void;
|
||||
extensionTimeoutMs?: number;
|
||||
logger: Logger;
|
||||
@@ -499,14 +492,13 @@ function buildResumeConfig(
|
||||
|
||||
function buildResumeStartInput(input: {
|
||||
resumeConfig: PiResumeConfig;
|
||||
processCwd: string;
|
||||
sessionFile: string;
|
||||
launchContext: AgentLaunchContext | undefined;
|
||||
mcpConfigFile: PiTempFile | null;
|
||||
paseoExtension: PiTempFile | null;
|
||||
}): PiStartSessionInput {
|
||||
return {
|
||||
cwd: input.processCwd,
|
||||
cwd: input.resumeConfig.cwd,
|
||||
env: input.launchContext?.env,
|
||||
session: input.sessionFile,
|
||||
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
|
||||
@@ -626,19 +618,6 @@ function createPiMcpConfigFile(
|
||||
};
|
||||
}
|
||||
|
||||
async function createPiSessionCopy(sessionFile: string, cwd: string): Promise<PiTempFile> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-session-"));
|
||||
const cleanup = () => rmSync(dir, { recursive: true, force: true });
|
||||
const filePath = join(dir, basename(sessionFile));
|
||||
try {
|
||||
await copyPiSessionFileWithCwd({ sourceFile: sessionFile, targetFile: filePath, cwd });
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
return { path: filePath, cleanup };
|
||||
}
|
||||
|
||||
function createPiPaseoExtensionFile({
|
||||
systemPrompt,
|
||||
mcpServers = {},
|
||||
@@ -1253,7 +1232,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.capabilities = options.capabilities;
|
||||
this.provider = PI_PROVIDER;
|
||||
this.currentModeId = options.currentModeId ?? null;
|
||||
this.persistedSessionFile = options.persistedSessionFile;
|
||||
this.cleanup = options.cleanup;
|
||||
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
|
||||
this.logger = options.logger;
|
||||
@@ -1284,7 +1262,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
|
||||
private readonly runtimeSession: PiRuntimeSession;
|
||||
private readonly config: AgentSessionConfig;
|
||||
private readonly persistedSessionFile?: string;
|
||||
private readonly cleanup?: () => void;
|
||||
private readonly extensionTimeoutMs: number;
|
||||
|
||||
@@ -1519,7 +1496,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.state.sessionId,
|
||||
nativeHandle: this.persistedSessionFile ?? this.state.sessionFile,
|
||||
nativeHandle: this.state.sessionFile,
|
||||
metadata: {
|
||||
cwd: this.config.cwd,
|
||||
...(this.config.model ? { model: this.config.model } : {}),
|
||||
@@ -2668,7 +2645,6 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentResumeSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const sessionFile = handle.nativeHandle;
|
||||
if (!sessionFile) {
|
||||
@@ -2677,7 +2653,6 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
|
||||
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
|
||||
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider);
|
||||
const processCwd = await resolveResumeProcessCwd(resumeConfig.cwd, options?.purpose);
|
||||
|
||||
const providerOptions = PiProviderOptionsSchema.parse(
|
||||
resumeConfig.config.providerOptions ?? {},
|
||||
@@ -2688,7 +2663,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
...launchContext?.env,
|
||||
};
|
||||
const mcp = await this.prepareMcpInjection(
|
||||
processCwd,
|
||||
resumeConfig.cwd,
|
||||
resumeConfig.config.mcpServers,
|
||||
mcpEnv,
|
||||
runtime,
|
||||
@@ -2701,29 +2676,20 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
),
|
||||
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
|
||||
});
|
||||
const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension];
|
||||
const cleanupTempFiles = () => {
|
||||
for (const tempFile of tempFiles) tempFile.cleanup();
|
||||
};
|
||||
let sessionCopy: PiTempFile | null = null;
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
if (processCwd !== resumeConfig.cwd) {
|
||||
sessionCopy = await createPiSessionCopy(sessionFile, processCwd);
|
||||
tempFiles.push(sessionCopy);
|
||||
}
|
||||
runtimeSession = await runtime.startSession(
|
||||
buildResumeStartInput({
|
||||
resumeConfig,
|
||||
processCwd,
|
||||
sessionFile: sessionCopy?.path ?? sessionFile,
|
||||
sessionFile,
|
||||
launchContext,
|
||||
mcpConfigFile,
|
||||
paseoExtension,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
cleanupTempFiles();
|
||||
mcpConfigFile?.cleanup();
|
||||
paseoExtension?.cleanup();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -2736,15 +2702,15 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
},
|
||||
initialState,
|
||||
capabilities: capabilitiesForSession(mcp !== null),
|
||||
persistedSessionFile: sessionCopy ? sessionFile : undefined,
|
||||
cleanup: cleanupTempFiles,
|
||||
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
|
||||
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
|
||||
logger: this.logger,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
});
|
||||
} catch (error) {
|
||||
await runtimeSession.close().catch(() => undefined);
|
||||
cleanupTempFiles();
|
||||
mcpConfigFile?.cleanup();
|
||||
paseoExtension?.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
copyPiSessionFileWithCwd,
|
||||
listPiImportableSessions,
|
||||
readPiImportSessionConfig,
|
||||
} from "./session-descriptor.js";
|
||||
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
|
||||
|
||||
async function writeSession(root: string, lines: unknown[]): Promise<string> {
|
||||
const sessionsDir = path.join(root, "sessions", "project");
|
||||
@@ -220,97 +216,3 @@ test("Pi import config preserves thinking before a later model in large sessions
|
||||
thinkingOptionId: "low",
|
||||
});
|
||||
});
|
||||
|
||||
test("a Pi session copy names the new cwd in its header and keeps every later byte", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: path.join(root, "removed-worktree"),
|
||||
};
|
||||
const body = Buffer.from(
|
||||
Array.from(
|
||||
{ length: 2_000 },
|
||||
(_, index) =>
|
||||
`${JSON.stringify({ type: "message", id: `entry-${index}`, message: { role: "user", content: `café ☕ ${index}` } })}\n`,
|
||||
).join(""),
|
||||
);
|
||||
expect(body.length).toBeGreaterThan(64 * 1024);
|
||||
const sourceFile = path.join(root, "source.jsonl");
|
||||
const targetFile = path.join(root, "copy.jsonl");
|
||||
await writeFile(sourceFile, Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`), body]));
|
||||
|
||||
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
|
||||
|
||||
const copy = await readFile(targetFile);
|
||||
const headerEnd = copy.indexOf(0x0a);
|
||||
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
|
||||
...header,
|
||||
cwd: "/home/user",
|
||||
});
|
||||
expect(copy.subarray(headerEnd + 1).equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
test("a Pi session copy refuses a file without a session header", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const sourceFile = path.join(root, "source.jsonl");
|
||||
await writeFile(sourceFile, `${JSON.stringify({ type: "message", id: "entry-1" })}\n`);
|
||||
|
||||
await expect(
|
||||
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
|
||||
).rejects.toThrow(`Pi session file has no session header: ${sourceFile}`);
|
||||
});
|
||||
|
||||
const COPY_BODY = [
|
||||
{ type: "message", id: "entry-1", message: { role: "user", content: "café ☕" } },
|
||||
{ type: "message", id: "entry-2", message: { role: "assistant", content: "Dans la cuisine." } },
|
||||
]
|
||||
.map((entry) => `${JSON.stringify(entry)}\n`)
|
||||
.join("");
|
||||
|
||||
const COPY_HEADER = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: "/removed-worktree",
|
||||
};
|
||||
|
||||
async function copySessionContent(content: string): Promise<Buffer> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const sourceFile = path.join(root, "source.jsonl");
|
||||
const targetFile = path.join(root, "copy.jsonl");
|
||||
await writeFile(sourceFile, content);
|
||||
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
|
||||
return readFile(targetFile);
|
||||
}
|
||||
|
||||
test.each([
|
||||
["a header line longer than 64 KiB", "", { ...COPY_HEADER, padding: "x".repeat(70 * 1024) }],
|
||||
["a header with an empty cwd", "", { ...COPY_HEADER, cwd: "" }],
|
||||
["blank lines before the header", "\n\r\n", COPY_HEADER],
|
||||
])("a Pi session copy accepts %s", async (_headerShape, prefix, header) => {
|
||||
const copy = await copySessionContent(`${prefix}${JSON.stringify(header)}\n${COPY_BODY}`);
|
||||
|
||||
const headerEnd = copy.indexOf(0x0a);
|
||||
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
|
||||
...header,
|
||||
cwd: "/home/user",
|
||||
});
|
||||
expect(copy.subarray(headerEnd + 1).equals(Buffer.from(COPY_BODY))).toBe(true);
|
||||
});
|
||||
|
||||
test("a Pi session copy reports a missing session file", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||
const sourceFile = path.join(root, "missing.jsonl");
|
||||
|
||||
await expect(
|
||||
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
|
||||
).rejects.toThrow(`Pi session file is missing: ${sourceFile}`);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createReadStream, createWriteStream, type Dirent } from "node:fs";
|
||||
import { open, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { open, readdir, readFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import type {
|
||||
ImportableProviderSession,
|
||||
@@ -19,7 +18,6 @@ const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||
const HEAD_BYTES = 64 * 1024;
|
||||
const TAIL_BYTES = 256 * 1024;
|
||||
const FULL_SCAN_LINE_LIMIT = 2_000;
|
||||
const PI_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
|
||||
// Rank all discovered files cheaply, then parse only a bounded recent window.
|
||||
const IMPORT_CANDIDATE_OVERSCAN = 40;
|
||||
const IMPORT_CANDIDATE_MIN = 400;
|
||||
@@ -102,59 +100,6 @@ export async function listPiImportableSessions(
|
||||
);
|
||||
}
|
||||
|
||||
export async function copyPiSessionFileWithCwd(input: {
|
||||
sourceFile: string;
|
||||
targetFile: string;
|
||||
cwd: string;
|
||||
}): Promise<void> {
|
||||
const found = findSessionHeaderLine(
|
||||
await readSessionFileStart(input.sourceFile, PI_SESSION_HEADER_SCAN_BYTES),
|
||||
);
|
||||
if (!found) {
|
||||
throw new Error(`Pi session file has no session header: ${input.sourceFile}`);
|
||||
}
|
||||
await writeFile(input.targetFile, `${JSON.stringify({ ...found.header, cwd: input.cwd })}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await pipeline(
|
||||
createReadStream(input.sourceFile, { start: found.bodyStart }),
|
||||
createWriteStream(input.targetFile, { flags: "a" }),
|
||||
);
|
||||
}
|
||||
|
||||
function findSessionHeaderLine(
|
||||
scanned: Buffer,
|
||||
): { header: Record<string, unknown>; bodyStart: number } | null {
|
||||
let lineStart = 0;
|
||||
while (lineStart < scanned.length) {
|
||||
const newline = scanned.indexOf(0x0a, lineStart);
|
||||
const bodyStart = newline === -1 ? scanned.length : newline + 1;
|
||||
const line = scanned.subarray(lineStart, bodyStart).toString("utf8").trim();
|
||||
if (line) {
|
||||
const header = parseJsonRecord(line);
|
||||
return header?.type === "session" ? { header, bodyStart } : null;
|
||||
}
|
||||
lineStart = bodyStart;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readSessionFileStart(filePath: string, maxBytes: number): Promise<Buffer> {
|
||||
const handle = await open(filePath, "r").catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw new Error(`Pi session file is missing: ${filePath}`);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
const buffer = Buffer.alloc(maxBytes);
|
||||
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPiImportSessionConfig(filePath: string): Promise<PiImportSessionConfig> {
|
||||
const descriptor = await readPiSessionDescriptor(filePath);
|
||||
if (!descriptor) return {};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
import type {
|
||||
PiRuntime,
|
||||
PiRuntimeLaunch,
|
||||
@@ -77,12 +75,6 @@ export class FakePi implements PiRuntime {
|
||||
const session = new FakePiSession(launch, this.removedModels);
|
||||
session.commands = this.queuedCommands.shift() ?? [];
|
||||
this.queuedSessionSetups.shift()?.(session);
|
||||
const missingSessionCwd = launch.noSession ? null : readMissingSessionCwd(launch.session);
|
||||
if (missingSessionCwd !== null) {
|
||||
session.getStateError = new Error(
|
||||
`Pi RPC process exited with code 1 and signal null\nStored session working directory does not exist: ${missingSessionCwd}\nSession file: ${launch.session}\nCurrent working directory: ${launch.cwd}`,
|
||||
);
|
||||
}
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
@@ -109,27 +101,6 @@ export class FakePi implements PiRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
function readMissingSessionCwd(sessionFile: string | undefined): string | null {
|
||||
if (!sessionFile || !existsSync(sessionFile)) {
|
||||
return null;
|
||||
}
|
||||
const [headerLine = ""] = readFileSync(sessionFile, "utf8").split("\n", 1);
|
||||
let header: unknown;
|
||||
try {
|
||||
header = JSON.parse(headerLine);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof header !== "object" || header === null || !("cwd" in header)) {
|
||||
return null;
|
||||
}
|
||||
const { type, cwd } = header as { type?: unknown; cwd?: unknown };
|
||||
if (type !== "session" || typeof cwd !== "string" || !cwd || existsSync(cwd)) {
|
||||
return null;
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export class FakePiSession implements PiRuntimeSession {
|
||||
readonly environment: Record<string, string>;
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import type { AgentResumePurpose } from "../agent-sdk-types.js";
|
||||
|
||||
export async function resolveResumeProcessCwd(
|
||||
cwd: string,
|
||||
purpose: AgentResumePurpose | undefined,
|
||||
): Promise<string> {
|
||||
if (purpose !== "history" || (await isDirectory(cwd))) {
|
||||
return cwd;
|
||||
}
|
||||
return homedir();
|
||||
}
|
||||
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(target)).isDirectory();
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => {
|
||||
|
||||
test("rolls back standalone listener without starting plugins when main listen fails", async () => {
|
||||
const mainPort = await findFreePort();
|
||||
const standalonePort = await findFreePort();
|
||||
const occupiedMain = http.createServer((_req, res) => {
|
||||
res.end("occupied-main");
|
||||
});
|
||||
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
|
||||
const standalonePort = await findFreePort();
|
||||
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
|
||||
@@ -600,7 +600,7 @@ function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): Mutab
|
||||
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
|
||||
enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false,
|
||||
idleRuntimeCleanup: config.idleRuntimeCleanup !== false,
|
||||
resumeOnStartup: config.resumeOnStartup !== false,
|
||||
resumeOnStartup: config.resumeOnStartup === true,
|
||||
resumePrompt: config.resumePrompt ?? "Resume working.",
|
||||
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
||||
pluginsEnabled: config.pluginsEnabled ?? false,
|
||||
@@ -1047,7 +1047,7 @@ export async function createPaseoDaemon(
|
||||
const restartRecovery = createRestartRecovery({
|
||||
storage: agentStorage,
|
||||
settings: () => ({
|
||||
enabled: daemonConfigStore.get().resumeOnStartup !== false,
|
||||
enabled: daemonConfigStore.get().resumeOnStartup === true,
|
||||
prompt: daemonConfigStore.get().resumePrompt ?? "Resume working.",
|
||||
}),
|
||||
eligible: async (record) =>
|
||||
|
||||
@@ -40,6 +40,24 @@ describe("server config", () => {
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
});
|
||||
|
||||
test.each([undefined, false, true])(
|
||||
"restart recovery requires explicit opt-in (%s)",
|
||||
async (enabled) => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-recovery-"));
|
||||
roots.push(paseoHome);
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
agents: { resumeOnStartup: enabled, resumePrompt: "Continue exactly.\n" },
|
||||
}),
|
||||
);
|
||||
const config = loadConfig(paseoHome, { env: {} });
|
||||
expect(config.resumeOnStartup).toBe(enabled === true);
|
||||
expect(config.resumePrompt).toBe("Continue exactly.\n");
|
||||
},
|
||||
);
|
||||
|
||||
test("records when the daemon is managed by Paseo Desktop", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-"));
|
||||
roots.push(paseoHome);
|
||||
|
||||
@@ -839,7 +839,7 @@ function resolveSpeechOverrideControlledPaths(
|
||||
|
||||
function resolveRestartRecoveryConfig(agents: PersistedConfig["agents"]) {
|
||||
return {
|
||||
resumeOnStartup: agents?.resumeOnStartup ?? true,
|
||||
resumeOnStartup: agents?.resumeOnStartup ?? false,
|
||||
resumePrompt: agents?.resumePrompt ?? "Resume working.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ test.each([false, true])(
|
||||
let client: DaemonClient | undefined;
|
||||
let mcp: Client | undefined;
|
||||
try {
|
||||
const first = await restartFixture(root);
|
||||
const first = await restartFixture(root, { resumeOnStartup: true });
|
||||
processes.push(first.child);
|
||||
client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" });
|
||||
await client.connect();
|
||||
@@ -182,7 +182,7 @@ test.each([false, true])(
|
||||
await client.close();
|
||||
client = undefined;
|
||||
await stopFixture(first.child, abrupt);
|
||||
const second = await restartFixture(root);
|
||||
const second = await restartFixture(root, { resumeOnStartup: true });
|
||||
processes.push(second.child);
|
||||
await expect.poll(() => readPrompts(root), { timeout: 15000 }).toHaveLength(1);
|
||||
const prompts = await readPrompts(root);
|
||||
@@ -222,7 +222,7 @@ test.each([false, true])(
|
||||
await client.close();
|
||||
client = undefined;
|
||||
await stopFixture(second.child, false);
|
||||
const third = await restartFixture(root);
|
||||
const third = await restartFixture(root, { resumeOnStartup: true });
|
||||
processes.push(third.child);
|
||||
expect(await readPrompts(root)).toHaveLength(1);
|
||||
expect(
|
||||
@@ -243,7 +243,7 @@ test.each([false, true])(
|
||||
60000,
|
||||
);
|
||||
|
||||
test.each([false, true])(
|
||||
test.each([undefined, false, true])(
|
||||
"host restart recovery settings (enabled=%s)",
|
||||
async (enabled) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "resume-settings-process-"));
|
||||
@@ -314,7 +314,7 @@ test("a provider that is slow to reopen does not hold daemon startup", async ()
|
||||
const processes: ChildProcess[] = [];
|
||||
let client: DaemonClient | undefined;
|
||||
try {
|
||||
const first = await restartFixture(root);
|
||||
const first = await restartFixture(root, { resumeOnStartup: true });
|
||||
processes.push(first.child);
|
||||
client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.13.0" });
|
||||
await client.connect();
|
||||
@@ -338,7 +338,7 @@ test("a provider that is slow to reopen does not hold daemon startup", async ()
|
||||
client = undefined;
|
||||
await stopFixture(first.child, false);
|
||||
|
||||
const second = await restartFixture(root, {}, { holdResume: true });
|
||||
const second = await restartFixture(root, { resumeOnStartup: true }, { 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();
|
||||
|
||||
@@ -61,24 +61,10 @@ describe("managed Git plugin sources", () => {
|
||||
roots.push(home);
|
||||
const sources = new ManagedPluginSources(home);
|
||||
|
||||
// Own the failure response instead of relying on a closed port and OS connect timing.
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(500, { "Content-Type": "text/plain" });
|
||||
response.end("clone rejected for oauth2:super-secret\n");
|
||||
const failure = sources.prepareInstall({
|
||||
source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git",
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as { port: number };
|
||||
try {
|
||||
const failure = sources.prepareInstall({
|
||||
source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`,
|
||||
});
|
||||
await expect(failure).rejects.toThrow("500");
|
||||
await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]");
|
||||
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
|
||||
});
|
||||
|
||||
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type EventEmitter, once } from "node:events";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import express from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import pino from "pino";
|
||||
@@ -334,39 +332,48 @@ describe("service proxy subsystem shape", () => {
|
||||
});
|
||||
});
|
||||
|
||||
interface ProxiedServiceFixture {
|
||||
interface ForwardedFixture {
|
||||
upstreamPort: number;
|
||||
daemonPort: number;
|
||||
daemon: http.Server;
|
||||
hostname: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a real workspace service behind a real daemon listener that serves the
|
||||
* service proxy for both HTTP and upgrades.
|
||||
* Runs a real workspace service behind a real daemon listener. The upstream
|
||||
* echoes the headers it received so tests can assert what actually crossed the
|
||||
* proxy, not what a helper returned.
|
||||
*/
|
||||
async function startProxiedService(
|
||||
upstream: http.Server,
|
||||
options: { upstreamHost?: string; logger?: pino.Logger } = {},
|
||||
): Promise<ProxiedServiceFixture> {
|
||||
const { upstreamHost = "127.0.0.1", logger: proxyLogger = logger } = options;
|
||||
// Upgraded sockets keep the server alive; close() alone would hang.
|
||||
const sockets = new Set<net.Socket>();
|
||||
function trackConnections(server: http.Server) {
|
||||
server.on("connection", (socket: net.Socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
}
|
||||
trackConnections(upstream);
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
|
||||
});
|
||||
});
|
||||
const upgradeSockets: net.Socket[] = [];
|
||||
upstream.on("upgrade", (req, socket) => {
|
||||
upgradeSockets.push(socket);
|
||||
// The client resets this connection once it has the echo, so the reset
|
||||
// reaches us as an 'error'. A raw upgrade socket has no default handler —
|
||||
// without this the event goes unhandled and takes down the test process.
|
||||
socket.on("error", () => socket.destroy());
|
||||
const payload = JSON.stringify(req.headers);
|
||||
socket.write(
|
||||
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstream.once("error", reject);
|
||||
upstream.listen(0, upstreamHost, resolve);
|
||||
});
|
||||
const upstreamPort = (upstream.address() as net.AddressInfo).port;
|
||||
|
||||
const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger });
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
const route = serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
@@ -383,51 +390,24 @@ async function startProxiedService(
|
||||
res.status(404).send("404 Not Found");
|
||||
});
|
||||
const daemon = http.createServer(app);
|
||||
trackConnections(daemon);
|
||||
daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false }));
|
||||
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
|
||||
|
||||
return {
|
||||
upstreamPort,
|
||||
daemonPort,
|
||||
daemon,
|
||||
hostname: route.hostname,
|
||||
async close() {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
// Upgraded sockets keep the server alive; close() alone would hang.
|
||||
for (const socket of upgradeSockets) socket.destroy();
|
||||
daemon.closeAllConnections();
|
||||
await new Promise<void>((resolve) => daemon.close(() => resolve()));
|
||||
upstream.closeAllConnections();
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The upstream echoes the headers it received so tests can assert what
|
||||
* actually crossed the proxy, not what a helper returned.
|
||||
*/
|
||||
function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ProxiedServiceFixture> {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
|
||||
});
|
||||
});
|
||||
upstream.on("upgrade", (req, socket) => {
|
||||
// The client resets this connection once it has the echo, so the reset
|
||||
// reaches us as an 'error'. A raw upgrade socket has no default handler —
|
||||
// without this the event goes unhandled and takes down the test process.
|
||||
socket.on("error", () => socket.destroy());
|
||||
const payload = JSON.stringify(req.headers);
|
||||
socket.write(
|
||||
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
|
||||
);
|
||||
});
|
||||
return startProxiedService(upstream, { upstreamHost });
|
||||
}
|
||||
|
||||
function upgradeThroughProxy(
|
||||
port: number,
|
||||
host: string,
|
||||
@@ -671,198 +651,3 @@ describe("service proxy forwarded headers", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function closed(emitter: EventEmitter): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
emitter.once("close", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function within<T>(promise: Promise<T>, ms: number, failure: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${failure} after ${ms}ms`)), ms);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function requestThroughProxy(
|
||||
fixture: ProxiedServiceFixture,
|
||||
requestPath: string,
|
||||
): http.ClientRequest {
|
||||
return http.get({
|
||||
hostname: "127.0.0.1",
|
||||
port: fixture.daemonPort,
|
||||
path: requestPath,
|
||||
headers: { host: fixture.hostname },
|
||||
});
|
||||
}
|
||||
|
||||
const STRAY_WARNING_GRACE_MS = 100;
|
||||
|
||||
function createUpgradeUpstream(onUpgrade: (socket: Duplex) => void): http.Server {
|
||||
const upstream = http.createServer();
|
||||
upstream.on("upgrade", (_req, socket) => {
|
||||
socket.on("error", () => socket.destroy());
|
||||
socket.on("end", () => socket.end());
|
||||
socket.write(
|
||||
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n",
|
||||
);
|
||||
onUpgrade(socket);
|
||||
});
|
||||
return upstream;
|
||||
}
|
||||
|
||||
function openUpgradeThroughProxy(fixture: ProxiedServiceFixture): Promise<net.Socket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port: fixture.daemonPort }, () => {
|
||||
const lines = [
|
||||
"GET /ws HTTP/1.1",
|
||||
`Host: ${fixture.hostname}`,
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
socket.write(lines.join("\r\n"));
|
||||
});
|
||||
let raw = "";
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
raw += chunk.toString();
|
||||
if (raw.includes("\r\n\r\n")) resolve(socket);
|
||||
});
|
||||
socket.on("close", () => reject(new Error("upgrade closed before the 101 arrived")));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("service proxy connection teardown", () => {
|
||||
it("closes the upstream connection when a client leaves an event stream", async () => {
|
||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
res.write("data: open\n\n");
|
||||
upstreamResponse.resolve(res);
|
||||
});
|
||||
const fixture = await startProxiedService(upstream);
|
||||
try {
|
||||
const request = requestThroughProxy(fixture, "/events");
|
||||
const [response] = (await once(request, "response")) as [http.IncomingMessage];
|
||||
await once(response, "data");
|
||||
const upstreamClosed = closed(await upstreamResponse.promise);
|
||||
|
||||
request.destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the event stream");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("aborts the upstream request when a client leaves before the response starts", async () => {
|
||||
const warnings: unknown[] = [];
|
||||
const proxyLogger = pino(
|
||||
{ level: "warn" },
|
||||
{ write: (line: string) => warnings.push(JSON.parse(line)) },
|
||||
);
|
||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
upstreamResponse.resolve(res);
|
||||
});
|
||||
const fixture = await startProxiedService(upstream, { logger: proxyLogger });
|
||||
try {
|
||||
const request = requestThroughProxy(fixture, "/slow");
|
||||
request.on("error", () => undefined);
|
||||
const upstreamClosed = closed(await upstreamResponse.promise);
|
||||
|
||||
request.destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the abandoned request");
|
||||
await new Promise((resolve) => setTimeout(resolve, STRAY_WARNING_GRACE_MS));
|
||||
expect(warnings).toEqual([]);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("lets a complete response finish without resetting the service", async () => {
|
||||
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
|
||||
const upstream = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.write("first;");
|
||||
upstreamResponse.resolve(res);
|
||||
});
|
||||
const fixture = await startProxiedService(upstream);
|
||||
try {
|
||||
const response = httpGet(fixture.daemonPort, fixture.hostname, { path: "/" });
|
||||
const res = await upstreamResponse.promise;
|
||||
const upstreamClosed = closed(res);
|
||||
|
||||
res.end("second");
|
||||
|
||||
await expect(response).resolves.toEqual({ status: 200, body: "first;second" });
|
||||
await within(upstreamClosed, 1000, "the service response never closed");
|
||||
expect(res.writableFinished).toBe(true);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("closes the upstream WebSocket when the daemon drops the client socket", async () => {
|
||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
||||
const fixture = await startProxiedService(
|
||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
||||
);
|
||||
const daemonSide = Promise.withResolvers<Duplex>();
|
||||
fixture.daemon.on("upgrade", (_req, socket) => daemonSide.resolve(socket));
|
||||
try {
|
||||
const client = await openUpgradeThroughProxy(fixture);
|
||||
const upstreamClosed = closed(await upstreamSocket.promise);
|
||||
|
||||
(await daemonSide.promise).destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
|
||||
client.destroy();
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("closes the upstream WebSocket when the client disconnects", async () => {
|
||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
||||
const fixture = await startProxiedService(
|
||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
||||
);
|
||||
try {
|
||||
const client = await openUpgradeThroughProxy(fixture);
|
||||
const upstreamClosed = closed(await upstreamSocket.promise);
|
||||
|
||||
client.destroy();
|
||||
|
||||
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
it("closes the client WebSocket when the service closes it", async () => {
|
||||
const upstreamSocket = Promise.withResolvers<Duplex>();
|
||||
const fixture = await startProxiedService(
|
||||
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
|
||||
);
|
||||
try {
|
||||
const client = await openUpgradeThroughProxy(fixture);
|
||||
const clientClosed = closed(client);
|
||||
|
||||
(await upstreamSocket.promise).destroy();
|
||||
|
||||
await within(clientClosed, 1000, "the client WebSocket is still open");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
@@ -342,14 +342,7 @@ function proxyHttpRequest({
|
||||
proxyRes.pipe(res, { end: true });
|
||||
},
|
||||
);
|
||||
let clientClosed = false;
|
||||
res.on("close", () => {
|
||||
if (res.writableFinished) return;
|
||||
clientClosed = true;
|
||||
proxyReq.destroy();
|
||||
});
|
||||
proxyReq.on("error", (err) => {
|
||||
if (clientClosed) return;
|
||||
logger.warn(
|
||||
{ err, hostname: route.hostname, port: route.port },
|
||||
"Service proxy: upstream unreachable",
|
||||
@@ -406,9 +399,6 @@ function proxyUpgradeRequest({
|
||||
socket.on("error", () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
socket.on("close", () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean {
|
||||
|
||||
@@ -95,13 +95,7 @@ it("accepts terminalId and token reports through the route into the tracker", as
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`
|
||||
const fs = require("node:fs");
|
||||
const envPath = ${JSON.stringify(envPath)};
|
||||
fs.writeFileSync(envPath + ".tmp", JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL }));
|
||||
fs.renameSync(envPath + ".tmp", envPath);
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
`require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`,
|
||||
],
|
||||
});
|
||||
await waitForCondition(() => existsSync(envPath), 10000);
|
||||
|
||||
@@ -410,12 +410,7 @@ describe("createTerminal", () => {
|
||||
cwd: realpathSync(tmpdir()),
|
||||
cols: 500,
|
||||
command: process.execPath,
|
||||
// Keep the PTY alive until the screen assertion; exit disposes its parser.
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify(process.argv.slice(1))); setInterval(() => {}, 100000)",
|
||||
nastyPrompt,
|
||||
],
|
||||
args: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", nastyPrompt],
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
TerminalWorkerIncompatibleError,
|
||||
} from "./terminal-worker-pool.js";
|
||||
import { connectTerminalWorker } from "./terminal-worker-client.js";
|
||||
import { launchTerminalWorker } from "./terminal-worker-launch.js";
|
||||
import { launchTerminalWorker, TerminalWorkerLaunchError } from "./terminal-worker-launch.js";
|
||||
|
||||
type TerminalRow = TerminalState["grid"][number];
|
||||
|
||||
@@ -2658,3 +2658,34 @@ it.each([false, true])(
|
||||
},
|
||||
45000,
|
||||
);
|
||||
|
||||
// Both Windows reports in #8766: the worker's process identity query (PowerShell there, `ps` here)
|
||||
// fails, the worker exits before writing its record, and the daemon used to exit with it.
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"starts without a worker when the worker cannot read its own identity, and launches one once it can",
|
||||
async () => {
|
||||
const paseoHome = createTemporaryHome();
|
||||
const binDir = mkdtempSync(join(tmpdir(), "worker-terminal-manager-no-ps-"));
|
||||
temporaryDirs.push(binDir);
|
||||
writeFileSync(join(binDir, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
|
||||
try {
|
||||
manager = await startRealWorkerManager({ paseoHome });
|
||||
expect(await workerRecords(paseoHome)).toEqual([]);
|
||||
await expect(
|
||||
manager.createTerminal({ workspaceId: "ws-no-identity", cwd: paseoHome }),
|
||||
).rejects.toThrow(TerminalWorkerLaunchError);
|
||||
expect(await workerRecords(paseoHome)).toEqual([]);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
|
||||
const terminal = trackTerminal(
|
||||
await manager.createTerminal({ workspaceId: "ws-no-identity", cwd: paseoHome }),
|
||||
);
|
||||
expect(terminal.id).toBeTruthy();
|
||||
expect((await workerRecords(paseoHome)).map((record) => record.pid)).toHaveLength(1);
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
@@ -1269,8 +1269,17 @@ export async function startWorkerTerminalManager(
|
||||
try {
|
||||
current = await startCurrent();
|
||||
} catch (error) {
|
||||
if (!(error instanceof NoTerminalWorkerAvailableError)) throw error;
|
||||
logger.warn(error.message);
|
||||
if (error instanceof NoTerminalWorkerAvailableError) {
|
||||
logger.warn(error.message);
|
||||
} else {
|
||||
// The daemon starts without terminals rather than not at all. A worker that cannot start
|
||||
// here (its process identity query failed, say) is launched again by the first terminal
|
||||
// creation, which reports the same error if it still cannot.
|
||||
logger.error(
|
||||
{ err: error },
|
||||
"Terminal worker did not start; terminals are unavailable until one starts",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -186,13 +186,7 @@ function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = fal
|
||||
|
||||
// A section's top-level directory and its category name the same thing, so
|
||||
// only directories below it become collapsible groups.
|
||||
if (
|
||||
stripTopDirectory &&
|
||||
directories[0] &&
|
||||
doc.frontmatter.category?.toLowerCase().endsWith(directories[0].toLowerCase())
|
||||
) {
|
||||
directories.shift();
|
||||
}
|
||||
if (stripTopDirectory) directories.shift();
|
||||
|
||||
let current = nodes;
|
||||
let parent: Extract<DocsNavNode, { type: "group" }> | undefined;
|
||||
|
||||
@@ -2,25 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect, parseHeadings } from "./docs";
|
||||
|
||||
describe("plugin documentation", () => {
|
||||
it.each(["en", "zh-CN"] as const)(
|
||||
"nests local file copying under Git worktrees in %s",
|
||||
(locale) => {
|
||||
const workspaces = buildDocsNavTree(getDocs(locale)).find(
|
||||
(node) => node.type === "category" && node.label === "Workspaces",
|
||||
);
|
||||
expect(workspaces).toMatchObject({
|
||||
children: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "group",
|
||||
href: "/docs/worktrees",
|
||||
children: [expect.objectContaining({ href: "/docs/worktrees/worktreeinclude" })],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(getDoc("worktrees/worktreeinclude", locale)).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("groups current guides by task without version navigation", () => {
|
||||
const plugins = buildDocsNavTree(getDocs("en")).find(
|
||||
(node) => node.type === "category" && node.label === "Plugins",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect, it } from "vitest";
|
||||
|
||||
const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs");
|
||||
|
||||
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees/index.md"])(
|
||||
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])(
|
||||
"%s makes schedule run retention discoverable",
|
||||
async (file) => {
|
||||
const markdown = await readFile(path.join(docsRoot, file), "utf8");
|
||||
|
||||
@@ -5,22 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { discover, fetchUsage } from "./usage.js";
|
||||
import type { UsageReport } from "@getpaseo/plugin/server/usage";
|
||||
|
||||
// The slot the Kimi CLI writes for a login to the global (.ai) deployment.
|
||||
const GLOBAL_SLOT = "kimi-code-env-0e4f99c69cc27850";
|
||||
|
||||
function kimiCredentialPath(dir: string, slot = "kimi-code"): string {
|
||||
return join(dir, "credentials", `${slot}.json`);
|
||||
function kimiCredentialPath(dir: string): string {
|
||||
return join(dir, "credentials", "kimi-code.json");
|
||||
}
|
||||
|
||||
function writeKimiCredentials(
|
||||
dir: string,
|
||||
accessToken: string,
|
||||
overrides: object = {},
|
||||
slot = "kimi-code",
|
||||
): void {
|
||||
function writeKimiCredentials(dir: string, accessToken: string, overrides: object = {}): void {
|
||||
mkdirSync(join(dir, "credentials"), { recursive: true });
|
||||
writeFileSync(
|
||||
kimiCredentialPath(dir, slot),
|
||||
kimiCredentialPath(dir),
|
||||
JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: "rt_kimi",
|
||||
@@ -181,102 +173,6 @@ describe("kimi usage source", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches usage from the global deployment for a kimi.ai login", async () => {
|
||||
writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_global_token", {}, GLOBAL_SLOT);
|
||||
let requestedUrl: string | null = null;
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
requestedUrl = url.toString();
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({ usage: { limit: "100", remaining: "60" } });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const kimi = findProvider(await service().listUsage(), "kimi");
|
||||
|
||||
expect(requestedUrl).toBe("https://api.kimi.ai/coding/v1/usages");
|
||||
expect(authorization).toBe("Bearer kimi_global_token");
|
||||
expect(kimi.status).toBe("available");
|
||||
});
|
||||
|
||||
it("prefers the login the CLI still refreshes over a stale slot", async () => {
|
||||
const kimiHome = join(homeDir, ".kimi-code");
|
||||
writeKimiCredentials(kimiHome, "kimi_stale_token", { expires_at: 1_791_426_752 });
|
||||
writeKimiCredentials(kimiHome, "kimi_live_token", { expires_at: 1_793_292_337 }, GLOBAL_SLOT);
|
||||
|
||||
expect(await discover()).toEqual([
|
||||
{
|
||||
key: "default",
|
||||
input: {
|
||||
store: "file",
|
||||
locator: kimiCredentialPath(kimiHome, GLOBAL_SLOT),
|
||||
baseUrl: "https://api.kimi.ai/coding/v1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("fetches global usage when an expired default-deployment login remains", async () => {
|
||||
const kimiHome = join(homeDir, ".kimi-code");
|
||||
writeKimiCredentials(kimiHome, "expired_default", { expires_at: 1_700_000_000 });
|
||||
writeKimiCredentials(kimiHome, "live_global", { expires_at: 2_000_000_000 }, GLOBAL_SLOT);
|
||||
const [account] = await discover();
|
||||
if (!account) throw new Error("No configured Kimi account");
|
||||
const requests: string[] = [];
|
||||
|
||||
const report = await fetchUsage(
|
||||
account.input as Parameters<typeof fetchUsage>[0],
|
||||
async (url, init) => {
|
||||
requests.push(url.toString());
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer live_global");
|
||||
return jsonResponse({ usage: { limit: "100", remaining: "60" } });
|
||||
},
|
||||
);
|
||||
|
||||
expect(requests).toEqual(["https://api.kimi.ai/coding/v1/usages"]);
|
||||
expect(report).toMatchObject({ status: "available", windows: [{ usedPct: 40 }] });
|
||||
});
|
||||
|
||||
it("keeps KIMI_CODE_HOME ahead of a newer legacy-home login", async () => {
|
||||
const selectedHome = join(homeDir, "selected-kimi");
|
||||
process.env.KIMI_CODE_HOME = selectedHome;
|
||||
writeKimiCredentials(
|
||||
selectedHome,
|
||||
"selected_global_token",
|
||||
{ expires_at: 2_000_000_000 },
|
||||
GLOBAL_SLOT,
|
||||
);
|
||||
writeKimiCredentials(join(homeDir, ".kimi"), "legacy_token", { expires_at: 2_000_000_100 });
|
||||
|
||||
const accounts = await discover();
|
||||
|
||||
expect(accounts).toEqual([
|
||||
{
|
||||
key: "default",
|
||||
input: {
|
||||
store: "file",
|
||||
locator: kimiCredentialPath(selectedHome, GLOBAL_SLOT),
|
||||
baseUrl: "https://api.kimi.ai/coding/v1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks the user to run kimi when the CLI token has expired", async () => {
|
||||
writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token", {
|
||||
expires_at: 1_700_000_000,
|
||||
});
|
||||
|
||||
const kimi = findProvider(await service().listUsage(), "kimi");
|
||||
|
||||
expect(kimi).toEqual({
|
||||
providerId: "kimi",
|
||||
status: "unavailable",
|
||||
problem: { kind: "expired", expiresAt: "2023-11-14T22:13:20.000Z", refreshedBy: "kimi" },
|
||||
error: null,
|
||||
planLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads Kimi credentials whose optional fields are null", async () => {
|
||||
writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token", {
|
||||
expires_at: null,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -19,21 +18,7 @@ const ApiOptionalStringSchema = z.preprocess(
|
||||
z.coerce.string().optional(),
|
||||
);
|
||||
|
||||
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
||||
|
||||
// The Kimi CLI's official deployments. A login writes its token to a credential
|
||||
// slot derived from the deployment, so a global (.ai) login never touches
|
||||
// kimi-code.json. Mirrors resolveKimiCodeOAuthKey in the Kimi CLI.
|
||||
const KIMI_CODE_DEPLOYMENTS = [
|
||||
{ oauthHost: "https://auth.kimi.com", baseUrl: KIMI_CODE_BASE_URL },
|
||||
{ oauthHost: "https://auth.kimi.ai", baseUrl: "https://api.kimi.ai/coding/v1" },
|
||||
];
|
||||
|
||||
function credentialSlot(deployment: { oauthHost: string; baseUrl: string }): string {
|
||||
if (deployment.baseUrl === KIMI_CODE_BASE_URL) return "kimi-code";
|
||||
const hash = createHash("sha256").update(JSON.stringify(deployment)).digest("hex");
|
||||
return `kimi-code-env-${hash.slice(0, 16)}`;
|
||||
}
|
||||
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
||||
|
||||
const KimiUsageFieldsSchema = z.object({
|
||||
limit: ApiOptionalStringSchema,
|
||||
@@ -272,7 +257,7 @@ export async function fetchUsage(
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
async function callUsageApi(token: string): Promise<Response> {
|
||||
return fetchApi(`${input.baseUrl ?? KIMI_CODE_BASE_URL}/usages`, {
|
||||
return fetchApi(KIMI_USAGE_URL, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -287,7 +272,6 @@ export async function fetchUsage(
|
||||
return unavailable({
|
||||
kind: "expired",
|
||||
expiresAt: new Date(credentials.expires_at * 1000).toISOString(),
|
||||
refreshedBy: "kimi",
|
||||
});
|
||||
|
||||
const res = await callUsageApi(credentials.access_token);
|
||||
@@ -322,31 +306,21 @@ async function readCredentials(input: UsageInput): Promise<KimiCredentials | und
|
||||
}
|
||||
}
|
||||
export async function discover(): Promise<UsageAccount[]> {
|
||||
for (const locator of ["KIMI_TOKEN", "KIMI_API_KEY"]) {
|
||||
const input: UsageInput = { store: "env", locator };
|
||||
const candidates: UsageInput[] = ["KIMI_TOKEN", "KIMI_API_KEY"].map((locator) => ({
|
||||
store: "env",
|
||||
locator,
|
||||
}));
|
||||
candidates.push(
|
||||
...[
|
||||
join(
|
||||
process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"),
|
||||
"credentials",
|
||||
"kimi-code.json",
|
||||
),
|
||||
join(homedir(), ".kimi", "credentials", "kimi-code.json"),
|
||||
].map((locator) => ({ store: "file" as const, locator })),
|
||||
);
|
||||
for (const input of candidates)
|
||||
if (await readCredentials(input)) return [{ key: "default", input }];
|
||||
}
|
||||
const homes = [
|
||||
process.env.KIMI_CODE_HOME || join(homedir(), ".kimi-code"),
|
||||
join(homedir(), ".kimi"),
|
||||
];
|
||||
// A login to another deployment leaves the old slot behind with a dead token.
|
||||
// Within each home, prefer the latest expiry. Keep the configured home ahead
|
||||
// of the legacy fallback, even if that fallback holds a newer token.
|
||||
for (const home of homes) {
|
||||
let found: { input: UsageInput; expiresAt: number } | undefined;
|
||||
for (const deployment of KIMI_CODE_DEPLOYMENTS) {
|
||||
const input: UsageInput = {
|
||||
store: "file",
|
||||
locator: join(home, "credentials", `${credentialSlot(deployment)}.json`),
|
||||
baseUrl: deployment.baseUrl,
|
||||
};
|
||||
const credentials = await readCredentials(input);
|
||||
if (!credentials) continue;
|
||||
const expiresAt = credentials.expires_at ?? Number.POSITIVE_INFINITY;
|
||||
if (!found || expiresAt > found.expiresAt) found = { input, expiresAt };
|
||||
}
|
||||
if (found) return [{ key: "default", input: found.input }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ export const inputSchema = z
|
||||
.object({
|
||||
store: z.enum(["env", "file"]),
|
||||
locator: z.string().min(1),
|
||||
baseUrl: z.url().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type UsageInput = z.infer<typeof inputSchema>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.15.0-beta.1",
|
||||
"version": "0.15.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -84,7 +84,16 @@ Add `--forge <name>` when Paseo cannot infer the forge from the source checkout.
|
||||
|
||||
## Copy local files with .worktreeinclude
|
||||
|
||||
Use [`.worktreeinclude`](/docs/worktrees/worktreeinclude) to copy selected gitignored files, such as `.env`, from your source checkout before setup runs.
|
||||
Create `.worktreeinclude` at the root of your source checkout to copy selected gitignored files into new worktrees:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
Patterns use gitignore syntax. A file must match `.worktreeinclude` and be gitignored to be copied. Tracked files are never copied, and existing destination files are never overwritten. Without `.worktreeinclude`, no local files are copied. Symlinked directories and nested repositories inside an included path cause the copy to fail and abort worktree creation.
|
||||
|
||||
The built-in `worktree-include` plugin copies from the checkout you create the worktree from, including when that checkout is another worktree. Copying finishes before setup, terminals, and the first agent. A copy failure aborts creation and removes the new worktree. Copies use copy-on-write clones on APFS and btrfs when supported, with a regular copy elsewhere.
|
||||
|
||||
## paseo.json
|
||||
|
||||
@@ -82,7 +82,16 @@ paseo workspace create \
|
||||
|
||||
## 使用 .worktreeinclude 复制本地文件 {#copy-local-files-with-worktreeinclude}
|
||||
|
||||
使用 [`.worktreeinclude`](/docs/worktrees/worktreeinclude),在 setup 运行前将 `.env` 等选定的 Git 忽略文件从源检出复制到新 worktree。
|
||||
在源检出的根目录创建 `.worktreeinclude`,将选定的 Git 忽略文件复制到新的 worktree:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
模式使用 gitignore 语法。只有同时匹配 `.worktreeinclude` 且被 Git 忽略的文件才会复制。已跟踪的文件不会被复制,目标中已存在的文件不会被覆盖。没有 `.worktreeinclude` 时,不会复制任何本地文件。包含路径中的符号链接目录和嵌套仓库会导致复制失败,并中止 worktree 创建。
|
||||
|
||||
内置的 `worktree-include` 插件从你创建 worktree 时所选的检出复制文件,即使源检出本身也是 worktree。复制会在 setup、终端和第一个 Agent 运行之前完成。复制失败会中止创建并删除新的 worktree。支持时会在 APFS 和 btrfs 上使用写时复制克隆,否则执行普通复制。
|
||||
|
||||
## paseo.json {#paseojson}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
title: .worktreeinclude
|
||||
description: Copy gitignored local files into new Paseo worktrees.
|
||||
nav: .worktreeinclude
|
||||
order: 12
|
||||
category: Workspaces
|
||||
---
|
||||
|
||||
# .worktreeinclude
|
||||
|
||||
Add `.worktreeinclude` at the root of your source checkout to copy local configuration into new [Git worktrees](/docs/worktrees). Paseo copies matching gitignored files before setup, terminals, and the first Agent start.
|
||||
|
||||
## Copy your local configuration
|
||||
|
||||
For a project whose `.gitignore` includes `.env` and `config/*.local.json`, create this `.worktreeinclude`:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
When you create a worktree through Paseo, an existing `.env` and matching files such as `config/dev.local.json` are copied to the same relative paths in the new worktree. Their contents stay out of Git.
|
||||
|
||||
Commit `.worktreeinclude` to share the patterns with your team. Each teammate supplies their own ignored files in the checkout they create worktrees from.
|
||||
|
||||
## Pattern syntax
|
||||
|
||||
Use gitignore syntax, with one pattern per line. Comments start with `#`. Prefix a pattern with `!` to exclude a match:
|
||||
|
||||
```gitignore
|
||||
# Local environment and configuration
|
||||
.env
|
||||
config/*.local.json
|
||||
!config/production.local.json
|
||||
```
|
||||
|
||||
A pattern only selects files that Git already ignores. Adding a path here does not ignore it, so also configure `.gitignore` when needed. Tracked files and untracked files that Git does not ignore are excluded.
|
||||
|
||||
## When files are copied
|
||||
|
||||
The built-in `worktree-include` plugin reads the file from the checkout you create the worktree from. That source can itself be another worktree. Keep the plugin enabled for copying to run.
|
||||
|
||||
- Without `.worktreeinclude`, the plugin copies nothing.
|
||||
- Matching files must exist in the source checkout.
|
||||
- Existing destination files are skipped, preserving their contents.
|
||||
- Copies happen during creation, with no ongoing synchronization between checkouts.
|
||||
- A copy error aborts creation and removes the new worktree. Avoid including symlinked directories or nested repositories, which cannot be copied as files.
|
||||
|
||||
## Run commands during setup
|
||||
|
||||
Use `.worktreeinclude` for files that already exist locally. Use a [setup hook](/docs/worktrees#setup-and-teardown) to install dependencies, generate files, or fetch secrets:
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
"setup": "npm ci"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Put this configuration in `paseo.json` on the base branch used for the new worktree. Copied files are available when setup runs.
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
title: .worktreeinclude
|
||||
description: 将 Git 忽略的本地文件复制到新的 Paseo worktree。
|
||||
nav: .worktreeinclude
|
||||
order: 12
|
||||
category: Workspaces
|
||||
---
|
||||
|
||||
# .worktreeinclude
|
||||
|
||||
在源检出的根目录添加 `.worktreeinclude`,将本地配置复制到新的 [Git worktree](/docs/worktrees)。Paseo 会在 setup、终端和首个 Agent 启动之前复制匹配的 Git 忽略文件。
|
||||
|
||||
## 复制本地配置 {#copy-your-local-configuration}
|
||||
|
||||
如果项目的 `.gitignore` 包含 `.env` 和 `config/*.local.json`,创建以下 `.worktreeinclude`:
|
||||
|
||||
```gitignore
|
||||
.env
|
||||
config/*.local.json
|
||||
```
|
||||
|
||||
通过 Paseo 创建 worktree 时,已有的 `.env` 和 `config/dev.local.json` 等匹配文件会被复制到新 worktree 中的相同相对路径。文件内容仍不会纳入 Git。
|
||||
|
||||
提交 `.worktreeinclude`,与团队共享这些模式。每位团队成员在用于创建 worktree 的检出中提供自己的忽略文件。
|
||||
|
||||
## 模式语法 {#pattern-syntax}
|
||||
|
||||
使用 gitignore 语法,每行一个模式。注释以 `#` 开头。模式前加 `!` 可排除匹配项:
|
||||
|
||||
```gitignore
|
||||
# Local environment and configuration
|
||||
.env
|
||||
config/*.local.json
|
||||
!config/production.local.json
|
||||
```
|
||||
|
||||
模式只会选择 Git 已忽略的文件。在此添加路径不会使 Git 忽略该文件,需要时还应配置 `.gitignore`。已跟踪的文件和未被 Git 忽略的未跟踪文件都不会被复制。
|
||||
|
||||
## 文件复制时机 {#when-files-are-copied}
|
||||
|
||||
内置的 `worktree-include` 插件从用于创建 worktree 的检出读取该文件。源检出也可以是另一个 worktree。保持该插件启用才能执行复制。
|
||||
|
||||
- 没有 `.worktreeinclude` 时,插件不会复制任何文件。
|
||||
- 匹配的文件必须存在于源检出中。
|
||||
- 目标中已存在的文件会被跳过,保留原有内容。
|
||||
- 复制发生在创建期间,之后不会在检出之间持续同步。
|
||||
- 复制错误会中止创建并删除新 worktree。避免包含符号链接目录或嵌套仓库,因为它们无法作为文件复制。
|
||||
|
||||
## 在 setup 期间运行命令 {#run-commands-during-setup}
|
||||
|
||||
使用 `.worktreeinclude` 复制本地已有文件。使用 [setup 钩子](/docs/worktrees#setup-and-teardown) 安装依赖、生成文件或获取密钥:
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
"setup": "npm ci"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将此配置放入新 worktree 所用基准分支的 `paseo.json` 中。setup 运行时,复制的文件已可使用。
|
||||
Reference in New Issue
Block a user