mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 14:43:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d121ac93a2 | ||
|
|
16621b4845 | ||
|
|
8f9465730d | ||
|
|
f92cce9fd2 | ||
|
|
8029f8d5ea | ||
|
|
4992b8d3da | ||
|
|
efc64a5443 | ||
|
|
7efa14238d | ||
|
|
dbbcb103ba | ||
|
|
7f318bf7cf | ||
|
|
04fe4be40f | ||
|
|
2d75cc80b2 | ||
|
|
bfac132920 | ||
|
|
34f996b32a | ||
|
|
3e83f433da | ||
|
|
674530152e | ||
|
|
919e44b319 | ||
|
|
4fe44038e3 | ||
|
|
9b0542d127 | ||
|
|
8130147426 | ||
|
|
fa89738e3a | ||
|
|
14519bdb5b | ||
|
|
891e62309c | ||
|
|
6ae6308cfb | ||
|
|
ef77b823ae | ||
|
|
60f29a4350 | ||
|
|
8efb21f55c | ||
|
|
b2666a0aec | ||
|
|
82d5f6c580 | ||
|
|
da75aa7a4a | ||
|
|
86c60d367c | ||
|
|
5afa1a9f8e | ||
|
|
2d7adfc450 | ||
|
|
7d9741ef77 | ||
|
|
caf9300358 | ||
|
|
f0dfb9134c | ||
|
|
56c051503b | ||
|
|
85354fcb6a | ||
|
|
2dd931c8bd | ||
|
|
58eafd81e5 | ||
|
|
eab9ac3b03 |
+72
-6
@@ -1,15 +1,81 @@
|
||||
# Changelog
|
||||
|
||||
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
|
||||
## 0.15.0-beta.1 - 2026-11-01
|
||||
|
||||
Added:
|
||||
Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations.
|
||||
|
||||
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
||||
|
||||
Improved:
|
||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
||||
|
||||
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
### Added
|
||||
|
||||
- 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 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))
|
||||
- Added daemon-wide support for `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
|
||||
- Fetch usage only while displayed, with a host setting to disable usage checks entirely ([#8181](https://github.com/getpaseo/paseo/pull/8181))
|
||||
|
||||
### Improved
|
||||
|
||||
- Replaced mobile terminal text rendering to align CJK characters, emoji, styled text and the cursor with terminal columns ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
|
||||
- Restore the configured terminal scrollback on reconnect, up to 10,000 retained lines ([#8282](https://github.com/getpaseo/paseo/pull/8282) by [@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
|
||||
- Load paginated Codex histories when importing or reopening sessions ([#8171](https://github.com/getpaseo/paseo/pull/8171) by [@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
|
||||
- Stopped rereading and retaining an unused copy of Codex history when reopening a cached chat ([#8132](https://github.com/getpaseo/paseo/pull/8132))
|
||||
- Split large daemon responses across relay frames instead of disconnecting at the frame limit ([#8302](https://github.com/getpaseo/paseo/pull/8302))
|
||||
- Truncate oversized chat entries before sending them to the app, including large tool results and embedded data ([#8274](https://github.com/getpaseo/paseo/pull/8274))
|
||||
- Show subagent activity in its own panel instead of duplicating it inside the parent’s tool card ([#8276](https://github.com/getpaseo/paseo/pull/8276))
|
||||
- Show exact usage reset times alongside more precise countdowns ([#8162](https://github.com/getpaseo/paseo/pull/8162))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Fixed discarded messages returning after rewinding plugin conversations ([#8368](https://github.com/getpaseo/paseo/pull/8368) by [@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
|
||||
- Reduced plugin downloads with smaller client bundles and a cache reused across reconnects and app launches ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- Preserved plugin screens and local state during temporary host disconnections ([#8105](https://github.com/getpaseo/paseo/pull/8105) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- Let plugins choose worktree locations and creation options through `worktree.create.before` ([#8117](https://github.com/getpaseo/paseo/pull/8117) by [@mcowger](https://github.com/mcowger))
|
||||
- Added `server.addHook` with an awaited `worktree.create.after` hook before workspace setup and agents start ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
- Released redundant plugin streaming snapshots instead of retaining them for the session’s lifetime ([#7675](https://github.com/getpaseo/paseo/pull/7675) by [@suntianc](https://github.com/suntianc))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed rewinding Claude conversations to messages sent during an active turn ([#8372](https://github.com/getpaseo/paseo/pull/8372) by [@tieo](https://github.com/tieo))
|
||||
- Fixed duplicate rows and missing replies when resuming Claude subagents after reopening a session ([#8408](https://github.com/getpaseo/paseo/pull/8408) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- Fixed Codex agents created without a prompt failing after their runtime reopens ([#8393](https://github.com/getpaseo/paseo/pull/8393) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
- Fixed newly running agents appearing as finished immediately after creation ([#8396](https://github.com/getpaseo/paseo/pull/8396) by [@luoph](https://github.com/luoph))
|
||||
- Fixed ACP startup exits waiting for a timeout instead of reporting the process error ([#8384](https://github.com/getpaseo/paseo/pull/8384) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- Fixed skills missing from new ACP agents when commands arrive in multiple batches ([#8387](https://github.com/getpaseo/paseo/pull/8387) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- Fixed sidebar titles and statuses stopping updates after directory subscriptions are released ([#8403](https://github.com/getpaseo/paseo/pull/8403) by [@ragokan](https://github.com/ragokan))
|
||||
- Kept the open workspace visible in collapsed sidebar groups ([#8395](https://github.com/getpaseo/paseo/pull/8395) by [@0xroylee](https://github.com/0xroylee))
|
||||
- Fixed relative chat links opening missing files instead of files in nested repositories ([#8361](https://github.com/getpaseo/paseo/pull/8361) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Accepted host:port and pasted URLs in the Direct connection Host field ([#8223](https://github.com/getpaseo/paseo/pull/8223) by [@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||
- Fixed terminal Ctrl+V paste over plain HTTP on non-Mac keyboards ([#8230](https://github.com/getpaseo/paseo/pull/8230) by [@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||
- Stopped failed history loads from repeatedly reopening the provider, with Retry available in the chat ([#8278](https://github.com/getpaseo/paseo/pull/8278))
|
||||
- Fixed Claude settings files being discarded when Paseo adds its own launch settings ([#8148](https://github.com/getpaseo/paseo/pull/8148) by [@yayugu](https://github.com/yayugu))
|
||||
- Fixed steering messages replacing a turn that was still starting and losing its original prompt ([#6192](https://github.com/getpaseo/paseo/pull/6192))
|
||||
- Fixed Pi tool-result images missing during streaming and after reopening the chat ([#8280](https://github.com/getpaseo/paseo/pull/8280) by [@duyiliu](https://github.com/duyiliu))
|
||||
- Fixed completed `edxeth/pi-subagents` children remaining marked as working ([#8176](https://github.com/getpaseo/paseo/pull/8176) by [@Zeno2019](https://github.com/Zeno2019))
|
||||
- Fixed Claude background subagent status after reopening a conversation ([#6259](https://github.com/getpaseo/paseo/pull/6259))
|
||||
- Fixed reused plugin subagents showing their previous completed status during new work ([#8080](https://github.com/getpaseo/paseo/pull/8080) by [@bccy](https://github.com/bccy))
|
||||
- Fixed custom Codex providers with reserved names such as `ollama` ([#6954](https://github.com/getpaseo/paseo/pull/6954) by [@mr860819](https://github.com/mr860819))
|
||||
- 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 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))
|
||||
- Fixed split dividers drifting ahead of or behind the pointer while dragging ([#6011](https://github.com/getpaseo/paseo/pull/6011))
|
||||
- Fixed delayed native filesystem watcher startup leaving Git updates unobserved ([#8107](https://github.com/getpaseo/paseo/pull/8107))
|
||||
- Fixed bundled skill arguments being passed incorrectly to Codex and Pi ([#8252](https://github.com/getpaseo/paseo/pull/8252) by [@AspireOne](https://github.com/AspireOne))
|
||||
|
||||
### 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
|
||||
- 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
|
||||
|
||||
|
||||
+73
-7
@@ -1,15 +1,81 @@
|
||||
# 更新日志
|
||||
# Changelog
|
||||
|
||||
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
|
||||
## 0.15.0-beta.1 - 2026-11-01
|
||||
|
||||
新增:
|
||||
daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。
|
||||
|
||||
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
|
||||
|
||||
改进:
|
||||
worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。
|
||||
|
||||
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
### 新增
|
||||
|
||||
- 点击回复中的已知 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))
|
||||
- 在 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))
|
||||
- 让 daemon 的网络请求支持 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
|
||||
- 仅在显示用量时查询用量,并提供主机设置以完全关闭用量查询 ([#8181](https://github.com/getpaseo/paseo/pull/8181))
|
||||
|
||||
### 改进
|
||||
|
||||
- 更换移动端终端文本渲染方式,让中日韩字符、表情符号、带样式文本和光标对齐终端列 ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
|
||||
- 重新连接时恢复设置指定的终端回滚历史,最多保留 10,000 行 ([#8282](https://github.com/getpaseo/paseo/pull/8282),贡献者:[@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
|
||||
- 导入或重新打开 Codex 会话时加载分页历史 ([#8171](https://github.com/getpaseo/paseo/pull/8171),贡献者:[@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
|
||||
- 重新打开已有缓存的 Codex 聊天时,不再重复读取并保留未使用的历史副本 ([#8132](https://github.com/getpaseo/paseo/pull/8132))
|
||||
- 将 daemon 的大型响应拆分为多个 relay 帧,避免达到单帧限制时断开连接 ([#8302](https://github.com/getpaseo/paseo/pull/8302))
|
||||
- 向应用发送聊天条目前截断超大内容,包括大型工具结果和内嵌数据 ([#8274](https://github.com/getpaseo/paseo/pull/8274))
|
||||
- 在子 Agent 自己的面板中显示活动,不再在父 Agent 的工具卡片中重复显示 ([#8276](https://github.com/getpaseo/paseo/pull/8276))
|
||||
- 在更精确的倒计时旁显示用量限制的具体重置时间 ([#8162](https://github.com/getpaseo/paseo/pull/8162))
|
||||
|
||||
### 插件
|
||||
|
||||
- 修复了插件会话回退后已丢弃的消息重新出现的问题 ([#8368](https://github.com/getpaseo/paseo/pull/8368),贡献者:[@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
|
||||
- 通过缩小客户端代码包并在重连和应用启动时复用缓存,减少插件下载量 ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- 在主机暂时断开连接期间保留插件页面和本地状态 ([#8105](https://github.com/getpaseo/paseo/pull/8105),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- 让插件通过 `worktree.create.before` 指定 worktree 的位置和创建选项 ([#8117](https://github.com/getpaseo/paseo/pull/8117),贡献者:[@mcowger](https://github.com/mcowger))
|
||||
- 新增 `server.addHook`,并在工作区初始化和 Agent 启动前等待 `worktree.create.after` hook 完成 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
- 释放多余的插件流式快照,不再将其保留到会话结束 ([#7675](https://github.com/getpaseo/paseo/pull/7675),贡献者:[@suntianc](https://github.com/suntianc))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复了无法将 Claude 会话回退到运行过程中发送的消息的问题 ([#8372](https://github.com/getpaseo/paseo/pull/8372),贡献者:[@tieo](https://github.com/tieo))
|
||||
- 修复了重新打开会话后恢复 Claude 子 Agent 时出现重复行和回复缺失的问题 ([#8408](https://github.com/getpaseo/paseo/pull/8408),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
||||
- 修复了未发送提示词的 Codex Agent 在运行时重新打开后无法使用的问题 ([#8393](https://github.com/getpaseo/paseo/pull/8393),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
- 修复了刚开始运行的 Agent 在创建后立即显示为已完成的问题 ([#8396](https://github.com/getpaseo/paseo/pull/8396),贡献者:[@luoph](https://github.com/luoph))
|
||||
- 修复了 ACP 启动进程退出后仍等待超时而未及时报告错误的问题 ([#8384](https://github.com/getpaseo/paseo/pull/8384),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- 修复了命令分批到达时新 ACP Agent 的技能列表不完整的问题 ([#8387](https://github.com/getpaseo/paseo/pull/8387),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
||||
- 修复了目录订阅释放后侧栏标题和状态停止更新的问题 ([#8403](https://github.com/getpaseo/paseo/pull/8403),贡献者:[@ragokan](https://github.com/ragokan))
|
||||
- 在折叠的侧栏分组中保持当前工作区可见 ([#8395](https://github.com/getpaseo/paseo/pull/8395),贡献者:[@0xroylee](https://github.com/0xroylee))
|
||||
- 修复了聊天中的相对链接未打开嵌套仓库中的文件而指向不存在文件的问题 ([#8361](https://github.com/getpaseo/paseo/pull/8361),贡献者:[@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- 在直接连接的主机字段中支持 host:port 和粘贴的 URL ([#8223](https://github.com/getpaseo/paseo/pull/8223),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||
- 修复了非 Mac 键盘通过普通 HTTP 访问网页终端时 Ctrl+V 无法粘贴的问题 ([#8230](https://github.com/getpaseo/paseo/pull/8230),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
|
||||
- 停止在历史加载失败后反复重新打开 Provider,可在聊天中手动重试 ([#8278](https://github.com/getpaseo/paseo/pull/8278))
|
||||
- 修复了 Paseo 添加自身启动设置时丢弃 Claude 设置文件的问题 ([#8148](https://github.com/getpaseo/paseo/pull/8148),贡献者:[@yayugu](https://github.com/yayugu))
|
||||
- 修复了引导消息替换尚在启动的轮次并丢失原始提示词的问题 ([#6192](https://github.com/getpaseo/paseo/pull/6192))
|
||||
- 修复了 Pi 工具结果中的图片在流式输出和重新打开聊天后缺失的问题 ([#8280](https://github.com/getpaseo/paseo/pull/8280),贡献者:[@duyiliu](https://github.com/duyiliu))
|
||||
- 修复了 `edxeth/pi-subagents` 子 Agent 完成后仍显示为工作中的问题 ([#8176](https://github.com/getpaseo/paseo/pull/8176),贡献者:[@Zeno2019](https://github.com/Zeno2019))
|
||||
- 修复了重新打开会话后的 Claude 后台子 Agent 状态 ([#6259](https://github.com/getpaseo/paseo/pull/6259))
|
||||
- 修复了复用的插件子 Agent 开始新任务时仍显示上次已完成状态的问题 ([#8080](https://github.com/getpaseo/paseo/pull/8080),贡献者:[@bccy](https://github.com/bccy))
|
||||
- 修复了使用 `ollama` 等保留名称的自定义 Codex Provider ([#6954](https://github.com/getpaseo/paseo/pull/6954),贡献者:[@mr860819](https://github.com/mr860819))
|
||||
- 修复了 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))
|
||||
- 修复了删除 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))
|
||||
- 修复了拖动分隔线时,分隔线偏离指针的问题 ([#6011](https://github.com/getpaseo/paseo/pull/6011))
|
||||
- 修复了原生文件监听器启动延迟导致 Git 更新未被监听的问题 ([#8107](https://github.com/getpaseo/paseo/pull/8107))
|
||||
- 修复了内置 skill 参数未正确传递给 Codex 和 Pi 的问题 ([#8252](https://github.com/getpaseo/paseo/pull/8252),贡献者:[@AspireOne](https://github.com/AspireOne))
|
||||
|
||||
### 说明
|
||||
|
||||
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
|
||||
- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能
|
||||
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
|
||||
@@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi
|
||||
|
||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||
operations; requesting this feature gives update guidance.
|
||||
operations; requesting this feature gives update guidance. The registry retries an older destination
|
||||
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
|
||||
authentication failures stay in `error` until an update changes the connection, `enabled`, or
|
||||
`replaceIdentity`, or A restarts.
|
||||
|
||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||
|
||||
+3
-3
@@ -374,10 +374,10 @@ changes both `usePaseo()` and `useRpc()` to that host's installation. An offline
|
||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
|
||||
|
||||
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
|
||||
Host handles and host-targeted clients are owned by `packages/app/src/plugins/hosts`. Its `projectHost` is the secret boundary; host target input uses the shared `parseHostTarget` grammar. Per-installation
|
||||
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
|
||||
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
|
||||
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#hosts).
|
||||
|
||||
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
|
||||
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
|
||||
@@ -396,7 +396,7 @@ in-memory bundles usable, and a later catalog retries persistence.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#hosts).
|
||||
|
||||
Workspace panels, Command Center items, and client slash commands are client contributions. The
|
||||
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
|
||||
|
||||
@@ -507,6 +507,8 @@ interface AgentSession {
|
||||
}
|
||||
```
|
||||
|
||||
`describePersistence()` returns a handle only once a new provider process could resume it. Reload, idle runtime collection and daemon restart resume whatever handle the agent stored; a stored agent without one starts a new session instead. When there is no handle, `AgentManager` stores `getRuntimeInfo().sessionId` as one, so that ID follows the same rule. Codex, for example, starts a thread when the agent is created but writes its rollout only at its first turn, and `thread/resume` fails for it until then, so its adapter reports neither ID before Codex announces that turn.
|
||||
|
||||
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
|
||||
|
||||
### Steps
|
||||
|
||||
@@ -6,15 +6,25 @@ Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/h
|
||||
|
||||
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
|
||||
|
||||
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. Drawing part of a cluster on its own reshapes it: a half-form conjunct such as `स्ते` comes apart into `स्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
|
||||
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. xterm usually keeps a conjunct in one cell (see [Character widths](#character-widths)), but a colour change inside it leaves `क्` and `ष` in separate cells that still shape together. Drawing part of a cluster on its own reshapes it: the conjunct comes apart into `क्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
|
||||
|
||||
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some conjuncts. Grapheme rules follow the OS ICU version. Android 10 (API 29) splits `स्ते` after the virama and renders it incorrectly. The tested corpus passed on API 33, 34 and 35; API 30–32 have not been tested. We accept that version-dependent limitation to avoid bundling approximately 14 MB of ICU4J data. CoreText keeps this conjunct together on iOS. This renderer does not guarantee identical shaping across OS versions.
|
||||
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some clusters that span cells. Grouping only merges whole cells, so a conjunct xterm keeps in one cell cannot be split by the OS. Grapheme rules follow the OS ICU version, and we accept that to avoid bundling approximately 14 MB of ICU4J data. Before the buffers used grapheme widths, Android 10 (API 29) split `स्ते` across its two cells. This renderer does not guarantee identical shaping across OS versions.
|
||||
|
||||
`TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set.
|
||||
|
||||
The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows.
|
||||
|
||||
Emoji widths come from the headless buffer, which uses xterm's default Unicode 6 table. `👍` gets one column, and `👋🏽` and `🇰🇷` get two cells drawn as one cluster. Web loads the Unicode 11 addon, so the same output can wrap differently on web and native.
|
||||
## Character widths
|
||||
|
||||
The daemon, native and web buffers all load `@xterm/addon-unicode-graphemes`: the daemon through `packages/server/src/terminal/headless-terminal.ts`, native in `createNativeHeadlessTerminal`, web in `terminal-emulator-runtime.ts`. Each emoji cluster takes one two-column cell, including VS16 (`❤️`), skin tones (`👋🏽`), flags (`🇰🇷`) and ZWJ sequences (`👨👩👧`). That is the width colour-emoji fonts draw at. Under xterm's default Unicode 6 table `😀` got one column, and the native views scaled it to half size to fit.
|
||||
|
||||
Every buffer must use the same table. The daemon answers cursor-position reports (`CSI 6n`) from its buffer, and a client re-parses the daemon's ANSI replay with its own table. A disagreement moves the cursor and the later cells of any row that holds a disputed cluster: after replay from a Unicode 6 daemon, the cursor that followed `A😀😀😀B` lands inside the third emoji.
|
||||
|
||||
Daemons that use grapheme widths advertise `server_info.features["terminal-grapheme-widths"]`. `terminal-pane.tsx` reads it once and passes `characterWidths` to the emulator. Against an older daemon the app keeps each renderer's previous table: xterm's default on native, Unicode 11 on web. An older app against a newer daemon cannot match it, because every client shares the daemon's one buffer. A terminal kept by a worker from an unreleased pre-grapheme build keeps that worker's table until it exits.
|
||||
|
||||
The table follows Unicode grapheme clusters, as Node's `string-width` does for Ink-based agent TUIs. Indic conjuncts follow the same rule: `क्ष` and `स्ते` take one column, and the native views scale them down to fit it. Shells measure with the C library's `wcwidth` instead. glibc counts `❤️` as one column, `👋🏽` as four and `क्ष` as two, so a line editor that measures those disagrees with the terminal.
|
||||
|
||||
Use `@xterm/addon-unicode-graphemes` 0.5.0-beta.330 or later. Earlier versions read their width table from the wrong offset when Node decodes it into a pooled `Buffer`, which gives wrong widths or a load error depending on earlier allocations. `headless-terminal.test.ts` forces that case in a fresh process.
|
||||
|
||||
## Ink outside the row
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ buildNpmPackage {
|
||||
# The Nix output is not a distributable DMG, so leave it unsigned and
|
||||
# disable the hardened runtime that requires a matching signature.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
||||
../../node_modules/.bin/electron-builder \
|
||||
npm exec --offline -- electron-builder \
|
||||
--config electron-builder.yml \
|
||||
--dir \
|
||||
--mac \
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
|
||||
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
||||
|
||||
Generated
+1092
-1368
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
fillComposerDraft,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendDraftWhenReady(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await pressInComposer(page, "Enter");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await submitMessage(page, text);
|
||||
await fillComposerDraft(page, text);
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await pressInComposer(page, "Enter");
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
|
||||
@@ -38,6 +38,9 @@ import {
|
||||
fillDirectHostUri,
|
||||
expectDirectHostFormValues,
|
||||
expectDirectHostSslEnabled,
|
||||
expectDirectHostSslDisabled,
|
||||
fillDirectHostAddress,
|
||||
toggleDirectHostSsl,
|
||||
expectDirectHostUriValue,
|
||||
expectDirectHostUriHidden,
|
||||
expectDiagnosticsContent,
|
||||
@@ -284,6 +287,16 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expectAddHostMethodOptions(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
|
||||
await fillDirectHostAddress(page, "example.paseo.test", "7443");
|
||||
await expectDirectHostSslDisabled(page);
|
||||
await toggleDirectHostSsl(page);
|
||||
await expectDirectHostSslEnabled(page);
|
||||
await toggleHostAdvanced(page);
|
||||
await expectDirectHostUriValue(page, "tcp://example.paseo.test:7443?ssl=true");
|
||||
await toggleHostAdvanced(page);
|
||||
await toggleDirectHostSsl(page);
|
||||
await expectDirectHostSslDisabled(page);
|
||||
|
||||
await toggleHostAdvanced(page);
|
||||
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||
await toggleHostAdvanced(page);
|
||||
|
||||
@@ -426,3 +426,81 @@ test("Manual restores pre-beta order and keeps drag order across sorting and rel
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
function workspaceRows(page: Page, ids: string[]) {
|
||||
return page.locator(
|
||||
ids.map((id) => `[data-testid="sidebar-workspace-row-${getServerId()}:${id}"]`).join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
async function openWorkspaceRow(page: Page, workspaceId: string) {
|
||||
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
|
||||
}
|
||||
|
||||
test("collapsed groups lead with the open workspace instead of hiding it behind Show more", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { seed, ids } = await seedCompactProject();
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
const { rows, group } = compactProjectRows(page, seed);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
await revealWorkspaces(group, "Show more");
|
||||
await openWorkspaceRow(page, ids[9]!);
|
||||
await expectWorkspaceOrder(rows, ids);
|
||||
await revealWorkspaces(group, "Show less");
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await reconnectSidebar(page);
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await openWorkspaceRow(page, ids[2]!);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[2]!}`),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a Manual drag in a collapsed group keeps the open workspace's saved place", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { seed, ids } = await seedCompactProject();
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const projectKey = projectEquivalenceViewKey(seed.projectKey);
|
||||
await restorePreBetaOrder(
|
||||
page,
|
||||
projectKey,
|
||||
ids.map((id) => `${getServerId()}:${id}`),
|
||||
);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
const rows = workspaceRows(page, ids);
|
||||
const toggle = page.getByTestId(`sidebar-project-show-more-${projectKey}`);
|
||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
||||
await toggle.click();
|
||||
await openWorkspaceRow(page, ids[9]!);
|
||||
await toggle.click();
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
||||
await quickDragFirstRowAfterSecond(workspaceRows(page, [ids[0]!, ids[1]!]), pressWorkspaceRow);
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
|
||||
await reconnectSidebar(page);
|
||||
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
|
||||
await toggle.click();
|
||||
await expectWorkspaceOrder(rows, [ids[1]!, ids[0]!, ...ids.slice(2)]);
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -70,25 +70,31 @@ test("previous and next traverse every visible workspace while digit shortcuts s
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
|
||||
await expectHiddenWorkspaceShortcuts(page, order);
|
||||
await test.step("collapsed groups keep shortcut targets selected without expanding", async () => {
|
||||
await expectCollapsedWorkspaceShortcuts(page, order);
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
|
||||
async function expectCollapsedWorkspaceShortcuts(page: Page, order: string[]) {
|
||||
await page.getByLabel("Show less", { exact: true }).click();
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(7);
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await expect(rows).toHaveCount(7);
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await expect(rows).toHaveCount(8);
|
||||
await page.keyboard.press("Alt+]");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[9] });
|
||||
await expect(rows).toHaveCount(8);
|
||||
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Alt+]");
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
||||
await expect(rows).toHaveCount(7);
|
||||
await expect(page.getByLabel("Show more", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import xterm from "@xterm/headless";
|
||||
import { PNG } from "pngjs";
|
||||
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
|
||||
@@ -82,6 +83,8 @@ async function fixtureTerminal(size) {
|
||||
rows: size.rows,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
// The app's buffer uses grapheme widths; the expected cells must too.
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
await new Promise((done) =>
|
||||
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
|
||||
);
|
||||
|
||||
@@ -351,9 +351,24 @@ export async function expectDirectHostFormValues(
|
||||
}
|
||||
|
||||
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).toBeChecked();
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function fillDirectHostAddress(page: Page, host: string, port: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Host", exact: true }).fill(host);
|
||||
await page.getByRole("textbox", { name: "Port", exact: true }).fill(port);
|
||||
}
|
||||
|
||||
export async function toggleDirectHostSsl(page: Page): Promise<void> {
|
||||
await page.getByRole("checkbox", { name: "Use SSL", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectDirectHostSslDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).not.toBeChecked();
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
||||
}
|
||||
|
||||
+10
-10
@@ -1,21 +1,21 @@
|
||||
[
|
||||
{
|
||||
"output": "स्ते",
|
||||
"row": "2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
|
||||
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
"row": "4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "क्ष",
|
||||
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
|
||||
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "নক্ষত্র",
|
||||
"row": "1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
|
||||
"clusterStarts": [0, 1, 3, 5, 6, 7],
|
||||
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
"row": "1,1,3,1,3,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
},
|
||||
{
|
||||
"output": "क्अ",
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
{
|
||||
"output": "अ्क",
|
||||
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
|
||||
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
|
||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||
}
|
||||
|
||||
-8
@@ -1,6 +1,5 @@
|
||||
package sh.paseo.terminal
|
||||
|
||||
import android.os.Build
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
@@ -44,13 +43,6 @@ class TerminalRowLayoutTest {
|
||||
val output = row.getString("output")
|
||||
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
|
||||
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
|
||||
// Android 10's platform ICU splits this conjunct. Keep its observed raster baseline
|
||||
// explicit rather than claiming this supported OS passes the modern grapheme corpus.
|
||||
if (Build.VERSION.SDK_INT == 29 && output == "स्ते") {
|
||||
assertTrue("Android 10 unexpectedly matches modern conjunct clustering", toExpected > MATCH_TOLERANCE)
|
||||
assertTrue("Android 10 split-conjunct baseline changed", toContrast <= MATCH_TOLERANCE)
|
||||
continue
|
||||
}
|
||||
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
|
||||
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -71,13 +71,14 @@
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
"@xterm/addon-fit": "^0.12.0-beta.213",
|
||||
"@xterm/addon-image": "^0.10.0-beta.213",
|
||||
"@xterm/addon-image": "0.10.0-beta.331",
|
||||
"@xterm/addon-ligatures": "^0.11.0-beta.213",
|
||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
|
||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||
"@xterm/xterm": "6.1.0-beta.292",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.329",
|
||||
"@xterm/xterm": "6.1.0-beta.336",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "~57.0.30",
|
||||
"expo-asset": "~57.0.20",
|
||||
@@ -155,7 +156,7 @@
|
||||
"@vitest/browser": "^4.1.7",
|
||||
"@vitest/browser-playwright": "^4.1.7",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "^6.1.0-beta.213",
|
||||
"@xterm/headless": "6.1.0-beta.334",
|
||||
"babel-preset-expo": "~57.0.16",
|
||||
"dotenv": "^17.2.3",
|
||||
"eas-cli": "^16.24.1",
|
||||
|
||||
@@ -6,15 +6,12 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import {
|
||||
parseConnectionUri,
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
import { buildDirectConnectionUri } from "./direct-connection-host";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
|
||||
@@ -139,37 +136,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function isIpv6Host(host: string): boolean {
|
||||
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
|
||||
}
|
||||
|
||||
function buildConnectionUriFromDraft(
|
||||
draft: DirectConnectionDraft,
|
||||
labels: DirectConnectionLabels,
|
||||
): string {
|
||||
const host = draft.host.trim();
|
||||
const port = Number(draft.port.trim());
|
||||
if (!host) {
|
||||
throw new Error(labels.hostRequired);
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(labels.invalidPort);
|
||||
}
|
||||
|
||||
return serializeConnectionUriForStorage({
|
||||
host,
|
||||
port,
|
||||
isIpv6: isIpv6Host(host),
|
||||
useTls: draft.useTls,
|
||||
...(draft.password ? { password: draft.password } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function prepareDirectConnection(
|
||||
draft: DirectConnectionDraft,
|
||||
labels: DirectConnectionLabels,
|
||||
): PreparedDirectConnection {
|
||||
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
|
||||
const parsed = parseConnectionUri(buildDirectConnectionUri(draft, labels));
|
||||
const endpoint = parsed.isIpv6
|
||||
? `[${parsed.host}]:${parsed.port}`
|
||||
: `${parsed.host}:${parsed.port}`;
|
||||
@@ -420,7 +391,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
advancedTarget.current = new PairingTargetTracker("", true);
|
||||
try {
|
||||
setAdvancedUri(
|
||||
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
||||
buildDirectConnectionUri({ host, port, useTls, password }, directConnectionLabels),
|
||||
);
|
||||
} catch {
|
||||
setAdvancedUri("");
|
||||
@@ -505,6 +476,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={t("pairing.direct.fields.useSsl")}
|
||||
accessibilityState={useTlsAccessibilityState}
|
||||
aria-checked={useTls}
|
||||
testID="direct-ssl-toggle"
|
||||
>
|
||||
<View style={checkboxStyle}>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDirectConnectionUri, normalizeDirectConnectionHost } from "./direct-connection-host";
|
||||
|
||||
const labels = {
|
||||
hostRequired: "host required",
|
||||
invalidPort: "invalid port",
|
||||
};
|
||||
|
||||
function buildUri(host: string, port = "6767"): string {
|
||||
return buildDirectConnectionUri({ host, port, useTls: false, password: "" }, labels);
|
||||
}
|
||||
|
||||
describe("normalizeDirectConnectionHost", () => {
|
||||
it.each([
|
||||
["100.66.69.36", { host: "100.66.69.36", port: null, isIpv6: false }],
|
||||
[" 100.66.69.36 ", { host: "100.66.69.36", port: null, isIpv6: false }],
|
||||
["100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
|
||||
["tcp://100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
|
||||
["http://100.66.69.36:6767/path?x=1#y", { host: "100.66.69.36", port: "6767", isIpv6: false }],
|
||||
["HTTPS://example.test", { host: "example.test", port: null, isIpv6: false }],
|
||||
["my-host.local:7000", { host: "my-host.local", port: "7000", isIpv6: false }],
|
||||
["[::1]", { host: "::1", port: null, isIpv6: true }],
|
||||
["[::1]:7000", { host: "::1", port: "7000", isIpv6: true }],
|
||||
["::1", { host: "::1", port: null, isIpv6: true }],
|
||||
["fe80::1", { host: "fe80::1", port: null, isIpv6: true }],
|
||||
["", { host: "", port: null, isIpv6: false }],
|
||||
])("normalizes %j", (input, expected) => {
|
||||
expect(normalizeDirectConnectionHost(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDirectConnectionUri", () => {
|
||||
it.each([
|
||||
["100.66.69.36", "tcp://100.66.69.36:6767"],
|
||||
["100.66.69.36:6767", "tcp://100.66.69.36:6767"],
|
||||
["tcp://100.66.69.36:6767", "tcp://100.66.69.36:6767"],
|
||||
["http://100.66.69.36:6767/path?x", "tcp://100.66.69.36:6767"],
|
||||
["my-host.local:6767", "tcp://my-host.local:6767"],
|
||||
["[::1]", "tcp://[::1]:6767"],
|
||||
["[::1]:7000", "tcp://[::1]:7000"],
|
||||
["::1", "tcp://[::1]:6767"],
|
||||
["fe80::1", "tcp://[fe80::1]:6767"],
|
||||
])("builds a tcp URI from host %j", (host, expected) => {
|
||||
expect(buildUri(host)).toBe(expected);
|
||||
});
|
||||
|
||||
it("lets a port typed in the host field override the port field", () => {
|
||||
expect(buildUri("100.66.69.36:7000", "6767")).toBe("tcp://100.66.69.36:7000");
|
||||
});
|
||||
|
||||
it("keeps TLS and password settings", () => {
|
||||
expect(
|
||||
buildDirectConnectionUri(
|
||||
{ host: "tcp://example.test:443", port: "6767", useTls: true, password: "secret" },
|
||||
labels,
|
||||
),
|
||||
).toBe("tcp://example.test:443?ssl=true&password=secret");
|
||||
});
|
||||
|
||||
it.each(["", " ", "tcp://", "http:///path"])("requires a host for %j", (host) => {
|
||||
expect(() => buildUri(host)).toThrow("host required");
|
||||
});
|
||||
|
||||
it("rejects an invalid port", () => {
|
||||
expect(() => buildUri("100.66.69.36", "70000")).toThrow("invalid port");
|
||||
expect(() => buildUri("100.66.69.36:0")).toThrow("invalid port");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { serializeConnectionUriForStorage } from "@/utils/daemon-endpoints";
|
||||
|
||||
export interface DirectConnectionHostInput {
|
||||
host: string;
|
||||
port: string;
|
||||
useTls: boolean;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface DirectConnectionHostLabels {
|
||||
hostRequired: string;
|
||||
invalidPort: string;
|
||||
}
|
||||
|
||||
export interface NormalizedDirectConnectionHost {
|
||||
host: string;
|
||||
/** Port typed into the host field, if any. Overrides the separate port field. */
|
||||
port: string | null;
|
||||
isIpv6: boolean;
|
||||
}
|
||||
|
||||
const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
const BRACKETED_IPV6_PATTERN = /^\[([^\]]*)\](?::(\d*))?$/;
|
||||
const HOST_WITH_PORT_PATTERN = /^([^:]+):(\d+)$/;
|
||||
|
||||
/**
|
||||
* Users often paste `host:port` or a full URL into the host field.
|
||||
* Reduce it to a bare host (plus an optional port) before building the URI.
|
||||
*/
|
||||
export function normalizeDirectConnectionHost(input: string): NormalizedDirectConnectionHost {
|
||||
let host = input.trim().replace(SCHEME_PATTERN, "");
|
||||
const suffixIndex = host.search(/[/?#]/);
|
||||
if (suffixIndex !== -1) {
|
||||
host = host.slice(0, suffixIndex);
|
||||
}
|
||||
|
||||
const bracketed = BRACKETED_IPV6_PATTERN.exec(host);
|
||||
if (bracketed) {
|
||||
return { host: bracketed[1] ?? "", port: bracketed[2] || null, isIpv6: true };
|
||||
}
|
||||
|
||||
const hostWithPort = HOST_WITH_PORT_PATTERN.exec(host);
|
||||
if (hostWithPort) {
|
||||
return { host: hostWithPort[1] ?? "", port: hostWithPort[2] ?? null, isIpv6: false };
|
||||
}
|
||||
|
||||
return { host, port: null, isIpv6: host.split(":").length > 2 };
|
||||
}
|
||||
|
||||
export function buildDirectConnectionUri(
|
||||
draft: DirectConnectionHostInput,
|
||||
labels: DirectConnectionHostLabels,
|
||||
): string {
|
||||
const normalized = normalizeDirectConnectionHost(draft.host);
|
||||
const port = Number((normalized.port ?? draft.port).trim());
|
||||
if (!normalized.host) {
|
||||
throw new Error(labels.hostRequired);
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(labels.invalidPort);
|
||||
}
|
||||
|
||||
return serializeConnectionUriForStorage({
|
||||
host: normalized.host,
|
||||
port,
|
||||
isIpv6: normalized.isIpv6,
|
||||
useTls: draft.useTls,
|
||||
...(draft.password ? { password: draft.password } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { HostPickerOption } from "./host-picker";
|
||||
|
||||
const actions: string[] = [];
|
||||
function select() {
|
||||
actions.push("select");
|
||||
}
|
||||
function openSettings() {
|
||||
actions.push("settings");
|
||||
}
|
||||
|
||||
it("keeps host selection and settings as separate accessible buttons", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
actions.length = 0;
|
||||
act(() =>
|
||||
root.render(
|
||||
<HostPickerOption
|
||||
serverId="host-a"
|
||||
label="Host A"
|
||||
showActiveConnection={false}
|
||||
active={false}
|
||||
onPress={select}
|
||||
onOpenHostSettings={openSettings}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const host = container.querySelector('button[aria-label="Host A"]');
|
||||
const settings = container.querySelector('button[aria-label="Open Host A settings"]');
|
||||
expect(host).not.toBeNull();
|
||||
expect(settings).not.toBeNull();
|
||||
expect(host?.contains(settings!)).toBe(false);
|
||||
act(() => (settings as HTMLElement).click());
|
||||
expect(actions).toEqual(["settings"]);
|
||||
act(() => (host as HTMLElement).click());
|
||||
expect(actions).toEqual(["settings", "select"]);
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/comb
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
|
||||
import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
@@ -89,35 +90,33 @@ export function HostPickerOption({
|
||||
);
|
||||
const trailingSlot = useMemo(() => {
|
||||
if (!onOpenHostSettings) return undefined;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleSettingsPress}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${label} settings`}
|
||||
>
|
||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}, [
|
||||
handleSettingsPress,
|
||||
label,
|
||||
onOpenHostSettings,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
]);
|
||||
return <View style={styles.settingsSlot} />;
|
||||
}, [onOpenHostSettings]);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
description={connectionLabel}
|
||||
leadingSlot={leadingSlot}
|
||||
trailingSlot={trailingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
/>
|
||||
<View style={styles.optionContainer}>
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
description={connectionLabel}
|
||||
leadingSlot={leadingSlot}
|
||||
trailingSlot={trailingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
/>
|
||||
{onOpenHostSettings ? (
|
||||
<Pressable
|
||||
style={styles.settingsAction}
|
||||
onPress={handleSettingsPress}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${label} settings`}
|
||||
>
|
||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,6 +318,15 @@ export function HostPicker({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
optionContainer: { position: "relative" },
|
||||
settingsSlot: { width: theme.iconSize.sm, height: theme.iconSize.sm },
|
||||
settingsAction: {
|
||||
position: "absolute",
|
||||
right: theme.spacing[3] + (isWeb ? 0 : theme.spacing[1]),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
},
|
||||
statusDotSlot: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
|
||||
@@ -462,7 +462,7 @@ describe("sidebar group visibility", () => {
|
||||
|
||||
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
|
||||
const items = entries(Array(count).fill("done"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.current.canToggle).toBe(count > 7);
|
||||
});
|
||||
@@ -471,7 +471,9 @@ describe("sidebar group visibility", () => {
|
||||
"keeps row twelve visible for %s without filling gaps",
|
||||
(status) => {
|
||||
const items = entries([...Array(11).fill("done"), status]);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup({ items, selection: null, policy }),
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
"row-1",
|
||||
"row-2",
|
||||
@@ -492,7 +494,11 @@ describe("sidebar group visibility", () => {
|
||||
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
|
||||
for (const entriesByKey of [new Map(), hydrated]) {
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
|
||||
useLimitedSidebarGroup({
|
||||
items,
|
||||
selection: null,
|
||||
policy: createWorkspaceVisibilityPolicy(entriesByKey),
|
||||
}),
|
||||
);
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
expect(names(result.current.visibleItems)).toEqual(
|
||||
@@ -512,7 +518,7 @@ describe("sidebar group visibility", () => {
|
||||
"needs_input",
|
||||
"failed",
|
||||
]);
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
expect(result.current.visibleItems).toEqual(items);
|
||||
expect(result.current.canToggle).toBe(false);
|
||||
});
|
||||
@@ -527,7 +533,7 @@ describe("sidebar group visibility", () => {
|
||||
"attention",
|
||||
]);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ rows }) => useLimitedSidebarGroup(rows, policy),
|
||||
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
|
||||
{ initialProps: { rows: items } },
|
||||
);
|
||||
expect(names(result.current.visibleItems)).toEqual([
|
||||
@@ -568,7 +574,7 @@ describe("sidebar group visibility", () => {
|
||||
expect(result.current.expanded).toBe(false);
|
||||
act(() => result.current.toggleExpanded());
|
||||
unmount();
|
||||
const remounted = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||
const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
||||
expect(remounted.result.current.expanded).toBe(false);
|
||||
expect(remounted.result.current.visibleItems).toHaveLength(10);
|
||||
});
|
||||
@@ -592,7 +598,12 @@ describe("sidebar group visibility", () => {
|
||||
lifecycle: "active" as const,
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy),
|
||||
({ last }) =>
|
||||
useLimitedSidebarGroup({
|
||||
items: [...items.slice(0, 11), last],
|
||||
selection: null,
|
||||
policy,
|
||||
}),
|
||||
{ initialProps: { last: items[11] } },
|
||||
);
|
||||
expect(result.current.visibleItems).toHaveLength(7);
|
||||
@@ -630,7 +641,7 @@ describe("sidebar group visibility", () => {
|
||||
|
||||
it("preserves the twenty-row pinned policy regardless of status", () => {
|
||||
const items = entries(Array(21).fill("running"));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup(items));
|
||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
|
||||
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
act(() => result.current.toggleExpanded());
|
||||
|
||||
@@ -1598,11 +1598,16 @@ function ProjectBlock({
|
||||
[workspaceEntriesByKey],
|
||||
);
|
||||
const {
|
||||
leadingItem: leadingWorkspace,
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
|
||||
} = useLimitedSidebarGroup({
|
||||
items: project.workspaces,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
policy: visibilityPolicy,
|
||||
});
|
||||
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
|
||||
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
|
||||
@@ -1761,28 +1766,33 @@ function ProjectBlock({
|
||||
let projectChildren = null;
|
||||
if (!collapsed) {
|
||||
if (project.workspaces.length > 0) {
|
||||
const leadingWorkspaceRow = leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null;
|
||||
projectChildren = (
|
||||
<>
|
||||
{manualSort ? (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
<>
|
||||
{leadingWorkspaceRow}
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
style={styles.workspaceListContainer}
|
||||
>
|
||||
{leadingWorkspaceRow}
|
||||
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
|
||||
</View>
|
||||
)}
|
||||
@@ -2188,11 +2198,15 @@ function ProjectModeList({
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||
const {
|
||||
leadingItem: leadingPinnedChat,
|
||||
visibleItems: visiblePinnedChats,
|
||||
expanded: pinnedChatsExpanded,
|
||||
canToggle: canTogglePinnedChats,
|
||||
toggleExpanded: togglePinnedChatsExpanded,
|
||||
} = useLimitedSidebarGroup(pinnedChats);
|
||||
} = useLimitedSidebarGroup({
|
||||
items: pinnedChats,
|
||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
||||
});
|
||||
const nativeScrollGestureProps = useMemo(
|
||||
() =>
|
||||
parentGestureRef
|
||||
@@ -2395,37 +2409,38 @@ function ProjectModeList({
|
||||
[renderProjectBlock],
|
||||
);
|
||||
|
||||
const renderPinnedChat = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
workspace={workspace}
|
||||
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
||||
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
|
||||
leadingProjectName={workspace.projectName}
|
||||
leadingProjectIconDataUri={
|
||||
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
|
||||
}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={workspace.projectKind === "git"}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
const renderPinnedChatRow = useCallback(
|
||||
(
|
||||
workspace: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
) => (
|
||||
<MemoWorkspaceRowItem
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
||||
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
|
||||
leadingProjectName={workspace.projectName}
|
||||
leadingProjectIconDataUri={
|
||||
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
|
||||
}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={workspace.projectKind === "git"}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
),
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
creatingWorkspaceIds,
|
||||
@@ -2441,6 +2456,22 @@ function ProjectModeList({
|
||||
],
|
||||
);
|
||||
|
||||
const renderPinnedChat = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderPinnedChatRow(workspace, {
|
||||
drag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps,
|
||||
});
|
||||
},
|
||||
[renderPinnedChatRow],
|
||||
);
|
||||
|
||||
const projectBody =
|
||||
projects.length === 0 ? (
|
||||
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
|
||||
@@ -2473,6 +2504,7 @@ function ProjectModeList({
|
||||
/>
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{leadingPinnedChat ? renderPinnedChatRow(leadingPinnedChat) : null}
|
||||
<DraggableList
|
||||
testID="sidebar-pinned-list"
|
||||
data={visiblePinnedChats}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
import {
|
||||
selectSidebarGroupVisibility,
|
||||
type SidebarGroupItem,
|
||||
type SidebarGroupVisibilityPolicy,
|
||||
} from "./sidebar-group-visibility";
|
||||
|
||||
interface Item extends SidebarGroupItem {
|
||||
id: number;
|
||||
statusBucket: SidebarStateBucket;
|
||||
}
|
||||
|
||||
const serverId = "host-a";
|
||||
|
||||
function rows(statuses: readonly SidebarStateBucket[], rowServerId = serverId): Item[] {
|
||||
return statuses.map((statusBucket, index) => ({
|
||||
id: index + 1,
|
||||
serverId: rowServerId,
|
||||
workspaceId: `workspace-${index + 1}`,
|
||||
statusBucket,
|
||||
}));
|
||||
}
|
||||
|
||||
const workspacePolicy: SidebarGroupVisibilityPolicy<Item> = {
|
||||
kind: "workspaces",
|
||||
getStatusBucket: (item) => item.statusBucket,
|
||||
};
|
||||
|
||||
function selectionFor(item: SidebarGroupItem): ActiveWorkspaceSelection {
|
||||
return { serverId: item.serverId, workspaceId: item.workspaceId };
|
||||
}
|
||||
|
||||
describe("selectSidebarGroupVisibility", () => {
|
||||
it("leads with a hidden open row while keeping the baseline order", () => {
|
||||
const items = rows(Array(9).fill("done"));
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: workspacePolicy,
|
||||
selection: selectionFor(items[8]!),
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBe(items[8]);
|
||||
expect(result.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.canToggle).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves selected rows in place when they are already in the baseline", () => {
|
||||
const items = rows([...Array(8).fill("done"), "running", "done"]);
|
||||
const expected = [...items.slice(0, 7), items[8]];
|
||||
|
||||
for (const selectedIndex of [2, 8]) {
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: workspacePolicy,
|
||||
selection: selectionFor(items[selectedIndex]!),
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBeNull();
|
||||
expect(result.visibleItems).toEqual(expected);
|
||||
expect(result.canToggle).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the whole group without a toggle when the open row is the only hidden row", () => {
|
||||
const items = rows(Array(8).fill("done"));
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: workspacePolicy,
|
||||
selection: selectionFor(items[7]!),
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBeNull();
|
||||
expect(result.visibleItems).toEqual(items);
|
||||
expect(result.canToggle).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps expanded groups in their original order", () => {
|
||||
const items = rows(Array(9).fill("done"));
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: true,
|
||||
policy: workspacePolicy,
|
||||
selection: selectionFor(items[8]!),
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBeNull();
|
||||
expect(result.visibleItems).toEqual(items);
|
||||
expect(result.canToggle).toBe(true);
|
||||
});
|
||||
|
||||
it("applies the twenty-row limit to pinned groups", () => {
|
||||
const items = rows(Array(22).fill("done"));
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: { kind: "pinned" },
|
||||
selection: selectionFor(items[21]!),
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBe(items[21]);
|
||||
expect(result.visibleItems).toEqual(items.slice(0, 20));
|
||||
expect(result.canToggle).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the current limit when selection is null", () => {
|
||||
const items = rows(Array(8).fill("done"));
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: workspacePolicy,
|
||||
selection: null,
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBeNull();
|
||||
expect(result.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.canToggle).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the open row by server and workspace identity", () => {
|
||||
const items = rows(Array(9).fill("done"), "host-b");
|
||||
const result = selectSidebarGroupVisibility({
|
||||
items,
|
||||
expanded: false,
|
||||
policy: workspacePolicy,
|
||||
selection: { serverId: serverId, workspaceId: items[8]!.workspaceId },
|
||||
});
|
||||
|
||||
expect(result.leadingItem).toBeNull();
|
||||
expect(result.visibleItems).toEqual(items.slice(0, 7));
|
||||
expect(result.canToggle).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,28 @@
|
||||
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
|
||||
export interface SidebarGroupItem {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface SidebarGroupVisibilityInput<T> {
|
||||
items: readonly T[];
|
||||
expanded: boolean;
|
||||
policy: SidebarGroupVisibilityPolicy<T>;
|
||||
/** The workspace the group's rows highlight as selected. Null when no row can be selected. */
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
}
|
||||
|
||||
export interface SidebarGroupVisibility<T> {
|
||||
/**
|
||||
* The open workspace when the collapsed limit would hide it. Rendered before `visibleItems`,
|
||||
* out of group order, outside any drag list. Never also present in `visibleItems`.
|
||||
*/
|
||||
leadingItem: T | null;
|
||||
visibleItems: T[];
|
||||
canToggle: boolean;
|
||||
}
|
||||
|
||||
export type SidebarGroupVisibilityPolicy<T> =
|
||||
| { kind: "pinned" }
|
||||
@@ -16,18 +40,45 @@ export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string
|
||||
};
|
||||
}
|
||||
|
||||
export function selectSidebarGroupVisibility<T>(
|
||||
items: readonly T[],
|
||||
expanded: boolean,
|
||||
policy: SidebarGroupVisibilityPolicy<T>,
|
||||
) {
|
||||
const limit = policy.kind === "pinned" ? 20 : 7;
|
||||
const baseline = items.filter(
|
||||
(item, index) =>
|
||||
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
|
||||
function isOpenWorkspace(
|
||||
item: SidebarGroupItem,
|
||||
selection: ActiveWorkspaceSelection | null,
|
||||
): boolean {
|
||||
return (
|
||||
selection !== null &&
|
||||
item.serverId === selection.serverId &&
|
||||
item.workspaceId === selection.workspaceId
|
||||
);
|
||||
}
|
||||
|
||||
export function selectSidebarGroupVisibility<T extends SidebarGroupItem>(
|
||||
input: SidebarGroupVisibilityInput<T>,
|
||||
): SidebarGroupVisibility<T> {
|
||||
const { items, expanded, policy, selection } = input;
|
||||
const limit = policy.kind === "pinned" ? 20 : 7;
|
||||
const baseline: T[] = [];
|
||||
let leadingItem: T | null = null;
|
||||
let canToggle = false;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const inBaseline =
|
||||
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done");
|
||||
if (inBaseline) {
|
||||
baseline.push(item);
|
||||
} else if (leadingItem === null && isOpenWorkspace(item, selection)) {
|
||||
leadingItem = item;
|
||||
} else {
|
||||
canToggle = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (expanded || !canToggle) {
|
||||
return { leadingItem: null, visibleItems: items.slice(), canToggle };
|
||||
}
|
||||
|
||||
return {
|
||||
visibleItems: expanded ? items.slice() : baseline,
|
||||
canToggle: baseline.length < items.length,
|
||||
leadingItem,
|
||||
visibleItems: baseline,
|
||||
canToggle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ export function SidebarStatusWorkspaceList({
|
||||
dragGestureHostActive,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const { t } = useTranslation();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedWorkspaceGroupKeys,
|
||||
);
|
||||
@@ -155,23 +156,27 @@ export function SidebarStatusWorkspaceList({
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
const {
|
||||
leadingItem: leadingPinnedWorkspace,
|
||||
visibleItems: visiblePinnedWorkspaces,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
canToggle: canTogglePinnedWorkspaces,
|
||||
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(pinnedWorkspaces);
|
||||
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
|
||||
|
||||
const statusShortcutIndex = showShortcutBadges
|
||||
? shortcutIndexByWorkspaceKey
|
||||
: EMPTY_SHORTCUT_INDEX;
|
||||
const renderPinnedWorkspace = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => (
|
||||
const renderPinnedWorkspaceRow = useCallback(
|
||||
(
|
||||
workspace: SidebarWorkspaceEntry,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
{...buildStatusRowProjectPresentation({
|
||||
workspace,
|
||||
@@ -184,9 +189,9 @@ export function SidebarStatusWorkspaceList({
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -199,6 +204,20 @@ export function SidebarStatusWorkspaceList({
|
||||
supportsPinningByServerId,
|
||||
],
|
||||
);
|
||||
const renderPinnedWorkspace = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) =>
|
||||
renderPinnedWorkspaceRow(workspace, {
|
||||
drag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps,
|
||||
}),
|
||||
[renderPinnedWorkspaceRow],
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
@@ -211,6 +230,7 @@ export function SidebarStatusWorkspaceList({
|
||||
/>
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{leadingPinnedWorkspace ? renderPinnedWorkspaceRow(leadingPinnedWorkspace) : null}
|
||||
<DraggableList
|
||||
testID="sidebar-pinned-list"
|
||||
data={visiblePinnedWorkspaces}
|
||||
@@ -345,12 +365,37 @@ function StatusGroupRows({
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const {
|
||||
leadingItem: leadingWorkspace,
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY);
|
||||
} = useLimitedSidebarGroup({
|
||||
items: group.rows,
|
||||
selection: activeWorkspaceSelection,
|
||||
policy: STATUS_GROUP_VISIBILITY,
|
||||
});
|
||||
|
||||
function renderWorkspaceRow(workspace: SidebarWorkspaceEntry) {
|
||||
return (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
{...buildStatusRowProjectPresentation({
|
||||
workspace,
|
||||
projectIconByProjectViewKey,
|
||||
hostBadgeByServerId,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
|
||||
@@ -360,22 +405,8 @@ function StatusGroupRows({
|
||||
style={styles.statusWorkspaceListContainer}
|
||||
testID={`sidebar-status-group-rows-${group.key}`}
|
||||
>
|
||||
{visibleWorkspaces.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
{...buildStatusRowProjectPresentation({
|
||||
workspace,
|
||||
projectIconByProjectViewKey,
|
||||
hostBadgeByServerId,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
{leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null}
|
||||
{visibleWorkspaces.map(renderWorkspaceRow)}
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
selectSidebarGroupVisibility,
|
||||
type SidebarGroupItem,
|
||||
type SidebarGroupVisibility,
|
||||
type SidebarGroupVisibilityPolicy,
|
||||
} from "./sidebar-group-visibility";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
|
||||
const PINNED_POLICY = { kind: "pinned" } as const;
|
||||
|
||||
export function useLimitedSidebarGroup<T>(
|
||||
items: readonly T[],
|
||||
policy: SidebarGroupVisibilityPolicy<T> = PINNED_POLICY,
|
||||
) {
|
||||
interface LimitedSidebarGroupInput<T> {
|
||||
items: readonly T[];
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
policy?: SidebarGroupVisibilityPolicy<T>;
|
||||
}
|
||||
|
||||
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
|
||||
items,
|
||||
selection,
|
||||
policy = PINNED_POLICY,
|
||||
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
|
||||
expanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
} {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibility = useMemo(
|
||||
() => selectSidebarGroupVisibility(items, expanded, policy),
|
||||
[expanded, items, policy],
|
||||
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
|
||||
[expanded, items, policy, selection],
|
||||
);
|
||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "../terminal/local-links/terminal-local-link-provider";
|
||||
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
|
||||
import type { TerminalFile } from "../terminal/file-paste";
|
||||
import type { TerminalCharacterWidths } from "../terminal/character-widths";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
|
||||
@@ -32,6 +33,7 @@ export interface TerminalEmulatorProps {
|
||||
ref: Ref<TerminalEmulatorHandle>;
|
||||
streamKey: string;
|
||||
supportsTerminalInputModeReplay: boolean;
|
||||
characterWidths: TerminalCharacterWidths;
|
||||
testId?: string;
|
||||
xtermTheme?: ITheme;
|
||||
scrollbackLines: number;
|
||||
|
||||
@@ -93,6 +93,7 @@ function NativeTerminalEmulator({
|
||||
testId = "terminal-surface",
|
||||
xtermTheme = DEFAULT_XTERM_THEME,
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
keyboardInset = 0,
|
||||
@@ -373,6 +374,7 @@ function NativeTerminalEmulator({
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
});
|
||||
terminalRef.current = terminal;
|
||||
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
|
||||
@@ -387,6 +389,7 @@ function NativeTerminalEmulator({
|
||||
resetInputModeTracker,
|
||||
resolveTerminalScreen,
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
clearSelection,
|
||||
]);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
|
||||
import type { TerminalFile } from "../terminal/file-paste";
|
||||
import type { TerminalCharacterWidths } from "../terminal/character-widths";
|
||||
import type {
|
||||
TerminalLocalFileLinkSource,
|
||||
TerminalLocalFileLinkTarget,
|
||||
@@ -96,6 +97,7 @@ interface TerminalEmulatorProps {
|
||||
ref: Ref<TerminalEmulatorHandle>;
|
||||
streamKey: string;
|
||||
supportsTerminalInputModeReplay: boolean;
|
||||
characterWidths: TerminalCharacterWidths;
|
||||
testId?: string;
|
||||
xtermTheme?: ITheme;
|
||||
scrollbackLines: number;
|
||||
@@ -154,6 +156,7 @@ export default function TerminalEmulator({
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
scrollbackLines,
|
||||
characterWidths,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
swipeGesturesEnabled = false,
|
||||
@@ -183,6 +186,8 @@ export default function TerminalEmulator({
|
||||
const fontSizeRef = useRef(fontSize);
|
||||
const scrollbackLinesRef = useRef(scrollbackLines);
|
||||
scrollbackLinesRef.current = scrollbackLines;
|
||||
const characterWidthsRef = useRef(characterWidths);
|
||||
characterWidthsRef.current = characterWidths;
|
||||
fontFamilyRef.current = fontFamily;
|
||||
fontSizeRef.current = fontSize;
|
||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||
@@ -315,6 +320,10 @@ export default function TerminalEmulator({
|
||||
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
|
||||
}, [scrollbackLines]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.setCharacterWidths({ widths: characterWidths });
|
||||
}, [characterWidths]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !swipeGesturesEnabled) {
|
||||
@@ -447,6 +456,7 @@ export default function TerminalEmulator({
|
||||
},
|
||||
});
|
||||
runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current });
|
||||
runtime.setCharacterWidths({ widths: characterWidthsRef.current });
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
|
||||
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
|
||||
import type { TerminalCharacterWidths } from "@/terminal/character-widths";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -304,6 +305,11 @@ export function TerminalPane({
|
||||
const supportsTerminalSizeOwnership = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
|
||||
);
|
||||
const characterWidths: TerminalCharacterWidths = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.serverInfo?.features?.["terminal-grapheme-widths"] === true
|
||||
? "graphemes"
|
||||
: "legacy",
|
||||
);
|
||||
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
@@ -1171,6 +1177,7 @@ export function TerminalPane({
|
||||
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
||||
streamKey={terminalStreamKey}
|
||||
supportsTerminalInputModeReplay={supportsTerminalInputModeReplay}
|
||||
characterWidths={characterWidths}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
scrollbackLines={settings.terminalScrollbackLines}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
|
||||
import type { DesktopDaemonStatus } from "./desktop-daemon";
|
||||
import { resolveDesktopDaemonServerId, type DesktopDaemonStatus } from "./desktop-daemon";
|
||||
|
||||
const runningManagedStatus: DesktopDaemonStatus = {
|
||||
serverId: "desktop",
|
||||
@@ -54,6 +54,29 @@ function makeDeps(overrides?: {
|
||||
};
|
||||
}
|
||||
|
||||
it("retains the desktop identity when the daemon is stopped or status fails", async () => {
|
||||
expect(
|
||||
await resolveDesktopDaemonServerId({
|
||||
getStatus: async () => ({ ...stoppedStatus, serverId: "" }),
|
||||
getSavedId: async () => "desktop",
|
||||
}),
|
||||
).toBe("desktop");
|
||||
expect(
|
||||
await resolveDesktopDaemonServerId({
|
||||
getStatus: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
getSavedId: async () => "desktop",
|
||||
}),
|
||||
).toBe("desktop");
|
||||
expect(
|
||||
await resolveDesktopDaemonServerId({
|
||||
getStatus: async () => runningManagedStatus,
|
||||
getSavedId: async () => "old",
|
||||
}),
|
||||
).toBe("desktop");
|
||||
});
|
||||
|
||||
describe("executeDaemonManagementToggle", () => {
|
||||
describe("enable path (currentlyManaging: false)", () => {
|
||||
it("persists the new setting then starts the daemon", async () => {
|
||||
|
||||
@@ -186,6 +186,28 @@ export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status"));
|
||||
}
|
||||
|
||||
/** Resolve the identity even when the daemon has stopped. */
|
||||
export async function resolveDesktopDaemonServerId(
|
||||
deps: {
|
||||
getStatus(): Promise<DesktopDaemonStatus>;
|
||||
getSavedId(): Promise<string | null>;
|
||||
} = {
|
||||
getStatus: getDesktopDaemonStatus,
|
||||
getSavedId: () => invokeDesktopCommand<string | null>("desktop_daemon_identity"),
|
||||
},
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const id = (await deps.getStatus()).serverId.trim();
|
||||
if (id) return id;
|
||||
} catch {
|
||||
/* The persisted identity remains available if the status request fails. */
|
||||
}
|
||||
const id = await deps.getSavedId();
|
||||
if (id === null) return null;
|
||||
if (typeof id !== "string") throw new Error("Invalid desktop daemon identity");
|
||||
return id.trim() || null;
|
||||
}
|
||||
|
||||
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
|
||||
}
|
||||
|
||||
@@ -132,12 +132,19 @@ function harness() {
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
renameHost: async () => {},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async () => {},
|
||||
removeConnection: async () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createPlayAudio, type AudioEngine } from "@/audio";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import {
|
||||
resolveDesktopDaemonServerId,
|
||||
shouldUseDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
|
||||
import {
|
||||
@@ -26,7 +30,14 @@ export function createPluginClientRuntime(
|
||||
...capabilities,
|
||||
i18n: installation.i18n,
|
||||
playAudio: createPlayAudio(audio, installation.lifetime.signal),
|
||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
|
||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal, {
|
||||
openSettings(serverId) {
|
||||
createPluginNavigation({ serverId, workspaceId: null }).openHostSettings(serverId);
|
||||
},
|
||||
async isManagedLocalHost(serverId) {
|
||||
return shouldUseDesktopDaemon() && (await resolveDesktopDaemonServerId()) === serverId;
|
||||
},
|
||||
}),
|
||||
addComposerPill(contribution) {
|
||||
return pluginButtonStore.addComposerPill(installation, contribution);
|
||||
},
|
||||
|
||||
@@ -819,6 +819,14 @@ it("binds imported getters to each originating installation across delayed callb
|
||||
hosts: {
|
||||
getSnapshot: () => [],
|
||||
subscribe: () => () => {},
|
||||
observeHosts: () => {
|
||||
calls.push(`${installation}/observe`);
|
||||
return () => {};
|
||||
},
|
||||
addHost: async (input) => {
|
||||
calls.push(`${installation}/add:${input.connection}`);
|
||||
return { status: "cancelled" };
|
||||
},
|
||||
getPaseoClient(serverId) {
|
||||
calls.push(`${installation}/${serverId}`);
|
||||
return runtime.paseo;
|
||||
@@ -826,11 +834,12 @@ it("binds imported getters to each originating installation across delayed callb
|
||||
},
|
||||
});
|
||||
const source = bundle(`
|
||||
const { getPaseoClient } = require("@getpaseo/plugin/client");
|
||||
const { getPaseoClient, observeHosts, addHost } = require("@getpaseo/plugin/client");
|
||||
getPaseoClient("entry-host");
|
||||
observeHosts(() => {});
|
||||
plugin.addCommandCenterItem({
|
||||
id: "read", title: "Read", icon: "Server", context: "global",
|
||||
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); },
|
||||
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); await addHost({ connection: "example:6767" }); },
|
||||
});
|
||||
`);
|
||||
const first = runPluginClientBundle("same-id", source, hostRuntime("first"));
|
||||
@@ -840,9 +849,13 @@ it("binds imported getters to each originating installation across delayed callb
|
||||
await second.commandCenterItems[0].onSelect({} as never);
|
||||
expect(calls).toEqual([
|
||||
"first/entry-host",
|
||||
"first/observe",
|
||||
"second/entry-host",
|
||||
"second/observe",
|
||||
"first/target-host",
|
||||
"first/add:example:6767",
|
||||
"second/target-host",
|
||||
"second/add:example:6767",
|
||||
]);
|
||||
await first.cleanup();
|
||||
await second.cleanup();
|
||||
|
||||
@@ -520,6 +520,10 @@ export function runPluginClientBundle(
|
||||
useSettings,
|
||||
openExternalUrl,
|
||||
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
|
||||
addHost: (input: Parameters<typeof runtime.hosts.addHost>[0]) =>
|
||||
runtime.hosts.addHost(input),
|
||||
observeHosts: (listener: Parameters<typeof runtime.hosts.observeHosts>[0]) =>
|
||||
runtime.hosts.observeHosts(listener),
|
||||
useHosts: () =>
|
||||
React.useSyncExternalStore(
|
||||
runtime.hosts.subscribe,
|
||||
|
||||
@@ -1,17 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createPluginHosts, type PluginHostsSource } from "./index";
|
||||
import { resolveDesktopDaemonServerId } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
function registry() {
|
||||
const hosts = [
|
||||
{ serverId: "a", label: "Alpha", password: "secret" },
|
||||
{ serverId: "b", label: "Beta", password: "secret" },
|
||||
{
|
||||
serverId: "a",
|
||||
label: "Alpha",
|
||||
password: "secret",
|
||||
appearance: { color: "none" as const, badgeDisplay: null },
|
||||
connections: [
|
||||
{ id: "one", type: "directTcp" as const, endpoint: "localhost:6767", useTls: false },
|
||||
{ id: "two", type: "directSocket" as const, path: "/tmp/test" },
|
||||
],
|
||||
lifecycle: {},
|
||||
preferredConnectionId: null,
|
||||
createdAt: "today",
|
||||
updatedAt: "today",
|
||||
},
|
||||
{
|
||||
serverId: "b",
|
||||
label: "Beta",
|
||||
password: "secret",
|
||||
appearance: { color: "none" as const, badgeDisplay: null },
|
||||
connections: [],
|
||||
lifecycle: {},
|
||||
preferredConnectionId: null,
|
||||
createdAt: "today",
|
||||
updatedAt: "today",
|
||||
},
|
||||
];
|
||||
const snapshots = new Map<string, NonNullable<ReturnType<PluginHostsSource["getSnapshot"]>>>();
|
||||
const snapshots = new Map<
|
||||
string,
|
||||
{ connectionStatus: "online" | "offline" | "connecting"; client: DaemonClient | null }
|
||||
>();
|
||||
const listeners = new Set<() => void>();
|
||||
const source: PluginHostsSource = {
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => hosts,
|
||||
getSnapshot: (id) => snapshots.get(id) ?? null,
|
||||
getSnapshot: (id) => {
|
||||
const value = snapshots.get(id);
|
||||
return value
|
||||
? { ...value, activeConnectionId: null, lastError: null, lastOnlineAt: null }
|
||||
: null;
|
||||
},
|
||||
renameHost: async (id, label) => {
|
||||
hosts.find((candidate) => candidate.serverId === id)!.label = label;
|
||||
},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async (id) => {
|
||||
const index = hosts.findIndex((host) => host.serverId === id);
|
||||
if (index >= 0) hosts.splice(index, 1);
|
||||
for (const l of listeners) l();
|
||||
},
|
||||
removeConnection: async (id, connectionId) => {
|
||||
const host = hosts.find((candidate) => candidate.serverId === id)!;
|
||||
host.connections = host.connections.filter((c) => c.id !== connectionId);
|
||||
if (!host.connections.length) await source.removeHost(id);
|
||||
else for (const l of listeners) l();
|
||||
},
|
||||
subscribeAll(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
@@ -26,7 +75,10 @@ function registry() {
|
||||
},
|
||||
};
|
||||
const lifetime = new AbortController();
|
||||
const runtime = createPluginHosts(source, lifetime.signal);
|
||||
const runtime = createPluginHosts(source, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
return {
|
||||
hosts,
|
||||
snapshots,
|
||||
@@ -48,7 +100,7 @@ function connection(id: string) {
|
||||
describe("plugin host access", () => {
|
||||
it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => {
|
||||
const h = registry();
|
||||
expect(h.runtime.getSnapshot()).toEqual([
|
||||
expect(h.runtime.getSnapshot()).toMatchObject([
|
||||
{ serverId: "a", label: "Alpha", status: "offline" },
|
||||
{ serverId: "b", label: "Beta", status: "offline" },
|
||||
]);
|
||||
@@ -68,6 +120,171 @@ describe("plugin host access", () => {
|
||||
expect(h.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps handles stable, protects credentials, and stops observations on unload", async () => {
|
||||
const h = registry();
|
||||
const seen: unknown[] = [];
|
||||
const stop = h.runtime.observeHosts((hosts) => seen.push(hosts));
|
||||
const first = h.runtime.getSnapshot()[0];
|
||||
function walk(value: unknown): void {
|
||||
if (typeof value === "string") expect(value).not.toContain("secret");
|
||||
if (!value || typeof value !== "object") return;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
expect(key.toLowerCase()).not.toBe("password");
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
walk(h.runtime.getSnapshot());
|
||||
h.hosts[0].label = "New name";
|
||||
h.publish();
|
||||
expect(h.runtime.getSnapshot()[0]).toBe(first);
|
||||
expect(first.label).toBe("New name");
|
||||
expect(seen).toHaveLength(2);
|
||||
h.lifetime.abort();
|
||||
h.publish();
|
||||
expect(seen).toHaveLength(2);
|
||||
await expect(first.remove()).rejects.toThrow("Plugin has stopped");
|
||||
stop();
|
||||
});
|
||||
|
||||
it("redacts saved passwords from mutation errors", async () => {
|
||||
const h = registry();
|
||||
h.source.renameHost = async () => {
|
||||
throw new Error("Failed for secret");
|
||||
};
|
||||
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow("secret");
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("isolates a throwing observer from other observers and registry mutations", async () => {
|
||||
const h = registry();
|
||||
let calls = 0;
|
||||
h.runtime.observeHosts(() => {
|
||||
if (calls++ > 0) throw new Error("plugin listener failed");
|
||||
});
|
||||
let delivered = 0;
|
||||
h.runtime.observeHosts(() => {
|
||||
delivered++;
|
||||
});
|
||||
await expect(h.runtime.getSnapshot()[0].removeConnection("one")).resolves.toBeUndefined();
|
||||
expect(delivered).toBe(2);
|
||||
expect(h.runtime.getSnapshot()[0].connections).toHaveLength(1);
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("refuses to remove the last connection of the desktop daemon", async () => {
|
||||
const h = registry();
|
||||
const lifetime = new AbortController();
|
||||
const runtime = createPluginHosts(h.source, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async (id) => id === "a",
|
||||
});
|
||||
const host = runtime.getSnapshot()[0];
|
||||
await host.removeConnection("one");
|
||||
await expect(host.removeConnection("two")).rejects.toThrow("desktop daemon");
|
||||
expect(host.connections).toHaveLength(1);
|
||||
lifetime.abort();
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("returns the projected handle after adding a host", async () => {
|
||||
const h = registry();
|
||||
h.source.addHost = async () => ({ status: "saved", serverId: "a" });
|
||||
const result = await h.runtime.addHost({ connection: "tcp://example:6767?password=secret" });
|
||||
expect(result.status).toBe("saved");
|
||||
if (result.status !== "saved") throw new Error("expected saved host");
|
||||
expect(result.host).toBe(h.runtime.getSnapshot()[0]);
|
||||
expect(JSON.stringify(result)).not.toContain("secret");
|
||||
h.lifetime.abort();
|
||||
await expect(h.runtime.addHost({ connection: "example:6767" })).rejects.toThrow(
|
||||
"Plugin has stopped",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes cancellation and authentication results without exposing credentials", async () => {
|
||||
const h = registry();
|
||||
for (const result of [
|
||||
{ status: "cancelled" as const },
|
||||
{ status: "password_required" as const, reason: "incorrect_password" as const },
|
||||
]) {
|
||||
h.source.addHost = async () => result;
|
||||
const returned = await h.runtime.addHost({
|
||||
connection: "tcp://example:6767?password=secret",
|
||||
});
|
||||
expect(returned).toEqual(result);
|
||||
for (const [key, value] of Object.entries(returned)) {
|
||||
expect(key).not.toBe("password");
|
||||
expect(value).not.toBe("secret");
|
||||
}
|
||||
}
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("redacts encoded saved passwords in mutation errors", async () => {
|
||||
const h = registry();
|
||||
h.hosts[0].password = "two words";
|
||||
h.source.renameHost = async () => {
|
||||
throw new Error("failed for two%20words");
|
||||
};
|
||||
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow(
|
||||
"two%20words",
|
||||
);
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("never exposes supplied passwords in add errors", async () => {
|
||||
const h = registry();
|
||||
h.source.addHost = async () => {
|
||||
throw new Error("secret was rejected");
|
||||
};
|
||||
await expect(
|
||||
h.runtime.addHost({ connection: "tcp://example:6767?password=secret" }),
|
||||
).rejects.not.toThrow("secret");
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("removes a connection and then the last host connection", async () => {
|
||||
const h = registry();
|
||||
const host = h.runtime.getSnapshot()[0];
|
||||
await host.removeConnection("one");
|
||||
expect(host.connections.map((item) => item.id)).toEqual(["two"]);
|
||||
await host.removeConnection("two");
|
||||
expect(h.runtime.getSnapshot().map((entry) => entry.serverId)).toEqual(["b"]);
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("rejects removal of the managed desktop daemon", async () => {
|
||||
const h = registry();
|
||||
const lifetime = new AbortController();
|
||||
const runtime = createPluginHosts(h.source, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async (id) => id === "a",
|
||||
});
|
||||
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
|
||||
expect(runtime.getSnapshot()).toHaveLength(2);
|
||||
lifetime.abort();
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("protects the desktop host after its daemon has stopped", async () => {
|
||||
const h = registry();
|
||||
const lifetime = new AbortController();
|
||||
const runtime = createPluginHosts(h.source, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async (id) =>
|
||||
id ===
|
||||
(await resolveDesktopDaemonServerId({
|
||||
getStatus: async () => {
|
||||
throw new Error("daemon stopped");
|
||||
},
|
||||
getSavedId: async () => "a",
|
||||
})),
|
||||
});
|
||||
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
|
||||
expect(runtime.getSnapshot()).toHaveLength(2);
|
||||
lifetime.abort();
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("rejects unknown and disconnected targets without falling through to another host", () => {
|
||||
const h = registry();
|
||||
h.snapshots.set("a", { connectionStatus: "online", client: connection("a") });
|
||||
@@ -82,7 +299,10 @@ describe("plugin host access", () => {
|
||||
h.snapshots.set("b", { connectionStatus: "online", client });
|
||||
h.publish();
|
||||
const otherLifetime = new AbortController();
|
||||
const other = createPluginHosts(h.source, otherLifetime.signal);
|
||||
const other = createPluginHosts(h.source, otherLifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const api = h.runtime.getPaseoClient("b");
|
||||
expect(api).toBe(h.runtime.getPaseoClient("b"));
|
||||
expect(api).not.toBe(other.getPaseoClient("b"));
|
||||
|
||||
@@ -1,31 +1,288 @@
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginHostSummary } from "@getpaseo/plugin/client";
|
||||
import type {
|
||||
PluginHost,
|
||||
PluginHostConnection,
|
||||
PluginHostPatch,
|
||||
PluginHostAddInput,
|
||||
PluginHostAddResult,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import {
|
||||
parseHostPort,
|
||||
serializeConnectionUri,
|
||||
serializeRelayConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@getpaseo/protocol/daemon-endpoints";
|
||||
import { parseHostTarget, serializeSocketHostTarget } from "@getpaseo/protocol/host-target";
|
||||
import {
|
||||
DEFAULT_SSH_DAEMON_PORT,
|
||||
serializeSshTransportUri,
|
||||
} from "@getpaseo/protocol/ssh-transport";
|
||||
|
||||
type HostSnapshot = Pick<
|
||||
HostRuntimeSnapshot,
|
||||
| "connectionStatus"
|
||||
| "client"
|
||||
| "activeConnectionId"
|
||||
| "lastError"
|
||||
| "authFailureReason"
|
||||
| "lastOnlineAt"
|
||||
>;
|
||||
export interface PluginHostsSource {
|
||||
getHosts(): readonly { serverId: string; label: string }[];
|
||||
getSnapshot(serverId: string): {
|
||||
connectionStatus: PluginHostSummary["status"];
|
||||
client: DaemonClient | null;
|
||||
} | null;
|
||||
addHost(
|
||||
input: PluginHostAddInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
| { status: "saved"; serverId: string }
|
||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
||||
| { status: "cancelled" }
|
||||
>;
|
||||
getHosts(): readonly HostProfile[];
|
||||
getSnapshot(serverId: string): HostSnapshot | null;
|
||||
subscribeAll(listener: () => void): () => void;
|
||||
subscribeHostList(listener: () => void): () => void;
|
||||
renameHost(serverId: string, label: string): Promise<void>;
|
||||
setHostColor(serverId: string, color: PluginHost["appearance"]["color"]): Promise<void>;
|
||||
setHostBadgeDisplay(
|
||||
serverId: string,
|
||||
badge: PluginHost["appearance"]["badgeDisplay"],
|
||||
): Promise<void>;
|
||||
removeHost(serverId: string): Promise<void>;
|
||||
removeConnection(serverId: string, connectionId: string): Promise<void>;
|
||||
}
|
||||
function redactSecret(message: string, secret: string | undefined): string {
|
||||
if (!secret) return message;
|
||||
for (const value of [
|
||||
secret,
|
||||
encodeURIComponent(secret),
|
||||
new URLSearchParams({ password: secret }).toString().slice(9),
|
||||
]) {
|
||||
message = message.split(value).join("[redacted]");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
type ProjectedHost = Omit<PluginHost, "update" | "remove" | "removeConnection" | "openSettings">;
|
||||
function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): ProjectedHost {
|
||||
const connections: PluginHostConnection[] = profile.connections.map((connection) => {
|
||||
switch (connection.type) {
|
||||
case "directTcp":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
endpoint: connection.endpoint,
|
||||
useTls: connection.useTls ?? false,
|
||||
uri: serializeConnectionUri({
|
||||
...parseHostPort(connection.endpoint),
|
||||
useTls: connection.useTls ?? false,
|
||||
}),
|
||||
};
|
||||
case "directSocket":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
path: connection.path,
|
||||
uri: serializeSocketHostTarget("unix", connection.path),
|
||||
};
|
||||
case "directPipe":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
path: connection.path,
|
||||
uri: serializeSocketHostTarget("pipe", connection.path),
|
||||
};
|
||||
case "remoteSsh":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
host: connection.host,
|
||||
...(connection.sshPort === undefined ? {} : { sshPort: connection.sshPort }),
|
||||
...(connection.daemonPort === undefined ? {} : { daemonPort: connection.daemonPort }),
|
||||
uri: serializeSshTransportUri({
|
||||
host: connection.host,
|
||||
sshPort: connection.sshPort,
|
||||
daemonPort: connection.daemonPort ?? DEFAULT_SSH_DAEMON_PORT,
|
||||
}),
|
||||
};
|
||||
case "relay": {
|
||||
const useTls =
|
||||
connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint);
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
useTls,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
...(connection.minimumSecurityMode
|
||||
? { minimumSecurityMode: connection.minimumSecurityMode }
|
||||
: {}),
|
||||
uri: serializeRelayConnectionUri({
|
||||
offer: {
|
||||
v: 2,
|
||||
serverId: profile.serverId,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
relay: { endpoint: connection.relayEndpoint, useTls },
|
||||
...(connection.minimumSecurityMode
|
||||
? { minimumSecurityMode: connection.minimumSecurityMode }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "routed":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
originServerId: connection.originServerId,
|
||||
uri: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
const error = snapshot?.lastError ?? null;
|
||||
return {
|
||||
serverId: profile.serverId,
|
||||
label: profile.label,
|
||||
status: snapshot?.connectionStatus ?? "offline",
|
||||
appearance: { color: profile.appearance.color, badgeDisplay: profile.appearance.badgeDisplay },
|
||||
connections,
|
||||
activeConnectionId: snapshot?.activeConnectionId ?? null,
|
||||
error: error ? redactSecret(error, profile.password) : null,
|
||||
authFailureReason: snapshot?.authFailureReason ?? null,
|
||||
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
|
||||
createdAt: profile.createdAt,
|
||||
updatedAt: profile.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */
|
||||
export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) {
|
||||
export function createPluginHosts(
|
||||
source: PluginHostsSource,
|
||||
signal: AbortSignal,
|
||||
options: {
|
||||
openSettings(serverId: string): void;
|
||||
isManagedLocalHost(serverId: string): Promise<boolean>;
|
||||
},
|
||||
) {
|
||||
const clients = new Map<
|
||||
string,
|
||||
{ client: DaemonClient; api: PaseoApi; lifetime: AbortController }
|
||||
>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: readonly PluginHostSummary[] = [];
|
||||
function readHosts(): readonly PluginHostSummary[] {
|
||||
return source.getHosts().map(({ serverId, label }) => ({
|
||||
serverId,
|
||||
label,
|
||||
status: source.getSnapshot(serverId)?.connectionStatus ?? "offline",
|
||||
}));
|
||||
let snapshot: readonly PluginHost[] = [];
|
||||
let serialized = "";
|
||||
const entries = new Map<string, { data: ProjectedHost; handle: PluginHost }>();
|
||||
function assertHost(id: string) {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
if (!source.getHosts().some((host) => host.serverId === id))
|
||||
throw new Error(`Unknown Paseo host: ${id}`);
|
||||
}
|
||||
async function perform(id: string, action: () => Promise<void>): Promise<void> {
|
||||
const secret = source.getHosts().find((host) => host.serverId === id)?.password;
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// A cause would expose the original error, including any password.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error(redactSecret(message, secret));
|
||||
}
|
||||
}
|
||||
function readHosts(): { hosts: PluginHost[]; key: string } {
|
||||
const projected = source
|
||||
.getHosts()
|
||||
.map((profile) => projectHost(profile, source.getSnapshot(profile.serverId)));
|
||||
const ids = new Set(projected.map((host) => host.serverId));
|
||||
for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id);
|
||||
const hosts = projected.map((data) => {
|
||||
let entry = entries.get(data.serverId);
|
||||
if (!entry) {
|
||||
const id = data.serverId;
|
||||
const read = () => entry!.data;
|
||||
const handle: PluginHost = {
|
||||
get serverId() {
|
||||
return read().serverId;
|
||||
},
|
||||
get label() {
|
||||
return read().label;
|
||||
},
|
||||
get status() {
|
||||
return read().status;
|
||||
},
|
||||
get appearance() {
|
||||
return read().appearance;
|
||||
},
|
||||
get connections() {
|
||||
return read().connections;
|
||||
},
|
||||
get activeConnectionId() {
|
||||
return read().activeConnectionId;
|
||||
},
|
||||
get error() {
|
||||
return read().error;
|
||||
},
|
||||
get authFailureReason() {
|
||||
return read().authFailureReason;
|
||||
},
|
||||
get lastOnlineAt() {
|
||||
return read().lastOnlineAt;
|
||||
},
|
||||
get createdAt() {
|
||||
return read().createdAt;
|
||||
},
|
||||
get updatedAt() {
|
||||
return read().updatedAt;
|
||||
},
|
||||
async update(patch: PluginHostPatch) {
|
||||
await perform(id, async () => {
|
||||
assertHost(id);
|
||||
if (patch.label !== undefined) await source.renameHost(id, patch.label);
|
||||
if (patch.appearance?.color !== undefined) {
|
||||
assertHost(id);
|
||||
await source.setHostColor(id, patch.appearance.color);
|
||||
}
|
||||
if (patch.appearance?.badgeDisplay !== undefined) {
|
||||
assertHost(id);
|
||||
await source.setHostBadgeDisplay(id, patch.appearance.badgeDisplay);
|
||||
}
|
||||
});
|
||||
},
|
||||
async remove() {
|
||||
await perform(id, async () => {
|
||||
assertHost(id);
|
||||
if (await options.isManagedLocalHost(id))
|
||||
throw new Error("Cannot remove the desktop daemon host");
|
||||
assertHost(id);
|
||||
await source.removeHost(id);
|
||||
});
|
||||
},
|
||||
async removeConnection(connectionId) {
|
||||
await perform(id, async () => {
|
||||
assertHost(id);
|
||||
const isManaged = await options.isManagedLocalHost(id);
|
||||
assertHost(id);
|
||||
const host = source.getHosts().find((candidate) => candidate.serverId === id)!;
|
||||
if (
|
||||
isManaged &&
|
||||
host.connections.length === 1 &&
|
||||
host.connections[0].id === connectionId
|
||||
) {
|
||||
throw new Error("Cannot remove the desktop daemon host");
|
||||
}
|
||||
await source.removeConnection(id, connectionId);
|
||||
});
|
||||
},
|
||||
openSettings() {
|
||||
assertHost(id);
|
||||
options.openSettings(id);
|
||||
},
|
||||
};
|
||||
entry = { data, handle };
|
||||
entries.set(id, entry);
|
||||
} else entry.data = data;
|
||||
return entry.handle;
|
||||
});
|
||||
return { hosts, key: JSON.stringify(projected) };
|
||||
}
|
||||
function resolve(serverId: string): DaemonClient {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
@@ -49,11 +306,16 @@ export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal
|
||||
}
|
||||
}
|
||||
const next = readHosts();
|
||||
if (JSON.stringify(snapshot) === JSON.stringify(next)) return;
|
||||
snapshot = next;
|
||||
if (serialized === next.key) return;
|
||||
serialized = next.key;
|
||||
snapshot = next.hosts;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
snapshot = readHosts();
|
||||
{
|
||||
const initial = readHosts();
|
||||
snapshot = initial.hosts;
|
||||
serialized = initial.key;
|
||||
}
|
||||
const unsubscribeRuntime = source.subscribeAll(refresh);
|
||||
const unsubscribeHosts = source.subscribeHostList(refresh);
|
||||
function stop() {
|
||||
@@ -67,6 +329,46 @@ export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal
|
||||
else signal.addEventListener("abort", stop, { once: true });
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
async addHost(input: PluginHostAddInput): Promise<PluginHostAddResult> {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
let result: Awaited<ReturnType<PluginHostsSource["addHost"]>>;
|
||||
try {
|
||||
result = await source.addHost(input, signal);
|
||||
} catch (error) {
|
||||
let secret: string | undefined;
|
||||
try {
|
||||
secret = parseHostTarget(input.connection).password;
|
||||
} catch {
|
||||
/* Invalid input has no parsed password. */
|
||||
}
|
||||
const message = redactSecret(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
secret,
|
||||
);
|
||||
// The original error can contain the supplied credential.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error(message);
|
||||
}
|
||||
if (result.status !== "saved") return result;
|
||||
const host = snapshot.find((item) => item.serverId === result.serverId);
|
||||
if (!host) throw new Error(`Unknown Paseo host: ${result.serverId}`);
|
||||
return { status: "saved", host };
|
||||
},
|
||||
observeHosts(listener: (hosts: readonly PluginHost[]) => void) {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
const notify = () => {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch (error) {
|
||||
console.error("[Plugins] Host observer failed", error);
|
||||
}
|
||||
};
|
||||
listeners.add(notify);
|
||||
notify();
|
||||
return () => {
|
||||
listeners.delete(notify);
|
||||
};
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
if (signal.aborted) return () => {};
|
||||
listeners.add(listener);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { buildPluginSettingsRoute } from "./settings/routes";
|
||||
import { router } from "expo-router";
|
||||
import type { PluginPanelLocation } from "@getpaseo/plugin/client";
|
||||
@@ -9,7 +10,7 @@ import type { PluginNavigation } from "./actions";
|
||||
export function createPluginNavigation(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | null;
|
||||
}): PluginNavigation {
|
||||
}): PluginNavigation & { openHostSettings(serverId: string): void } {
|
||||
const { serverId, workspaceId } = input;
|
||||
function placement(location: PluginPanelLocation) {
|
||||
if (location !== "explorer") return undefined;
|
||||
@@ -20,6 +21,9 @@ export function createPluginNavigation(input: {
|
||||
return { mode: "pane" as const, paneId };
|
||||
}
|
||||
return {
|
||||
openHostSettings(hostId) {
|
||||
router.push(buildSettingsHostRoute(hostId));
|
||||
},
|
||||
openSettings(pluginId, screenId) {
|
||||
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
|
||||
},
|
||||
|
||||
@@ -43,12 +43,19 @@ function registry(
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
renameHost: async () => {},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async () => {},
|
||||
removeConnection: async () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
|
||||
@@ -18,12 +18,19 @@ function registry(version: string) {
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
renameHost: async () => {},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async () => {},
|
||||
removeConnection: async () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { deriveProjectPlacementFromCwd } from "@/utils/project-placement";
|
||||
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
|
||||
import { useWorkspaceLabels } from "@/workspace-labels";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
@@ -41,12 +42,15 @@ class FakeDirectoryClient {
|
||||
supportsWorkspaceLabels = false;
|
||||
listWorkspaceLabelsCalls = 0;
|
||||
fetchAgentsCalls = 0;
|
||||
observeAgentsCalls = 0;
|
||||
lastAgentOptions: unknown;
|
||||
fetchWorkspacesCalls = 0;
|
||||
observeWorkspacesCalls = 0;
|
||||
lastWorkspaceOptions: unknown;
|
||||
listProjectsCalls = 0;
|
||||
lastProjectOptions: unknown;
|
||||
projectResult: ProjectListResult | null = null;
|
||||
readonly listSnapshots: Promise<unknown>[] = [];
|
||||
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
|
||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
|
||||
@@ -151,15 +155,22 @@ class FakeDirectoryClient {
|
||||
}
|
||||
|
||||
observeAgents(options: Parameters<DaemonClient["observeAgents"]>[0]) {
|
||||
return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) =>
|
||||
this.on("agent_update", receive),
|
||||
this.observeAgentsCalls += 1;
|
||||
const subscription = subscriptionFixture(
|
||||
this.fetchAgents({ ...options, subscribe: {} }),
|
||||
(receive) => this.on("agent_update", receive),
|
||||
);
|
||||
this.listSnapshots.push(subscription.ready);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
observeWorkspaces(options: Parameters<DaemonClient["observeWorkspaces"]>[0]) {
|
||||
return subscriptionFixture(this.fetchWorkspaces(options), (receive) =>
|
||||
this.observeWorkspacesCalls += 1;
|
||||
const subscription = subscriptionFixture(this.fetchWorkspaces(options), (receive) =>
|
||||
this.on("workspace_update", receive),
|
||||
);
|
||||
this.listSnapshots.push(subscription.ready);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
observeEvents(events: readonly SessionOutboundMessage["type"][]) {
|
||||
@@ -592,6 +603,83 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["full", "before"],
|
||||
["full", "after"],
|
||||
["route", "before"],
|
||||
["route", "after"],
|
||||
] as const)(
|
||||
"restores live directory updates when %s demand returns %s refresh completion",
|
||||
async (demand, timing) => {
|
||||
const serverId = `released-directory-${demand}-${timing}`;
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, projectList: true },
|
||||
});
|
||||
const source = {};
|
||||
function setDemand(demanded: boolean): void {
|
||||
if (demand === "full") directory.setDemand(source, demanded);
|
||||
else directory.setAgentRouteDemand(demanded ? ["agent-1"] : []);
|
||||
}
|
||||
const releaseProjects = client.holdProjectFetch();
|
||||
const projects: ProjectListResult = { requestId: "projects", projects: [] };
|
||||
|
||||
try {
|
||||
setDemand(true);
|
||||
const refresh = directory.refreshDemand();
|
||||
await expect
|
||||
.poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls])
|
||||
.toEqual([1, 1]);
|
||||
|
||||
await Promise.all(client.listSnapshots);
|
||||
setDemand(false);
|
||||
if (timing === "before") setDemand(true);
|
||||
releaseProjects(projects);
|
||||
await refresh;
|
||||
if (timing === "after") setDemand(true);
|
||||
|
||||
await expect
|
||||
.poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls])
|
||||
.toEqual([2, 2]);
|
||||
client.emit({
|
||||
type: "agent_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
agent: {
|
||||
...projectAgentSnapshot(createAgent(serverId, "agent-1")),
|
||||
title: "Live title",
|
||||
},
|
||||
project: deriveProjectPlacementFromCwd("/repo"),
|
||||
},
|
||||
});
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: { ...createWorkspaceEntry("workspace-1"), name: "Live workspace" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
return [
|
||||
session.agents.get("agent-1")?.title,
|
||||
session.workspaces.get("workspace-1")?.name,
|
||||
];
|
||||
})
|
||||
.toEqual(["Live title", "Live workspace"]);
|
||||
} finally {
|
||||
directory.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("publishes cached directory data before the authoritative request completes", async () => {
|
||||
const serverId = "cached-directory";
|
||||
serverIds.add(serverId);
|
||||
|
||||
@@ -160,6 +160,7 @@ export class DirectorySync {
|
||||
private readonly fullDemandSources = new Set<object>();
|
||||
private readonly scriptStatusDemand = new Map<object, string>();
|
||||
private demandRefresh: Promise<void> | null = null;
|
||||
private subscriptionGeneration = 0;
|
||||
private satisfiedDemandSource: DirectorySourceToken | null = null;
|
||||
private cursors: DirectoryCheckpoint = {};
|
||||
|
||||
@@ -262,6 +263,7 @@ export class DirectorySync {
|
||||
}
|
||||
|
||||
private releaseSubscriptions(): void {
|
||||
this.subscriptionGeneration += 1;
|
||||
for (const subscription of [
|
||||
this.agentSubscription,
|
||||
this.workspaceSubscription,
|
||||
@@ -362,6 +364,7 @@ export class DirectorySync {
|
||||
this.observeDirectoryEvents();
|
||||
this.observeScriptStatusEvents();
|
||||
const source = this.connection.source;
|
||||
const subscriptionGeneration = this.subscriptionGeneration;
|
||||
if (
|
||||
!force &&
|
||||
this.satisfiedDemandSource?.clientGeneration === source.clientGeneration &&
|
||||
@@ -378,17 +381,19 @@ export class DirectorySync {
|
||||
]).then(() => undefined);
|
||||
this.demandRefresh = refresh
|
||||
.then(() => {
|
||||
this.satisfiedDemandSource = source;
|
||||
if (this.subscriptionGeneration === subscriptionGeneration) {
|
||||
this.satisfiedDemandSource = source;
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
this.demandRefresh = null;
|
||||
const current = this.connection.source;
|
||||
if (
|
||||
this.hasDemand() &&
|
||||
(current.clientGeneration !== source.clientGeneration ||
|
||||
current.connectionEpoch !== source.connectionEpoch)
|
||||
) {
|
||||
const sourceChanged =
|
||||
current.clientGeneration !== source.clientGeneration ||
|
||||
current.connectionEpoch !== source.connectionEpoch;
|
||||
const subscriptionsReleased = this.subscriptionGeneration !== subscriptionGeneration;
|
||||
if (this.hasDemand() && (sourceChanged || subscriptionsReleased)) {
|
||||
void this.requestDemandRefresh().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -55,24 +55,38 @@ export class HostConfirmations {
|
||||
}
|
||||
|
||||
/** Resolves true without asking when the link matches a saved host exactly. */
|
||||
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
|
||||
confirmLink(
|
||||
offer: ConnectionOffer,
|
||||
savedHosts: HostProfile[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
|
||||
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
|
||||
return this.ask({
|
||||
id: this.nextRequestId++,
|
||||
serverId: offer.serverId,
|
||||
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
kind: savedHost ? "changedConnection" : "newHost",
|
||||
});
|
||||
if (signal?.aborted) return Promise.resolve(false);
|
||||
return this.ask(
|
||||
{
|
||||
id: this.nextRequestId++,
|
||||
serverId: offer.serverId,
|
||||
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
kind: savedHost ? "changedConnection" : "newHost",
|
||||
},
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
private ask(request: HostConfirmationRequest): Promise<boolean> {
|
||||
private ask(request: HostConfirmationRequest, signal?: AbortSignal): Promise<boolean> {
|
||||
// One question at a time: a newer one cancels the unanswered one.
|
||||
this.pending?.resolve(false);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
this.pending = { request, resolve };
|
||||
const cancel = () => this.answer(request.id, false);
|
||||
const finish = (approved: boolean) => {
|
||||
signal?.removeEventListener("abort", cancel);
|
||||
resolve(approved);
|
||||
};
|
||||
this.pending = { request, resolve: finish };
|
||||
signal?.addEventListener("abort", cancel, { once: true });
|
||||
this.emit();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
import { defaultHostAppearance, selectHostBadges } from "@/hosts/appearance";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||
@@ -33,6 +33,7 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||
import { subscriptionFixture } from "./subscription-fixture";
|
||||
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||
import type { HostConfirmationRequest } from "./host-confirmation";
|
||||
import { createPluginHosts } from "@/plugins/hosts";
|
||||
|
||||
it("requests the managed connection credential through desktop main without a web hint", async () => {
|
||||
const requests: string[] = [];
|
||||
@@ -4778,3 +4779,295 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding hosts from plugin connection targets", () => {
|
||||
it("does not save an unauthenticated target and retries with the password", async () => {
|
||||
const store = createPairingStore({ password: "secret", serverIdForHost: () => "srv_direct" });
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
expect(await store.addHost({ connection: "example.com:6767" })).toEqual({
|
||||
status: "password_required",
|
||||
reason: "password_required",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
expect(
|
||||
await store.addHost({ connection: "tcp://example.com:6767?password=secret", label: "Build" }),
|
||||
).toEqual({ status: "saved", serverId: "srv_direct" });
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
label: "Build",
|
||||
password: "secret",
|
||||
connections: [{ type: "directTcp", endpoint: "example.com:6767" }],
|
||||
});
|
||||
expect(requests).toHaveLength(0);
|
||||
await waitForHostOnline(store, "srv_direct");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("persists the supplied password and reconnects after the app restarts", async () => {
|
||||
const storage = createMemoryHostRuntimeStorage({ "@paseo:e2e": "1" });
|
||||
const first = createPairingStore({
|
||||
storage,
|
||||
password: "secret",
|
||||
serverIdForHost: () => "srv_direct",
|
||||
});
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(first, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const result = await plugin.addHost({ connection: "tcp://example.com:6767?password=secret" });
|
||||
expect(result.status).toBe("saved");
|
||||
expect(JSON.stringify(result)).not.toContain("secret");
|
||||
const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
|
||||
expect(persisted[0].password).toBe("secret");
|
||||
lifetime.abort();
|
||||
first.syncHosts([]);
|
||||
|
||||
const attempts: string[] = [];
|
||||
const restarted = createPairingStore({
|
||||
storage,
|
||||
password: "secret",
|
||||
connectAttempts: attempts,
|
||||
serverIdForHost: () => "srv_direct",
|
||||
});
|
||||
await restarted.boot();
|
||||
await waitForHostOnline(restarted, "srv_direct");
|
||||
expect(attempts).toContain("direct:example.com:6767");
|
||||
expect(restarted.getHosts()[0].password).toBe("secret");
|
||||
const restartedLifetime = new AbortController();
|
||||
const restartedPlugin = createPluginHosts(restarted, restartedLifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
expect(JSON.stringify(restartedPlugin.getSnapshot())).not.toContain("secret");
|
||||
restartedLifetime.abort();
|
||||
restarted.syncHosts([]);
|
||||
});
|
||||
|
||||
it("asks before saving a relay target and respects rejection", async () => {
|
||||
const uri = "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true";
|
||||
const rejected = createPairingStore();
|
||||
const requests = answerHostConfirmations(rejected, false);
|
||||
expect(await rejected.addHost({ connection: uri })).toEqual({ status: "cancelled" });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(rejected.getHosts()).toHaveLength(0);
|
||||
const accepted = createPairingStore();
|
||||
const acceptedRequests = answerHostConfirmations(accepted, true);
|
||||
expect(await accepted.addHost({ connection: uri, label: "Relay" })).toEqual({
|
||||
status: "saved",
|
||||
serverId: "srv_offer",
|
||||
});
|
||||
expect(acceptedRequests).toHaveLength(1);
|
||||
expect(accepted.getHosts()[0]).toMatchObject({
|
||||
label: "Relay",
|
||||
connections: [{ type: "relay" }],
|
||||
});
|
||||
accepted.syncHosts([]);
|
||||
});
|
||||
it.each([
|
||||
["ssh://deploy@build:2222?daemonPort=7777", "remoteSsh"],
|
||||
["unix:///tmp/paseo.sock", "directSocket"],
|
||||
["pipe://mypipe", "directPipe"],
|
||||
["wss://example.com/ws", "directTcp"],
|
||||
])("probes and saves %s", async (connection, type) => {
|
||||
const store = createPairingStore({ serverIdForHost: () => "srv_target" });
|
||||
expect(await store.addHost({ connection })).toEqual({
|
||||
status: "saved",
|
||||
serverId: "srv_target",
|
||||
});
|
||||
expect(store.getHosts()[0].connections[0].type).toBe(type);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("shares host changes between plugin handles, host settings, and sidebar badges", async () => {
|
||||
const store = createPairingStore({
|
||||
serverIdForHost: (host) => {
|
||||
const connection = host.connections[0];
|
||||
return connection?.type === "directTcp" ? connection.endpoint : "";
|
||||
},
|
||||
});
|
||||
await store.addHost({ connection: "first:6767", label: "First" });
|
||||
await store.addHost({ connection: "second:6767", label: "Second" });
|
||||
await waitForHostOnline(store, "first:6767");
|
||||
await waitForHostOnline(store, "second:6767");
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
expect(
|
||||
plugin.getSnapshot().map((host) => [host.label, host.status, host.connections[0].uri]),
|
||||
).toEqual([
|
||||
["First", "online", "tcp://first:6767"],
|
||||
["Second", "online", "tcp://second:6767"],
|
||||
]);
|
||||
await plugin.getSnapshot()[0].update({ label: "Build", appearance: { color: "sky" } });
|
||||
expect(store.getHosts()[0]).toMatchObject({ label: "Build", appearance: { color: "sky" } });
|
||||
expect(
|
||||
selectHostBadges({ hosts: store.getHosts(), localServerId: null, enabled: true }).get(
|
||||
"first:6767",
|
||||
),
|
||||
).toMatchObject({ label: "Build", color: "sky" });
|
||||
lifetime.abort();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"tcp://example.com:6767?ssl=true",
|
||||
"ssh://deploy@build:2222?daemonPort=7777",
|
||||
"unix:///tmp/paseo.sock",
|
||||
"relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
|
||||
])("recreates the projected connection on another device: %s", async (connection) => {
|
||||
const first = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
|
||||
answerHostConfirmations(first, true);
|
||||
const result = await first.addHost({ connection });
|
||||
if (result.status !== "saved") throw new Error("expected saved host");
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(first, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const uri = plugin.getSnapshot().find((host) => host.serverId === result.serverId)
|
||||
?.connections[0].uri;
|
||||
if (!uri) throw new Error("expected connection URI");
|
||||
const second = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
|
||||
answerHostConfirmations(second, true);
|
||||
expect(await second.addHost({ connection: uri })).toEqual({
|
||||
status: "saved",
|
||||
serverId: result.serverId,
|
||||
});
|
||||
expect(second.getHosts()[0].connections).toEqual(first.getHosts()[0].connections);
|
||||
lifetime.abort();
|
||||
first.syncHosts([]);
|
||||
second.syncHosts([]);
|
||||
});
|
||||
|
||||
it("does not save an in-flight addition after its plugin stops", async () => {
|
||||
const started = createDeferred<void>();
|
||||
const probe = createDeferred<{
|
||||
client: DaemonClient;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
}>();
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
started.resolve(undefined);
|
||||
return probe.promise;
|
||||
},
|
||||
getClientId: async () => "test",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const adding = plugin.addHost({ connection: "example.com:6767" });
|
||||
await started.promise;
|
||||
lifetime.abort();
|
||||
probe.resolve({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: "srv",
|
||||
hostname: null,
|
||||
});
|
||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("reports a stopped plugin when an aborted probe rejects authentication", async () => {
|
||||
const started = createDeferred<void>();
|
||||
const probe = createDeferred<{
|
||||
client: DaemonClient;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
}>();
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
started.resolve(undefined);
|
||||
return probe.promise;
|
||||
},
|
||||
getClientId: async () => "test",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const adding = plugin.addHost({ connection: "example.com:6767" });
|
||||
await started.promise;
|
||||
lifetime.abort();
|
||||
probe.reject(new DaemonAuthenticationError("password_required"));
|
||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("dismisses an unanswered confirmation when the plugin stops", async () => {
|
||||
const store = createPairingStore();
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const pending = createDeferred<void>();
|
||||
const unsubscribe = store.subscribeHostConfirmation(() => {
|
||||
if (store.getPendingHostConfirmation()) pending.resolve(undefined);
|
||||
});
|
||||
const adding = plugin.addHost({
|
||||
connection: "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
|
||||
});
|
||||
await pending.promise;
|
||||
lifetime.abort();
|
||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
||||
expect(store.getPendingHostConfirmation()).toBeNull();
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
unsubscribe();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("returns incorrect_password without saving and redacts failed probe errors", async () => {
|
||||
let failure: "auth" | "network" = "auth";
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
if (failure === "auth") throw new DaemonAuthenticationError("incorrect_password");
|
||||
throw new Error("connection failed for secret");
|
||||
},
|
||||
getClientId: async () => "test",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
expect(await store.addHost({ connection: "tcp://example.com:6767?password=secret" })).toEqual({
|
||||
status: "password_required",
|
||||
reason: "incorrect_password",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
failure = "network";
|
||||
await expect(
|
||||
store.addHost({ connection: "tcp://example.com:6767?password=secret" }),
|
||||
).rejects.not.toThrow("secret");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("rejects websocket paths and query options the app cannot represent", async () => {
|
||||
const store = createPairingStore();
|
||||
await expect(store.addHost({ connection: "wss://example.com/custom" })).rejects.toThrow(
|
||||
"unsupported",
|
||||
);
|
||||
await expect(
|
||||
store.addHost({ connection: "wss://example.com/ws?token=secret" }),
|
||||
).rejects.toThrow("unsupported");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { watchdog } from "@/watchdog";
|
||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
|
||||
import { useSyncExternalStore, useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
@@ -1612,6 +1613,48 @@ interface AgentDirectoryRefreshInput {
|
||||
page?: FetchAgentsOptions["page"];
|
||||
}
|
||||
|
||||
function directHostTarget(target: HostTarget, input: string): HostConnection {
|
||||
let connection: HostConnection;
|
||||
if (target.kind === "ssh") {
|
||||
connection = createRemoteSshHostConnection(target.ssh);
|
||||
} else {
|
||||
if (/^wss?:\/\//.test(input.trim())) {
|
||||
const url = new URL(target.url);
|
||||
if (url.pathname !== "/ws" || url.search)
|
||||
throw new Error("WebSocket path or query is unsupported by the app");
|
||||
}
|
||||
switch (target.connection.type) {
|
||||
case "directTcp": {
|
||||
const endpoint = normalizeHostPort(target.connection.endpoint);
|
||||
connection = {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
useTls: target.connection.useTls,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "directSocket":
|
||||
connection = {
|
||||
id: `socket:${target.connection.path}`,
|
||||
type: "directSocket",
|
||||
path: target.connection.path,
|
||||
};
|
||||
break;
|
||||
case "directPipe":
|
||||
connection = {
|
||||
id: `pipe:${target.connection.path}`,
|
||||
type: "directPipe",
|
||||
path: target.connection.path,
|
||||
};
|
||||
break;
|
||||
case "relay":
|
||||
throw new Error("Unexpected relay target");
|
||||
}
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export class HostRuntimeStore {
|
||||
private controllers = new Map<string, HostRuntimeController>();
|
||||
private hostObservations = new Map<
|
||||
@@ -1964,6 +2007,66 @@ export class HostRuntimeStore {
|
||||
);
|
||||
}
|
||||
|
||||
/** Add a saved host using the shared connection grammar. */
|
||||
async addHost(
|
||||
input: {
|
||||
connection: string;
|
||||
label?: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
| { status: "saved"; serverId: string }
|
||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
||||
| { status: "cancelled" }
|
||||
> {
|
||||
const target = parseHostTarget(input.connection);
|
||||
await this.loadRegistry();
|
||||
try {
|
||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
||||
if (target.kind === "socket" && target.connection.type === "relay") {
|
||||
const approved = await this.confirmLink(target.connection.offer, signal);
|
||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
||||
if (!approved) return { status: "cancelled" };
|
||||
const result = await this.probeAndUpsertOffer(
|
||||
target.connection.offer,
|
||||
target.password,
|
||||
input.label,
|
||||
signal,
|
||||
);
|
||||
return { status: "saved", serverId: result.serverId };
|
||||
}
|
||||
const connection = directHostTarget(target, input.connection);
|
||||
const result = await this.probeAndUpsertConnection({
|
||||
connection,
|
||||
password: target.password,
|
||||
label: input.label,
|
||||
signal,
|
||||
});
|
||||
return { status: "saved", serverId: result.serverId };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
// The failed probe can include a credential; do not attach it as a cause.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error("Plugin has stopped");
|
||||
}
|
||||
const reason = getConnectionAuthFailureReason(error);
|
||||
if (reason) return { status: "password_required", reason };
|
||||
let message = error instanceof Error ? error.message : String(error);
|
||||
if (target.password) {
|
||||
for (const value of [
|
||||
target.password,
|
||||
encodeURIComponent(target.password),
|
||||
new URLSearchParams({ password: target.password }).toString().slice(9),
|
||||
]) {
|
||||
if (value) message = message.split(value).join("[redacted]");
|
||||
}
|
||||
}
|
||||
// The original error can contain a credential; do not expose it as a cause.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async upsertDirectConnection(input: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
@@ -1993,6 +2096,7 @@ export class HostRuntimeStore {
|
||||
password?: string;
|
||||
label?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
if (input.connection.type === "relay") {
|
||||
throw new Error("Cannot probe a relay connection without a server id.");
|
||||
@@ -2013,6 +2117,10 @@ export class HostRuntimeStore {
|
||||
connection: input.connection,
|
||||
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
||||
});
|
||||
if (input.signal?.aborted) {
|
||||
await client.close().catch(() => undefined);
|
||||
throw new Error("Plugin has stopped");
|
||||
}
|
||||
const profile = await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: input.label ?? hostname ?? undefined,
|
||||
@@ -2062,9 +2170,9 @@ export class HostRuntimeStore {
|
||||
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
|
||||
// confirm when it adds a new host or changes a saved host's key, relay, or
|
||||
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
|
||||
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
|
||||
private async confirmLink(offer: ConnectionOffer, signal?: AbortSignal): Promise<boolean> {
|
||||
await this.loadRegistry();
|
||||
return this.hostConfirmations.confirmLink(offer, this.hosts);
|
||||
return this.hostConfirmations.confirmLink(offer, this.hosts, signal);
|
||||
}
|
||||
|
||||
getPendingHostConfirmation(): HostConfirmationRequest | null {
|
||||
@@ -2144,6 +2252,8 @@ export class HostRuntimeStore {
|
||||
private async probeAndUpsertOffer(
|
||||
offer: ConnectionOffer,
|
||||
password: string | undefined,
|
||||
label?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const probeHost: HostProfile = {
|
||||
@@ -2159,7 +2269,12 @@ export class HostRuntimeStore {
|
||||
};
|
||||
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
|
||||
await client.close().catch(() => undefined);
|
||||
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
|
||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
||||
const profile = await this.upsertConnectionFromOffer(
|
||||
offer,
|
||||
label ?? hostname ?? undefined,
|
||||
password,
|
||||
);
|
||||
return { profile, serverId: offer.serverId, hostname };
|
||||
}
|
||||
|
||||
@@ -2221,7 +2336,10 @@ export class HostRuntimeStore {
|
||||
}));
|
||||
}
|
||||
|
||||
async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise<void> {
|
||||
async setHostBadgeDisplay(
|
||||
serverId: string,
|
||||
badgeDisplay: HostBadgeDisplay | null,
|
||||
): Promise<void> {
|
||||
await this.updateHostAppearance(serverId, (host) => ({
|
||||
...host,
|
||||
appearance: { ...host.appearance, badgeDisplay },
|
||||
@@ -3034,7 +3152,7 @@ export interface HostMutations {
|
||||
beginLinkPairing: () => LinkPairing;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay | null) => Promise<void>;
|
||||
removeHost: (serverId: string) => Promise<void>;
|
||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The table a terminal buffer measures characters with.
|
||||
*
|
||||
* The daemon answers cursor reports and replays the screen from its own buffer,
|
||||
* so the app measures with the table the host advertises. `graphemes` gives each
|
||||
* emoji cluster one two-column cell. `legacy` is what each renderer used before
|
||||
* hosts advertised grapheme widths. See docs/terminal-rendering.md.
|
||||
*/
|
||||
export type TerminalCharacterWidths = "graphemes" | "legacy";
|
||||
@@ -159,4 +159,52 @@ describe("native headless terminal state", () => {
|
||||
{ char: "B", width: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
// Apple Color Emoji and Noto Color Emoji are two cells wide. The native
|
||||
// renderer scales a cluster to its column slot, so a one-column emoji
|
||||
// renders at half size. Each emoji cluster must occupy one wide cell.
|
||||
test.each([
|
||||
["presentation emoji", "😀"],
|
||||
["thumbs up", "👍"],
|
||||
["check mark", "✅"],
|
||||
["VS16 heart", "❤️"],
|
||||
["skin-tone modifier", "👋🏽"],
|
||||
["regional-indicator flag", "🇰🇷"],
|
||||
["ZWJ family", "👨👩👧"],
|
||||
["ZWJ technologist", "👩💻"],
|
||||
])("gives a %s cluster one two-column cell", async (_name, cluster) => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 2, cols: 12 });
|
||||
|
||||
await terminal.write(`A${cluster}B`);
|
||||
const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows;
|
||||
|
||||
expect(row.slice(0, 4).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([
|
||||
{ char: "A", width: 1 },
|
||||
{ char: cluster, width: 2 },
|
||||
{ char: " ", width: 0 },
|
||||
{ char: "B", width: 1 },
|
||||
]);
|
||||
expect(terminal.getViewportState().cursor.col).toBe(4);
|
||||
});
|
||||
|
||||
// A host without grapheme widths answers cursor reports and replays with
|
||||
// xterm's default table. The native buffer matches it so the cursor lands
|
||||
// where the host put it.
|
||||
test("measures with xterm's default table for hosts without grapheme widths", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({
|
||||
rows: 2,
|
||||
cols: 12,
|
||||
characterWidths: "legacy",
|
||||
});
|
||||
|
||||
await terminal.write("A😀B");
|
||||
const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows;
|
||||
|
||||
expect(row.slice(0, 3).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([
|
||||
{ char: "A", width: 1 },
|
||||
{ char: "😀", width: 1 },
|
||||
{ char: "B", width: 1 },
|
||||
]);
|
||||
expect(terminal.getViewportState().cursor.col).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import type { TerminalCharacterWidths } from "../character-widths";
|
||||
import {
|
||||
Terminal as HeadlessTerminal,
|
||||
type IBufferCell,
|
||||
@@ -17,6 +19,8 @@ export interface NativeHeadlessTerminalOptions {
|
||||
rows: number;
|
||||
cols: number;
|
||||
scrollbackLines?: number;
|
||||
/** Defaults to `graphemes`, the table current daemons use. */
|
||||
characterWidths?: TerminalCharacterWidths;
|
||||
}
|
||||
|
||||
export interface NativeHeadlessTerminal {
|
||||
@@ -297,6 +301,12 @@ export function createNativeHeadlessTerminal(
|
||||
scrollback: options.scrollbackLines ?? 1000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
// Grapheme widths give each emoji cluster one two-column cell, the width the
|
||||
// native renderer draws it at. Legacy hosts measure with xterm's default
|
||||
// table, so their cursor reports and replay stay consistent with it.
|
||||
if ((options.characterWidths ?? "graphemes") === "graphemes") {
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
let coordinateEpoch = 0;
|
||||
const coordinateStorage = new Map<object, TerminalCoordinateStorage>();
|
||||
|
||||
@@ -90,18 +90,16 @@ describe("native terminal frame", () => {
|
||||
test("keeps combining marks and joiners inside the cell xterm assigned them", async () => {
|
||||
const { frame } = await frameFor("éx̸ 각 क्ष 👩💻");
|
||||
|
||||
expect(cellLayout(frame.rows[0]).slice(0, 11)).toEqual([
|
||||
expect(cellLayout(frame.rows[0]).slice(0, 9)).toEqual([
|
||||
{ text: "é", col: 0, span: 1 },
|
||||
{ text: "x̸", col: 1, span: 1 },
|
||||
{ text: " ", col: 2, span: 1 },
|
||||
{ text: "각", col: 3, span: 2 },
|
||||
{ text: " ", col: 5, span: 1 },
|
||||
{ text: "क्", col: 6, span: 1 },
|
||||
{ text: "ष", col: 7, span: 1 },
|
||||
{ text: " ", col: 8, span: 1 },
|
||||
{ text: "👩", col: 9, span: 1 },
|
||||
{ text: "💻", col: 10, span: 1 },
|
||||
{ text: " ", col: 11, span: 1 },
|
||||
{ text: "क्ष", col: 6, span: 1 },
|
||||
{ text: " ", col: 7, span: 1 },
|
||||
{ text: "👩💻", col: 8, span: 2 },
|
||||
{ text: " ", col: 10, span: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -237,7 +235,7 @@ test("builds native frames after replay commits at captured geometry and the lat
|
||||
expect(frame.columns).toBe(50);
|
||||
expect(frame.rows).toHaveLength(10);
|
||||
expect(frame.rows[0].text.trimEnd()).toBe("LIVE-界🙂");
|
||||
expect(frame.rows[0].styles.slice(0, 4)).toEqual([8, RED, 0, 0]);
|
||||
expect(frame.rows[0].styles.slice(0, 4)).toEqual([9, RED, 0, 0]);
|
||||
expect(encodeTerminalFrame(frame).rows[0]).toContain("LIVE-界🙂");
|
||||
} finally {
|
||||
drain.dispose();
|
||||
|
||||
@@ -717,11 +717,13 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
||||
{ name: "DA2", bytes: "\x1b[>c" },
|
||||
{ name: "DA3", bytes: "\x1b[=c" },
|
||||
{ name: "XTVERSION", bytes: "\x1b[>q" },
|
||||
{ name: "DSR-5", bytes: "\x1b[5n" },
|
||||
{ name: "DSR-6", bytes: "\x1b[6n" },
|
||||
{ name: "DSR-?6", bytes: "\x1b[?6n" },
|
||||
{ name: "DECRQM", bytes: "\x1b[1$p" },
|
||||
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
|
||||
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
|
||||
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
|
||||
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
|
||||
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
|
||||
@@ -821,6 +823,67 @@ describe("incremental replay lifecycle", () => {
|
||||
expect(mounted.inputs).toHaveLength(1);
|
||||
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
|
||||
});
|
||||
|
||||
it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
const text = "A😀B❤️C👋🏽D👨👩👧E";
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput(text), onCommitted: resolve }),
|
||||
);
|
||||
|
||||
const terminal = window.__paseoTerminal;
|
||||
if (!terminal) {
|
||||
throw new Error("Expected the browser terminal to be mounted");
|
||||
}
|
||||
const line = terminal.buffer.active.getLine(terminal.buffer.active.cursorY);
|
||||
const cells = Array.from({ length: 13 }, (_, col) => {
|
||||
const cell = line?.getCell(col);
|
||||
return { chars: cell?.getChars(), width: cell?.getWidth() };
|
||||
});
|
||||
|
||||
expect(cells).toEqual([
|
||||
{ chars: "A", width: 1 },
|
||||
{ chars: "😀", width: 2 },
|
||||
{ chars: "", width: 0 },
|
||||
{ chars: "B", width: 1 },
|
||||
{ chars: "❤️", width: 2 },
|
||||
{ chars: "", width: 0 },
|
||||
{ chars: "C", width: 1 },
|
||||
{ chars: "👋🏽", width: 2 },
|
||||
{ chars: "", width: 0 },
|
||||
{ chars: "D", width: 1 },
|
||||
{ chars: "👨👩👧", width: 2 },
|
||||
{ chars: "", width: 0 },
|
||||
{ chars: "E", width: 1 },
|
||||
]);
|
||||
expect(terminal.buffer.active.cursorX).toBe(13);
|
||||
|
||||
terminal.select(0, terminal.buffer.active.cursorY, 13);
|
||||
expect(terminal.getSelection()).toBe(text);
|
||||
});
|
||||
|
||||
// A host without grapheme widths answers cursor reports and replays with its
|
||||
// own table. The web client keeps the Unicode 11 table it used before.
|
||||
it("measures with the legacy table for hosts without grapheme widths", async () => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
const terminal = window.__paseoTerminal;
|
||||
if (!terminal) {
|
||||
throw new Error("Expected the browser terminal to be mounted");
|
||||
}
|
||||
const buffer = terminal.buffer;
|
||||
async function cursorAfter(text: string): Promise<number> {
|
||||
await new Promise<void>((resolve) =>
|
||||
mounted.runtime.write({ data: terminalOutput(`\r\n${text}`), onCommitted: resolve }),
|
||||
);
|
||||
return buffer.active.cursorX;
|
||||
}
|
||||
|
||||
mounted.runtime.setCharacterWidths({ widths: "legacy" });
|
||||
expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 3]);
|
||||
|
||||
mounted.runtime.setCharacterWidths({ widths: "graphemes" });
|
||||
expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
it("restores fitted web geometry after replay before live output", async () => {
|
||||
|
||||
@@ -36,6 +36,12 @@ vi.mock("@xterm/addon-unicode11", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@xterm/addon-unicode-graphemes", () => ({
|
||||
UnicodeGraphemesAddon: class UnicodeGraphemesAddon {
|
||||
dispose(): void {}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@xterm/addon-web-links", () => ({
|
||||
WebLinksAddon: class WebLinksAddon {
|
||||
dispose(): void {}
|
||||
@@ -82,6 +88,7 @@ import {
|
||||
createTerminalResizeEvent,
|
||||
encodeTerminalOutput,
|
||||
TerminalEmulatorRuntime,
|
||||
type TerminalClipboard,
|
||||
} from "./terminal-emulator-runtime";
|
||||
|
||||
interface StubTerminal {
|
||||
@@ -638,3 +645,97 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(fitAndEmitResize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal-emulator-runtime clipboard shortcuts", () => {
|
||||
type KeyHandler = (event: KeyboardEvent) => boolean;
|
||||
|
||||
function createClipboard(available: boolean) {
|
||||
return {
|
||||
isAvailable: () => available,
|
||||
readText: vi.fn(async () => "clipboard text"),
|
||||
read: vi.fn(async () => []),
|
||||
writeText: vi.fn(async (_text: string) => undefined),
|
||||
} satisfies TerminalClipboard;
|
||||
}
|
||||
|
||||
function attachKeyHandler(input: { clipboard: TerminalClipboard; selection: string }) {
|
||||
const runtime = new TerminalEmulatorRuntime({ isMac: false }, input.clipboard);
|
||||
let handler: KeyHandler | null = null;
|
||||
const paste = vi.fn();
|
||||
runtime.attachKeyEventHandler({
|
||||
attachCustomKeyEventHandler: (next: KeyHandler) => {
|
||||
handler = next;
|
||||
},
|
||||
hasSelection: () => input.selection.length > 0,
|
||||
getSelection: () => input.selection,
|
||||
paste,
|
||||
input: vi.fn(),
|
||||
scrollToBottom: vi.fn(),
|
||||
});
|
||||
if (!handler) throw new Error("key handler was not attached");
|
||||
return { handler: handler as KeyHandler, paste };
|
||||
}
|
||||
|
||||
function ctrlKey(key: string) {
|
||||
const preventDefault = vi.fn();
|
||||
const event = {
|
||||
type: "keydown",
|
||||
key,
|
||||
isComposing: false,
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
preventDefault,
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
return { event, preventDefault };
|
||||
}
|
||||
|
||||
it("leaves Ctrl+C with a selection to the native copy event without the Clipboard API", () => {
|
||||
const clipboard = createClipboard(false);
|
||||
const { handler } = attachKeyHandler({ clipboard, selection: "selected text" });
|
||||
const { event, preventDefault } = ctrlKey("c");
|
||||
|
||||
expect(handler(event)).toBe(false);
|
||||
expect(clipboard.writeText).not.toHaveBeenCalled();
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves Ctrl+V to the native paste event without the Clipboard API", () => {
|
||||
const clipboard = createClipboard(false);
|
||||
const { handler, paste } = attachKeyHandler({ clipboard, selection: "" });
|
||||
const { event, preventDefault } = ctrlKey("v");
|
||||
|
||||
expect(handler(event)).toBe(false);
|
||||
expect(clipboard.readText).not.toHaveBeenCalled();
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
expect(paste).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets xterm send ^C for Ctrl+C without a selection when the Clipboard API is missing", () => {
|
||||
const clipboard = createClipboard(false);
|
||||
const { handler } = attachKeyHandler({ clipboard, selection: "" });
|
||||
const { event, preventDefault } = ctrlKey("c");
|
||||
|
||||
expect(handler(event)).toBe(true);
|
||||
expect(clipboard.writeText).not.toHaveBeenCalled();
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies and pastes through the Clipboard API when it is available", async () => {
|
||||
const clipboard = createClipboard(true);
|
||||
const { handler, paste } = attachKeyHandler({ clipboard, selection: "selected text" });
|
||||
|
||||
const copy = ctrlKey("c");
|
||||
expect(handler(copy.event)).toBe(false);
|
||||
expect(clipboard.writeText).toHaveBeenCalledWith("selected text");
|
||||
expect(copy.preventDefault).not.toHaveBeenCalled();
|
||||
|
||||
const pasteKey = ctrlKey("v");
|
||||
expect(handler(pasteKey.event)).toBe(false);
|
||||
expect(pasteKey.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(clipboard.readText).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("clipboard text"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,12 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
||||
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
|
||||
import type { TerminalCharacterWidths } from "../character-widths";
|
||||
import {
|
||||
type TerminalInputModeState,
|
||||
TerminalInputModeTracker,
|
||||
@@ -182,15 +184,26 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
|
||||
}
|
||||
|
||||
export interface TerminalClipboard {
|
||||
/** Whether the async Clipboard API is usable. Browsers omit it outside secure contexts. */
|
||||
isAvailable?(): boolean;
|
||||
readText(): Promise<string>;
|
||||
read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]>;
|
||||
writeText(text: string): Promise<void>;
|
||||
}
|
||||
|
||||
function getBrowserClipboard(): Clipboard | undefined {
|
||||
return typeof navigator !== "undefined" ? navigator.clipboard : undefined;
|
||||
}
|
||||
|
||||
function rejectUnavailableClipboard(): Promise<never> {
|
||||
return Promise.reject(new Error("Clipboard API is unavailable"));
|
||||
}
|
||||
|
||||
const browserClipboard: TerminalClipboard = {
|
||||
readText: () => navigator.clipboard.readText(),
|
||||
read: () => navigator.clipboard.read(),
|
||||
writeText: (text) => navigator.clipboard.writeText(text),
|
||||
isAvailable: () => !!getBrowserClipboard(),
|
||||
readText: () => getBrowserClipboard()?.readText() ?? rejectUnavailableClipboard(),
|
||||
read: () => getBrowserClipboard()?.read() ?? rejectUnavailableClipboard(),
|
||||
writeText: (text) => getBrowserClipboard()?.writeText(text) ?? rejectUnavailableClipboard(),
|
||||
};
|
||||
|
||||
async function readClipboardImage(clipboard: TerminalClipboard): Promise<TerminalFile | null> {
|
||||
@@ -221,6 +234,12 @@ function findFileOnlyPaste(transfer: DataTransfer | null): File | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Grapheme widths match the daemon and native buffers. Legacy hosts keep the
|
||||
// Unicode 11 table web used before. See docs/terminal-rendering.md.
|
||||
function applyCharacterWidths(terminal: Terminal, widths: TerminalCharacterWidths): void {
|
||||
terminal.unicode.activeVersion = widths === "graphemes" ? "15-graphemes" : "11";
|
||||
}
|
||||
|
||||
export class TerminalEmulatorRuntime {
|
||||
constructor(
|
||||
private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() },
|
||||
@@ -233,6 +252,7 @@ export class TerminalEmulatorRuntime {
|
||||
shift: false,
|
||||
alt: false,
|
||||
};
|
||||
private characterWidths: TerminalCharacterWidths = "graphemes";
|
||||
private terminal: Terminal | null = null;
|
||||
private fitAddon: FitAddon | null = null;
|
||||
private searchAddon: SearchAddon | null = null;
|
||||
@@ -320,6 +340,16 @@ export class TerminalEmulatorRuntime {
|
||||
this.pendingModifiers = input.pendingModifiers;
|
||||
}
|
||||
|
||||
setCharacterWidths(input: { widths: TerminalCharacterWidths }): void {
|
||||
this.characterWidths = input.widths;
|
||||
const terminal = this.terminal;
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
applyCharacterWidths(terminal, input.widths);
|
||||
this.refreshVisibleRows();
|
||||
}
|
||||
|
||||
private loadSearchAddon(terminal: Terminal): void {
|
||||
const searchAddon = new SearchAddon({ highlightLimit: FIND_HIGHLIGHT_LIMIT });
|
||||
terminal.loadAddon(searchAddon);
|
||||
@@ -368,22 +398,7 @@ export class TerminalEmulatorRuntime {
|
||||
!event.altKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
|
||||
if (key === "c" && terminal.hasSelection()) {
|
||||
void this.clipboard.writeText(terminal.getSelection());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
|
||||
if (key === "v") {
|
||||
event.preventDefault();
|
||||
this.pasteClipboard(terminal);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return !this.handleCtrlClipboardShortcut(terminal, event);
|
||||
}
|
||||
|
||||
if (this.maybeSendMacEditingShortcut(terminal, event)) {
|
||||
@@ -432,6 +447,36 @@ export class TerminalEmulatorRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns true when a non-Mac Ctrl+C / Ctrl+V clipboard shortcut was handled. */
|
||||
private handleCtrlClipboardShortcut(
|
||||
terminal: Pick<Terminal, "hasSelection" | "getSelection" | "paste">,
|
||||
event: KeyboardEvent,
|
||||
): boolean {
|
||||
const key = event.key.toLowerCase();
|
||||
// Without the Clipboard API (e.g. plain http on a LAN address), the browser's native
|
||||
// copy/paste events still fire and xterm handles them, so leave the default action.
|
||||
const clipboardAvailable = this.clipboard.isAvailable?.() ?? true;
|
||||
|
||||
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
|
||||
if (key === "c" && terminal.hasSelection()) {
|
||||
if (clipboardAvailable) {
|
||||
void this.clipboard.writeText(terminal.getSelection());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
|
||||
if (key === "v") {
|
||||
if (clipboardAvailable) {
|
||||
event.preventDefault();
|
||||
this.pasteClipboard(terminal);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private handleFindShortcut(event: KeyboardEvent): boolean {
|
||||
if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false;
|
||||
event.preventDefault();
|
||||
@@ -529,11 +574,12 @@ export class TerminalEmulatorRuntime {
|
||||
theme: withOverviewRulerBorderHidden(input.theme),
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
let webglAddon: WebglAddon | null = null;
|
||||
let imageAddon: ImageAddon | null = null;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
applyCharacterWidths(terminal, this.characterWidths);
|
||||
terminal.loadAddon(new WebLinksAddon(openExternalLink));
|
||||
const localFileLinkProvider = terminal.registerLinkProvider(
|
||||
createTerminalLocalFileLinkProvider(terminal, {
|
||||
@@ -568,11 +614,6 @@ export class TerminalEmulatorRuntime {
|
||||
terminal.open(input.host);
|
||||
this.themeBackgroundElements = this.collectThemeBackgroundElements(input);
|
||||
this.applyThemeBackground(input.theme);
|
||||
try {
|
||||
terminal.unicode.activeVersion = "11";
|
||||
} catch {
|
||||
// Ignore if unicode API isn't available in this build/runtime.
|
||||
}
|
||||
|
||||
const disposeImageAddon = (): void => {
|
||||
try {
|
||||
@@ -606,6 +647,7 @@ export class TerminalEmulatorRuntime {
|
||||
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
|
||||
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
|
||||
@@ -614,6 +656,7 @@ export class TerminalEmulatorRuntime {
|
||||
{ prefix: "?", intermediates: "$", final: "p" },
|
||||
() => true,
|
||||
);
|
||||
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
|
||||
for (const code of [10, 11, 12]) {
|
||||
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/server": "0.14.0",
|
||||
"@getpaseo/client": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/server": "0.15.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -47,8 +47,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/relay": "0.14.0",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@getpaseo/relay": "0.15.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -204,15 +204,16 @@ export function createHostTransportFactory(
|
||||
}
|
||||
export function createHostClient(
|
||||
connection: string,
|
||||
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
|
||||
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
|
||||
budget?: HostTransportBudget,
|
||||
): DaemonClient {
|
||||
const target = parseHostTarget(connection);
|
||||
const { webSocketAgent, ...clientConfig } = config;
|
||||
const { webSocketAgent, transportFactory, ...clientConfig } = config;
|
||||
return new DaemonClient({
|
||||
...clientConfig,
|
||||
url: "ws://host-connector/ws",
|
||||
...(target.password ? { password: target.password } : {}),
|
||||
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||
transportFactory:
|
||||
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
@@ -41,7 +41,7 @@
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-builder": "26.18.0",
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
"vitest": "^4.1.6",
|
||||
|
||||
@@ -152,6 +152,12 @@ describe("daemon-manager commands", () => {
|
||||
expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the desktop daemon identity after it has stopped", async () => {
|
||||
mkdirSync(mocks.paseoHome);
|
||||
writeFileSync(path.join(mocks.paseoHome, "server-id"), "srv_existing\n");
|
||||
expect(await createDaemonCommandHandlers().desktop_daemon_identity()).toBe("srv_existing");
|
||||
});
|
||||
|
||||
it("reports an errored daemon when the local daemon state cannot be read", async () => {
|
||||
mkdirSync(mocks.paseoHome);
|
||||
writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage");
|
||||
|
||||
@@ -476,6 +476,14 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
runningUnderARM64Translation: isRunningUnderARM64Translation(),
|
||||
}),
|
||||
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
|
||||
desktop_daemon_identity: () => {
|
||||
try {
|
||||
return readFileSync(path.join(getPaseoHome(), "server-id"), "utf8").trim() || null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
desktop_local_credential: async (args) => {
|
||||
const instance = await readDaemonInstance(getPaseoHome());
|
||||
if (!instance?.desktopManaged || typeof args?.listen !== "string") return null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -77,8 +77,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/client": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -88,8 +88,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/client": "0.15.0-beta.1",
|
||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -92,6 +92,18 @@ function typeErrors(code: string): string[] {
|
||||
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
||||
}
|
||||
describe("plugin SDK import boundaries", () => {
|
||||
it("exposes host handles and object-shaped additions from the client entry", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import { addHost, observeHosts, useHosts, type PluginHost } from "@getpaseo/plugin/client";
|
||||
const hosts: readonly PluginHost[] = useHosts();
|
||||
const stop = observeHosts((value) => { void value[0]?.openSettings(); });
|
||||
void addHost({ connection: "localhost:6767", label: "Local" });
|
||||
void hosts; stop();
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes process capabilities without their Windows implementation helpers", () => {
|
||||
expect(Object.keys(server).sort()).toEqual([
|
||||
"defineAgentTool",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { PluginHostSummary } from "./index.js";
|
||||
|
||||
export type PluginHostStatus = PluginHostSummary["status"];
|
||||
export type PluginHostColor =
|
||||
| "none"
|
||||
| "violet"
|
||||
| "sky"
|
||||
| "emerald"
|
||||
| "orange"
|
||||
| "pink"
|
||||
| "indigo"
|
||||
| "teal"
|
||||
| "red"
|
||||
| "amber"
|
||||
| "blue";
|
||||
export type PluginHostBadgeDisplay = "name" | "icon" | "hidden";
|
||||
export interface PluginHostAppearance {
|
||||
readonly color: PluginHostColor;
|
||||
/** Null uses the automatic local or remote default. */
|
||||
readonly badgeDisplay: PluginHostBadgeDisplay | null;
|
||||
}
|
||||
/** Saved connection settings without credentials. Routed connections have no URI. */
|
||||
export type PluginHostConnection =
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "directTcp";
|
||||
readonly uri: string;
|
||||
readonly endpoint: string;
|
||||
readonly useTls: boolean;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "directSocket";
|
||||
readonly uri: string;
|
||||
readonly path: string;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "directPipe";
|
||||
readonly uri: string;
|
||||
readonly path: string;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "remoteSsh";
|
||||
readonly uri: string;
|
||||
readonly host: string;
|
||||
readonly sshPort?: number;
|
||||
readonly daemonPort?: number;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "relay";
|
||||
readonly uri: string;
|
||||
readonly relayEndpoint: string;
|
||||
readonly useTls: boolean;
|
||||
readonly daemonPublicKeyB64: string;
|
||||
readonly minimumSecurityMode?: "protected";
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "routed";
|
||||
readonly uri: null;
|
||||
readonly originServerId: string;
|
||||
};
|
||||
export interface PluginHostPatch {
|
||||
label?: string;
|
||||
appearance?: Partial<PluginHostAppearance>;
|
||||
}
|
||||
export interface PluginHost extends PluginHostSummary {
|
||||
readonly appearance: PluginHostAppearance;
|
||||
readonly connections: readonly PluginHostConnection[];
|
||||
readonly activeConnectionId: string | null;
|
||||
readonly error: string | null;
|
||||
readonly authFailureReason: "password_required" | "incorrect_password" | null;
|
||||
readonly lastOnlineAt: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
update(patch: PluginHostPatch): Promise<void>;
|
||||
/** Removes the host; throws for the desktop daemon host. */
|
||||
remove(): Promise<void>;
|
||||
/** Removing the last connection removes the host. */
|
||||
removeConnection(connectionId: string): Promise<void>;
|
||||
openSettings(): void;
|
||||
}
|
||||
export interface PluginHostAddInput {
|
||||
/** Shared host connection grammar; may contain a password for the probe. */
|
||||
connection: string;
|
||||
label?: string;
|
||||
}
|
||||
export type PluginHostAddResult =
|
||||
| { status: "saved"; host: PluginHost }
|
||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
||||
| { status: "cancelled" };
|
||||
export declare function useHosts(): readonly PluginHost[];
|
||||
export declare function observeHosts(listener: (hosts: readonly PluginHost[]) => void): () => void;
|
||||
|
||||
export declare function addHost(input: PluginHostAddInput): Promise<PluginHostAddResult>;
|
||||
@@ -86,7 +86,20 @@ export interface PluginHostSummary {
|
||||
readonly status: "idle" | "connecting" | "online" | "offline" | "error";
|
||||
}
|
||||
/** Live configured hosts. Supplied by the app's client bundle loader. */
|
||||
export declare function useHosts(): readonly PluginHostSummary[];
|
||||
export declare const useHosts: typeof import("./hosts.js").useHosts;
|
||||
export declare const observeHosts: typeof import("./hosts.js").observeHosts;
|
||||
export declare const addHost: typeof import("./hosts.js").addHost;
|
||||
export type {
|
||||
PluginHost,
|
||||
PluginHostStatus,
|
||||
PluginHostColor,
|
||||
PluginHostBadgeDisplay,
|
||||
PluginHostAppearance,
|
||||
PluginHostConnection,
|
||||
PluginHostPatch,
|
||||
PluginHostAddInput,
|
||||
PluginHostAddResult,
|
||||
} from "./hosts.js";
|
||||
/** Borrow an online host's API under this installation's lifetime. */
|
||||
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
|
||||
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { parseHostTarget } from "./host-target.js";
|
||||
import { serializeSshTransportUri } from "./ssh-transport.js";
|
||||
import { serializeSocketHostTarget } from "./host-target.js";
|
||||
import {
|
||||
parseConnectionUri,
|
||||
serializeConnectionUri,
|
||||
serializeRelayConnectionUri,
|
||||
} from "./daemon-endpoints.js";
|
||||
|
||||
test("SSH and local socket targets round trip through their serializers", () => {
|
||||
const ssh = parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777");
|
||||
if (ssh.kind !== "ssh") throw new Error("expected SSH");
|
||||
expect(parseHostTarget(serializeSshTransportUri(ssh.ssh))).toEqual(ssh);
|
||||
for (const [type, path] of [
|
||||
["unix", "/tmp/paseo.sock"],
|
||||
["pipe", "\\\\.\\pipe\\paseo"],
|
||||
] as const) {
|
||||
const target = parseHostTarget(`${type}://${path}`);
|
||||
expect(parseHostTarget(serializeSocketHostTarget(type, path))).toEqual(target);
|
||||
}
|
||||
});
|
||||
|
||||
test("TCP and relay targets round trip through credential-free serializers", () => {
|
||||
const tcp = parseConnectionUri("tcp://example.com:6767?ssl=true&password=secret");
|
||||
const tcpUri = serializeConnectionUri(tcp);
|
||||
expect(tcpUri).not.toContain("secret");
|
||||
expect(parseHostTarget(tcpUri)).toMatchObject({
|
||||
connection: { type: "directTcp", endpoint: "example.com:6767", useTls: true },
|
||||
});
|
||||
const relay = parseHostTarget(
|
||||
"relay://relay.example:443/server?key=public&ssl=true&password=secret",
|
||||
);
|
||||
if (relay.kind !== "socket" || relay.connection.type !== "relay")
|
||||
throw new Error("expected relay");
|
||||
const relayUri = serializeRelayConnectionUri({ offer: relay.connection.offer });
|
||||
expect(relayUri).not.toContain("secret");
|
||||
expect(parseHostTarget(relayUri)).toMatchObject({ connection: relay.connection });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["example.com:6767", { type: "directTcp", endpoint: "example.com:6767", useTls: false }],
|
||||
[
|
||||
"tcp://example.com:6767?ssl=true",
|
||||
{ type: "directTcp", endpoint: "example.com:6767", useTls: true },
|
||||
],
|
||||
["wss://example.com/ws", { type: "directTcp", endpoint: "example.com:443", useTls: true }],
|
||||
["unix:///tmp/paseo.sock", { type: "directSocket", path: "/tmp/paseo.sock" }],
|
||||
["pipe://mypipe", { type: "directPipe", path: "mypipe" }],
|
||||
])("%s exposes its structured connection", (input, connection) => {
|
||||
expect(parseHostTarget(input)).toMatchObject({ kind: "socket", connection });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["127.0.0.1:6767", "tcp://127.0.0.1:6767"],
|
||||
|
||||
@@ -14,12 +14,23 @@ export type HostTarget =
|
||||
| {
|
||||
kind: "socket";
|
||||
url: string;
|
||||
connection:
|
||||
| { type: "directTcp"; endpoint: string; useTls: boolean }
|
||||
| { type: "directSocket"; path: string }
|
||||
| { type: "directPipe"; path: string }
|
||||
| { type: "relay"; offer: ConnectionOffer };
|
||||
socketPath?: string;
|
||||
password?: string;
|
||||
offer?: ConnectionOffer;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/** Serialize a local socket target in the shared host grammar. */
|
||||
export function serializeSocketHostTarget(type: "unix" | "pipe", path: string): string {
|
||||
if (!path || path === "~") throw new Error("Invalid socket path");
|
||||
return `${type}://${path}`;
|
||||
}
|
||||
|
||||
/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */
|
||||
export function parseHostTarget(input: string): HostTarget {
|
||||
try {
|
||||
@@ -57,6 +68,9 @@ function parse(input: string): HostTarget {
|
||||
const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\");
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: pipe
|
||||
? { type: "directPipe", path: socketPath }
|
||||
: { type: "directSocket", path: socketPath },
|
||||
socketPath,
|
||||
url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`,
|
||||
description: `${pipe ? "pipe" : "unix"}://${socketPath}`,
|
||||
@@ -67,6 +81,7 @@ function parse(input: string): HostTarget {
|
||||
const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "directTcp", endpoint, useTls: false },
|
||||
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
|
||||
description: `tcp://${endpoint}`,
|
||||
};
|
||||
@@ -75,6 +90,7 @@ function relayTarget(offer: ConnectionOffer, password?: string): HostTarget {
|
||||
const endpoint = validatedEndpoint(offer.relay.endpoint);
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "relay", offer },
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint,
|
||||
serverId: offer.serverId,
|
||||
@@ -97,6 +113,7 @@ function tcpTarget(input: string): HostTarget {
|
||||
);
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "directTcp", endpoint, useTls: parts.useTls },
|
||||
url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }),
|
||||
password: parts.password,
|
||||
description: `tcp://${endpoint}`,
|
||||
@@ -109,7 +126,15 @@ function websocketTarget(input: string): HostTarget {
|
||||
// Unknown query parameters may themselves contain credentials; discovery shows only the origin.
|
||||
if (url.pathname === "/") url.pathname = "/ws";
|
||||
url.searchParams.delete("password");
|
||||
return { kind: "socket", url: url.toString(), password, description: url.origin };
|
||||
const useTls = url.protocol === "wss:";
|
||||
const endpoint = `${url.hostname}:${url.port || (useTls ? "443" : "80")}`;
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "directTcp", endpoint, useTls },
|
||||
url: url.toString(),
|
||||
password,
|
||||
description: url.origin,
|
||||
};
|
||||
}
|
||||
|
||||
function sshTarget(input: string): HostTarget {
|
||||
|
||||
@@ -3751,6 +3751,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
"terminal-input-mode-replay": z.boolean().optional(),
|
||||
// COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02.
|
||||
"terminal-size-ownership": z.boolean().optional(),
|
||||
// COMPAT(terminalGraphemeWidths): added in v0.15.0, remove gate after 2027-05-01.
|
||||
"terminal-grapheme-widths": z.boolean().optional(),
|
||||
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
|
||||
rewind: z.boolean().optional(),
|
||||
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
|
||||
|
||||
@@ -60,6 +60,19 @@ export function parseSshTransportUri(value: string): SshTransportTarget {
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize an SSH target without daemon credentials. */
|
||||
export function serializeSshTransportUri(target: SshTransportTarget): string {
|
||||
const host = validateSshHost(target.host);
|
||||
const separator = host.lastIndexOf("@");
|
||||
const username = separator < 0 ? "" : `${encodeURIComponent(host.slice(0, separator))}@`;
|
||||
const hostname = host.slice(separator + 1);
|
||||
const authority = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||
const port = target.sshPort === undefined ? "" : `:${validatePort(target.sshPort, "SSH port")}`;
|
||||
const daemonPort = validatePort(target.daemonPort, "Daemon port");
|
||||
const query = daemonPort === DEFAULT_SSH_DAEMON_PORT ? "" : `?daemonPort=${daemonPort}`;
|
||||
return `ssh://${username}${authority}${port}${query}`;
|
||||
}
|
||||
|
||||
export function buildSshTunnelArgs(target: SshTransportTarget): string[] {
|
||||
const host = validateSshHost(target.host);
|
||||
const daemonPort = validatePort(target.daemonPort, "Daemon port");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0-beta.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
@@ -107,17 +107,18 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.14.0",
|
||||
"@getpaseo/highlight": "0.14.0",
|
||||
"@getpaseo/plugin": "0.14.0",
|
||||
"@getpaseo/protocol": "0.14.0",
|
||||
"@getpaseo/relay": "0.14.0",
|
||||
"@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",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@opencode/client": "2.0.10",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
|
||||
"@xterm/headless": "6.1.0-beta.334",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
||||
@@ -697,6 +697,7 @@ export interface AgentSession {
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void>;
|
||||
/** Null until a new provider process could resume this session; see docs/providers.md. */
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
/**
|
||||
* Resolve once every foreground turn that predates this call can no longer run or become active.
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
||||
availableModes: OPENCODE_MODES,
|
||||
}),
|
||||
).toThrow(
|
||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
|
||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
||||
availableModes: CODEX_MODES,
|
||||
}),
|
||||
).toThrow(
|
||||
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
|
||||
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
||||
targetUnattendedMode: "full-access",
|
||||
}),
|
||||
).toThrow(
|
||||
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
|
||||
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
||||
targetUnattendedMode: undefined,
|
||||
}),
|
||||
).toThrow(
|
||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
|
||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -2634,6 +2634,72 @@ describe("ACPAgentSession slash commands", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("lists late command batches through the public ACP client", async () => {
|
||||
const artifacts = path.resolve(".artifacts");
|
||||
await mkdir(artifacts, { recursive: true });
|
||||
const directory = await mkdtemp(path.join(artifacts, "acp-command-inventory-"));
|
||||
const script = path.join(directory, "agent.mjs");
|
||||
await writeFile(
|
||||
script,
|
||||
`
|
||||
import { createInterface } from "node:readline";
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
const help = { name: "help", description: "Show available commands" };
|
||||
const skill = { name: "xlsx", description: "Work with spreadsheets", input: { hint: "[instructions]" } };
|
||||
const publish = (commands) => send({
|
||||
jsonrpc: "2.0", method: "session/update", params: {
|
||||
sessionId: "inventory-session", update: {
|
||||
sessionUpdate: "available_commands_update", availableCommands: commands,
|
||||
},
|
||||
},
|
||||
});
|
||||
createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
if (request.method === "initialize") {
|
||||
send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: 1, agentCapabilities: {} } });
|
||||
} else if (request.method === "session/new") {
|
||||
// The first batch is already cached when createSession returns.
|
||||
publish([help]);
|
||||
send({ jsonrpc: "2.0", id: request.id, result: { sessionId: "inventory-session" } });
|
||||
setTimeout(() => publish([help]), 100);
|
||||
setTimeout(() => publish([help, skill]), 350);
|
||||
} else if (request.id !== undefined) {
|
||||
send({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } });
|
||||
}
|
||||
});
|
||||
`,
|
||||
);
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: [process.execPath, script],
|
||||
});
|
||||
try {
|
||||
const session = await client.createSession({ provider: "acp", cwd: directory });
|
||||
try {
|
||||
const commands = await session.listCommands!();
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
name: "help",
|
||||
description: "Show available commands",
|
||||
argumentHint: "",
|
||||
kind: "command",
|
||||
},
|
||||
{
|
||||
name: "xlsx",
|
||||
description: "Work with spreadsheets",
|
||||
argumentHint: "[instructions]",
|
||||
kind: "command",
|
||||
},
|
||||
]);
|
||||
expect(await session.listCommands!()).toEqual(commands);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentSession", () => {
|
||||
|
||||
@@ -120,6 +120,7 @@ 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,
|
||||
@@ -165,18 +166,6 @@ 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);
|
||||
}
|
||||
@@ -337,6 +326,14 @@ const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000;
|
||||
const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000;
|
||||
const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000;
|
||||
|
||||
/**
|
||||
* An ACP agent publishes its slash-command inventory incrementally. Wait this
|
||||
* long without another batch before treating the inventory as complete. MiniMax
|
||||
* publishes its skills about 350ms after its built-in commands.
|
||||
*/
|
||||
const COMMAND_INVENTORY_QUIET_MS = 600;
|
||||
const COMMAND_INVENTORY_POLL_MS = 25;
|
||||
|
||||
function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } {
|
||||
return {
|
||||
type: error instanceof Error ? error.name : typeof error,
|
||||
@@ -1765,7 +1762,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private lastActivityAt: string | null = null;
|
||||
private configOptions: SessionConfigOption[] = [];
|
||||
private cachedCommands: AgentSlashCommand[] = [];
|
||||
private commandsReadyDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
|
||||
/** When the last command batch arrived; null while nothing has been published. */
|
||||
private commandsUpdatedAt: number | null = null;
|
||||
private commandsReadySettled = false;
|
||||
private waitForInitialCommands: boolean;
|
||||
private initialCommandsWaitTimeoutMs: number;
|
||||
@@ -2063,58 +2061,39 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
];
|
||||
}
|
||||
|
||||
private ensureCommandsReadyDeferred(): void {
|
||||
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
this.commandsReadyDeferred = { promise, resolve };
|
||||
}
|
||||
|
||||
private settleCommandsReady(): void {
|
||||
if (this.commandsReadySettled) {
|
||||
return;
|
||||
}
|
||||
this.commandsReadySettled = true;
|
||||
this.commandsReadyDeferred?.resolve();
|
||||
this.commandsReadyDeferred = null;
|
||||
}
|
||||
|
||||
private async waitForCommandsReady(): Promise<void> {
|
||||
const deferred = this.commandsReadyDeferred;
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
deferred.promise,
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
/**
|
||||
* Providers publish their command inventory incrementally. MiniMax sends its
|
||||
* built-in commands twice, then a third batch about 350ms later that merges in
|
||||
* every discovered skill. Returning after the first batch loses the skills.
|
||||
*
|
||||
* Wait for the inventory to go quiet rather than for the first batch. The
|
||||
* quiet window must exceed the gap between a provider's own batches; 600ms
|
||||
* covers MiniMax with margin. `initialCommandsWaitTimeoutMs` still bounds the
|
||||
* whole wait for providers that publish once or never publish at all.
|
||||
*/
|
||||
private async waitForCommandsToSettle(): Promise<void> {
|
||||
const deadline = Date.now() + this.initialCommandsWaitTimeoutMs;
|
||||
while (!this.commandsReadySettled && !this.closed) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) return;
|
||||
const quietFor = this.commandsUpdatedAt === null ? 0 : Date.now() - this.commandsUpdatedAt;
|
||||
if (quietFor >= COMMAND_INVENTORY_QUIET_MS) return;
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, Math.min(remaining, COMMAND_INVENTORY_POLL_MS)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
if (this.cachedCommands.length > 0) {
|
||||
return this.cachedCommands;
|
||||
}
|
||||
if (!this.waitForInitialCommands || this.closed) {
|
||||
if (this.commandsReadySettled || this.closed || !this.waitForInitialCommands) {
|
||||
return this.cachedCommands;
|
||||
}
|
||||
|
||||
this.ensureCommandsReadyDeferred();
|
||||
await this.waitForCommandsReady();
|
||||
await this.waitForCommandsToSettle();
|
||||
this.settleCommandsReady();
|
||||
return this.cachedCommands;
|
||||
}
|
||||
@@ -2745,14 +2724,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache an asynchronously-delivered slash-command batch and unblock any
|
||||
// listCommands() call that is waiting on the initial batch. Used when a
|
||||
// provider supplies an extensionCommandsParser whose result arrives after
|
||||
// session/new (e.g. via a vendor extension notification). The ready gate is
|
||||
// always settled — even for an empty batch — so a provider that legitimately
|
||||
// reports no commands does not leave listCommands() blocked for the full
|
||||
// initial-commands timeout. An optional sessionId scopes the batch to this
|
||||
// session; notifications addressed to a different session are ignored.
|
||||
// Cache an asynchronously-delivered slash-command batch from a provider that
|
||||
// supplies an extensionCommandsParser (e.g. a vendor extension notification).
|
||||
// An empty batch is the provider saying it has no commands, so it settles the
|
||||
// waiter immediately instead of letting it wait out the quiet window. An
|
||||
// optional sessionId scopes the batch to this session; notifications
|
||||
// addressed to a different session are ignored.
|
||||
private applyResolvedCommands(
|
||||
commands: AgentSlashCommand[],
|
||||
options?: { sessionId?: string },
|
||||
@@ -2766,7 +2743,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
this.commandsUpdatedAt = Date.now();
|
||||
this.cachedCommands = commands;
|
||||
// A non-empty batch may be followed by more. Let the quiet window decide
|
||||
// when the inventory is complete rather than settling here; an empty
|
||||
// batch is the provider saying it has nothing, which must still unblock
|
||||
// the waiter immediately.
|
||||
return;
|
||||
}
|
||||
this.settleCommandsReady();
|
||||
}
|
||||
@@ -2889,11 +2872,8 @@ 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 async resolveProcessCwd(): Promise<string> {
|
||||
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
|
||||
return this.config.cwd;
|
||||
}
|
||||
return homedir();
|
||||
private resolveProcessCwd(): Promise<string> {
|
||||
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
|
||||
}
|
||||
|
||||
private async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
@@ -3150,13 +3130,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.handleUsageUpdate(update);
|
||||
return pendingUserEvents;
|
||||
case "available_commands_update":
|
||||
this.commandsUpdatedAt = Date.now();
|
||||
this.cachedCommands = update.availableCommands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
argumentHint: command.input?.hint ?? "",
|
||||
kind: "command",
|
||||
}));
|
||||
this.settleCommandsReady();
|
||||
// An empty batch means the agent has no commands and would otherwise
|
||||
// wait out the whole quiet window. A non-empty batch may be one of
|
||||
// several, so completion belongs to the quiet window.
|
||||
if (update.availableCommands.length === 0) {
|
||||
this.settleCommandsReady();
|
||||
}
|
||||
return pendingUserEvents;
|
||||
default:
|
||||
return pendingUserEvents;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import type { ClaudeQueryFactory } from "./query.js";
|
||||
|
||||
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
|
||||
|
||||
interface ScriptedTurn {
|
||||
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
|
||||
errorReply?: string;
|
||||
// Assistant text the model wrote before or after it.
|
||||
before?: string;
|
||||
after?: string;
|
||||
isError: boolean;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCostUsd: number;
|
||||
};
|
||||
}
|
||||
|
||||
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
|
||||
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
|
||||
return ({ prompt }) => {
|
||||
let index = 0;
|
||||
const assistant = (id: string, text: string, error?: string) => ({
|
||||
type: "assistant",
|
||||
session_id: "error-session",
|
||||
parent_tool_use_id: null,
|
||||
uuid: id,
|
||||
message: {
|
||||
id,
|
||||
role: "assistant",
|
||||
model: error ? "<synthetic>" : "claude-opus-5-5",
|
||||
content: [{ type: "text", text }],
|
||||
stop_reason: "stop_sequence",
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
...(error ? { error } : {}),
|
||||
});
|
||||
const messages = (async function* () {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
for await (const _message of prompt) {
|
||||
const turn = turns[index];
|
||||
index += 1;
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "error-session",
|
||||
permissionMode: "default",
|
||||
model: "claude-opus-5-5",
|
||||
};
|
||||
if (turn.before) yield assistant(`before-${index}`, turn.before);
|
||||
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
|
||||
if (turn.after) yield assistant(`after-${index}`, turn.after);
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "error-session",
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 0,
|
||||
is_error: turn.isError,
|
||||
num_turns: 1,
|
||||
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
|
||||
stop_reason: "stop_sequence",
|
||||
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
|
||||
usage: {
|
||||
input_tokens: turn.usage?.inputTokens ?? 0,
|
||||
output_tokens: turn.usage?.outputTokens ?? 0,
|
||||
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: `result-${index}`,
|
||||
};
|
||||
}
|
||||
})();
|
||||
return Object.assign(messages, {
|
||||
close: () => {
|
||||
void messages.return();
|
||||
},
|
||||
interrupt: async () => undefined,
|
||||
setModel: async () => undefined,
|
||||
setPermissionMode: async () => undefined,
|
||||
supportedModels: async () => [],
|
||||
supportedCommands: async () => [],
|
||||
}) as ReturnType<ClaudeQueryFactory>;
|
||||
};
|
||||
}
|
||||
|
||||
async function runTurns(turns: ScriptedTurn[]) {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: scriptedQuery(turns),
|
||||
resolveBinary: async () => process.execPath,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
try {
|
||||
const results = [];
|
||||
for (const [index] of turns.entries()) {
|
||||
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
|
||||
return events.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
|
||||
return events.find(
|
||||
(event) =>
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled",
|
||||
);
|
||||
}
|
||||
|
||||
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
|
||||
const [limited, overloaded, next] = await runTurns([
|
||||
{ errorReply: LIMIT, isError: true },
|
||||
{
|
||||
before: "Checking the deploy doc.",
|
||||
errorReply: "API Error: 529 Overloaded",
|
||||
isError: true,
|
||||
},
|
||||
{ after: "Done.", isError: false },
|
||||
]);
|
||||
|
||||
expect(terminal(limited)).toMatchObject({
|
||||
type: "turn_failed",
|
||||
error: LIMIT,
|
||||
});
|
||||
expect(assistantTexts(limited)).toEqual([]);
|
||||
|
||||
expect(terminal(overloaded)).toMatchObject({
|
||||
type: "turn_failed",
|
||||
error: "API Error: 529 Overloaded",
|
||||
});
|
||||
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
|
||||
|
||||
// The session keeps working after a failed turn.
|
||||
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(next)).toEqual(["Done."]);
|
||||
});
|
||||
|
||||
test("a failed turn retains its final token counts and cost", async () => {
|
||||
const usage = {
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 80,
|
||||
outputTokens: 30,
|
||||
totalCostUsd: 0.012,
|
||||
};
|
||||
const [failed] = await runTurns([
|
||||
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
|
||||
]);
|
||||
|
||||
expect(failed).toContainEqual(
|
||||
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
|
||||
);
|
||||
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
|
||||
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
|
||||
});
|
||||
|
||||
test("an error reply the turn moves past is still shown", async () => {
|
||||
const [recovered, completed] = await runTurns([
|
||||
{
|
||||
errorReply: "API Error: 529 Overloaded",
|
||||
after: "Recovered.",
|
||||
isError: false,
|
||||
},
|
||||
{ errorReply: LIMIT, isError: false },
|
||||
]);
|
||||
|
||||
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
|
||||
|
||||
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(completed)).toEqual([LIMIT]);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { constants as bufferConstants } from "node:buffer";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => {
|
||||
|
||||
await expect(importSession(sessionId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("reads the rename from a transcript too large to hold as one string", async () => {
|
||||
const sessionId = "oversized-transcript";
|
||||
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
// A hole stands in for the long session this used to fail on: the file only
|
||||
// has to be longer than a string can be, and the rename sits at its end.
|
||||
await fs.writeFile(transcript, "");
|
||||
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
|
||||
await fs.appendFile(
|
||||
transcript,
|
||||
`\n${[
|
||||
userRecord({ sessionId, content: "Trace the slow query" }),
|
||||
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Slow query hunt");
|
||||
});
|
||||
|
||||
test("reads the rename from behind more records than one read covers", async () => {
|
||||
const sessionId = "late-records-rename";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
userRecord({ sessionId, content: "Review this project" }),
|
||||
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
|
||||
// One record longer than a single read, then enough records to leave the
|
||||
// rename several reads back from the end of the file.
|
||||
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
|
||||
...Array.from({ length: 64 }, (_, index) =>
|
||||
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Scrollback review");
|
||||
});
|
||||
|
||||
test("imports a large record after the last rename without repeatedly copying it", async () => {
|
||||
const sessionId = "large-record-after-rename";
|
||||
await writeSession({
|
||||
sessionId,
|
||||
records: [
|
||||
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
|
||||
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
|
||||
],
|
||||
});
|
||||
|
||||
const imported = await importSession(sessionId);
|
||||
|
||||
expect(imported.config.title).toBe("Large attachment review");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
describe("Claude runtime info before a turn", () => {
|
||||
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
|
||||
|
||||
test("reports the explicitly configured model before the first turn", async () => {
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-haiku-5-5",
|
||||
});
|
||||
try {
|
||||
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reports the persisted configured model after rebuilding an idle session", async () => {
|
||||
const session = await client.resumeSession({
|
||||
provider: "claude",
|
||||
sessionId: "runtime-model-before-turn",
|
||||
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
|
||||
});
|
||||
try {
|
||||
expect(await session.getRuntimeInfo()).toMatchObject({
|
||||
sessionId: "runtime-model-before-turn",
|
||||
model: "claude-haiku-5-5",
|
||||
});
|
||||
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
|
||||
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("leaves the model unknown when Claude will choose its default", async () => {
|
||||
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
|
||||
try {
|
||||
expect((await session.getRuntimeInfo()).model).toBeNull();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
|
||||
@@ -77,6 +78,29 @@ function sidechainEntry(options: { agentId?: string; stopReason?: string | null
|
||||
});
|
||||
}
|
||||
|
||||
function buildQueryMock(events: unknown[]) {
|
||||
let index = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (index >= events.length) return { done: true, value: undefined };
|
||||
const value = events[index];
|
||||
index += 1;
|
||||
return { done: false, value };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
close: vi.fn(() => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => []),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
const logger = createTestLogger();
|
||||
const queryFactory = vi.fn(() => {
|
||||
@@ -1262,4 +1286,106 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
);
|
||||
expect(items).toEqual([{ type: "assistant_message", text: report }]);
|
||||
});
|
||||
|
||||
test("keeps a subagent resumed after the session reopens on the row its transcript restored", async () => {
|
||||
// Verified on the wire (Claude Code 2.1.309): SendMessage resumes a finished subagent under its
|
||||
// original task_id but a new tool_use_id, and the child's frames still name the original Task
|
||||
// call as their parent. A reopened session only knows that child from its transcript.
|
||||
writeSession({
|
||||
parentLines: [taskToolUse(), taskToolResult()],
|
||||
meta: JSON.stringify({ agentType: "general-purpose", toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
sessionId: "replay-session",
|
||||
timestamp: "2026-07-26T06:27:50.000Z",
|
||||
effort: "high",
|
||||
message: {
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-5",
|
||||
content: [{ type: "text", text: "summary of the docs" }],
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const liveQuery = vi.fn(() =>
|
||||
buildQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "replay-session",
|
||||
permissionMode: "default",
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
task_id: AGENT_ID,
|
||||
tool_use_id: "toolu_send_message",
|
||||
description: "Summarize the docs",
|
||||
subagent_type: "general-purpose",
|
||||
task_type: "local_agent",
|
||||
is_backgrounded: true,
|
||||
prompt: "Summarize them again",
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: TOOL_USE_ID,
|
||||
session_id: "replay-session",
|
||||
message: {
|
||||
id: "msg_resumed_child",
|
||||
role: "assistant",
|
||||
model: "claude-opus-5",
|
||||
content: [{ type: "text", text: "summary again" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_updated",
|
||||
task_id: AGENT_ID,
|
||||
patch: { status: "completed" },
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: liveQuery,
|
||||
resolveVersion: async () => "2.1.309",
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).resumeSession({ provider: "claude", sessionId: "replay-session" }, { cwd });
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamSession(session, "Ask it to summarize again")) {
|
||||
events.push(event);
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") break;
|
||||
}
|
||||
await session.close();
|
||||
|
||||
const live = events.flatMap((event) =>
|
||||
event.type === "provider_subagent" ? [event.event] : [],
|
||||
);
|
||||
expect(new Set(live.map((event) => event.id))).toEqual(new Set([TOOL_USE_ID]));
|
||||
expect(live).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "upsert",
|
||||
id: TOOL_USE_ID,
|
||||
subtitle: "general-purpose · Opus 5 · High",
|
||||
}),
|
||||
);
|
||||
expect(live).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
id: TOOL_USE_ID,
|
||||
item: expect.objectContaining({ type: "assistant_message", text: "summary again" }),
|
||||
}),
|
||||
);
|
||||
expect(live.at(-1)).toMatchObject({ type: "upsert", id: TOOL_USE_ID, status: "completed" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2389,6 +2389,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private mainTurnInFlight = false;
|
||||
private foregroundHasVisibleActivity = false;
|
||||
private activeTurnHasAssistantText = false;
|
||||
/**
|
||||
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
|
||||
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
|
||||
* failed turn shows the same text as its error, so showing both would repeat it.
|
||||
*/
|
||||
private withheldErrorReply: AgentTimelineItem[] = [];
|
||||
private readonly contextUsage: ClaudeContextUsageState;
|
||||
private userMessageIds: string[] = [];
|
||||
private readonly emittedUserMessageIds = new Set<string>();
|
||||
@@ -2460,7 +2466,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const info: AgentRuntimeInfo = {
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
model: this.lastOptionsModel,
|
||||
model: this.lastOptionsModel ?? this.config.model ?? null,
|
||||
modeId: this.currentMode ?? null,
|
||||
...(this.lastRuntimeModel
|
||||
? {
|
||||
@@ -2531,6 +2537,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.foregroundHasVisibleActivity = false;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.withheldErrorReply = [];
|
||||
this.contextUsage.beginTurn();
|
||||
this.transitionTurnState("foreground", "foreground turn started");
|
||||
this.clearRecentStderr();
|
||||
@@ -4419,6 +4426,21 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}) satisfies AgentStreamEvent,
|
||||
);
|
||||
|
||||
if (!readClaudeParentToolUseId(message)) {
|
||||
if (message.type === "assistant" && message.error) {
|
||||
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
|
||||
return messageEvents;
|
||||
}
|
||||
if (
|
||||
message.type === "assistant" ||
|
||||
message.type === "user" ||
|
||||
message.type === "stream_event"
|
||||
) {
|
||||
const released: AgentStreamEvent[] = [];
|
||||
this.releaseWithheldErrorReply(released);
|
||||
return [...released, ...messageEvents, ...assistantTimelineEvents];
|
||||
}
|
||||
}
|
||||
return [...messageEvents, ...assistantTimelineEvents];
|
||||
}
|
||||
|
||||
@@ -4966,6 +4988,23 @@ class ClaudeAgentSession implements AgentSession {
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const usage = this.convertUsage(message);
|
||||
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
|
||||
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
|
||||
if (message.subtype === "success" && message.is_error) {
|
||||
const withheld = this.withheldErrorReply;
|
||||
this.withheldErrorReply = [];
|
||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||
const withheldText = withheld
|
||||
.map((item) => (item.type === "assistant_message" ? item.text : ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
|
||||
events.push(...this.sidechainTracker.finishAll("failed"));
|
||||
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
|
||||
return;
|
||||
}
|
||||
const releasedErrorReply = this.withheldErrorReply.length > 0;
|
||||
this.releaseWithheldErrorReply(events);
|
||||
if (message.subtype === "success") {
|
||||
events.push(...this.sidechainTracker.finishAll("completed"));
|
||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||
@@ -4975,7 +5014,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// accounting from provider gateways does not duplicate streamed output.
|
||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||
const outputTokens = message.usage?.output_tokens;
|
||||
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
|
||||
if (
|
||||
resultText.length > 0 &&
|
||||
outputTokens === 0 &&
|
||||
!this.activeTurnHasAssistantText &&
|
||||
!releasedErrorReply
|
||||
) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
@@ -4997,6 +5041,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
events.push(this.buildTurnFailedEvent(errorMessage));
|
||||
}
|
||||
|
||||
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
|
||||
if (this.withheldErrorReply.length === 0) return;
|
||||
for (const item of this.withheldErrorReply) {
|
||||
events.push({ type: "timeline", item, provider: "claude" });
|
||||
}
|
||||
this.withheldErrorReply = [];
|
||||
}
|
||||
|
||||
private createClaudeSessionChangedNotice(
|
||||
oldSessionId: string,
|
||||
newSessionId: string,
|
||||
@@ -5455,6 +5507,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
convertEntry: (entry) => this.convertHistoryEntry(entry),
|
||||
})),
|
||||
];
|
||||
this.taskProtocolSource.restore(subagentReplay.restored);
|
||||
const restoredProviderSubagentIds = new Set(
|
||||
observations
|
||||
.filter((observation) => observation.kind === "declared")
|
||||
@@ -6852,40 +6905,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
|
||||
}
|
||||
|
||||
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
||||
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
|
||||
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
|
||||
// The needle also matches a prompt that quotes it, so a record still has to
|
||||
// parse as a `custom-title` entry carrying a usable name. Records without the
|
||||
// needle are never decoded, so a long one costs a byte search and nothing more.
|
||||
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(record.toString("utf8").trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const entry = toObjectRecord(parsed);
|
||||
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||
return null;
|
||||
}
|
||||
return normalizeClaudeSessionTitle(
|
||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `custom-title` record is re-emitted every time the session is saved, so
|
||||
* the current name is the last usable one in the file. Searching backwards finds it without
|
||||
* walking the transcript, which matters because sessions reach tens of
|
||||
* megabytes.
|
||||
* the current name is the last usable one in the file. Reading backwards a chunk
|
||||
* at a time finds it without walking the transcript, which matters because
|
||||
* sessions reach tens of megabytes, and never holds the file as one string, so a
|
||||
* transcript past V8's maximum string length still imports.
|
||||
*/
|
||||
function readLastClaudeCustomTitle(content: string): string | null {
|
||||
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
|
||||
let at = content.lastIndexOf(needle);
|
||||
while (at >= 0) {
|
||||
const lineStart = content.lastIndexOf("\n", at) + 1;
|
||||
const lineEnd = content.indexOf("\n", at);
|
||||
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line.trim());
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
const entry = toObjectRecord(parsed);
|
||||
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||
const title = normalizeClaudeSessionTitle(
|
||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
|
||||
const handle = await fsPromises.open(filePath, "r");
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile()) {
|
||||
// Opening a directory succeeds on Windows even though reading it does not,
|
||||
// so reject the way reading the transcript as a file would.
|
||||
throw Object.assign(
|
||||
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
|
||||
{ code: "EISDIR", path: filePath },
|
||||
);
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
// The needle also matches a prompt that quotes it, and a record can carry no
|
||||
// usable name, so keep looking backwards for one that does.
|
||||
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
|
||||
let end = stats.size;
|
||||
// Retain unfinished record pieces in reverse file order. Assemble each
|
||||
// complete record once, so a large tool/image record costs linear work.
|
||||
let pieces: Buffer[] = [];
|
||||
while (end > 0) {
|
||||
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
|
||||
const chunk = Buffer.alloc(length);
|
||||
await handle.read(chunk, 0, length, end - length);
|
||||
let recordEnd = chunk.length;
|
||||
for (
|
||||
let at = chunk.lastIndexOf(10);
|
||||
at >= 0;
|
||||
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
|
||||
) {
|
||||
pieces.push(chunk.subarray(at + 1, recordEnd));
|
||||
const record = Buffer.concat(pieces.toReversed());
|
||||
const title = readClaudeCustomTitleFromRecord(record);
|
||||
if (title) return title;
|
||||
pieces = [];
|
||||
recordEnd = at;
|
||||
}
|
||||
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
|
||||
end -= length;
|
||||
}
|
||||
// The first record in the file has no newline in front of it.
|
||||
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
@@ -6897,9 +6989,8 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
sessionId: string;
|
||||
configDir: string;
|
||||
}): Promise<string | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
||||
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
|
||||
} catch (error) {
|
||||
// A session with no transcript on disk has no rename to carry over. Every
|
||||
// other read failure is a real fault and stays explicit.
|
||||
@@ -6908,7 +6999,6 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return readLastClaudeCustomTitle(content);
|
||||
}
|
||||
|
||||
function normalizeImportablePromptPreview(text: string): string | null {
|
||||
|
||||
@@ -156,6 +156,42 @@ describe("ClaudeTaskProtocolSource", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("resumes a child a reopened session restored on its original row", () => {
|
||||
const source = new ClaudeTaskProtocolSource();
|
||||
source.restore([
|
||||
{
|
||||
taskId: "a1730a6215e1f5cf6",
|
||||
id: "toolu_original",
|
||||
presentation: { title: "general-purpose", model: "claude-sonnet-5", effort: "high" },
|
||||
},
|
||||
]);
|
||||
|
||||
// Restored is not running: a canceled turn and an unannounced frame leave it alone.
|
||||
expect(source.cancelRunningForegroundTasks()).toEqual([]);
|
||||
expect(source.resolveSubagentId("toolu_original")).toBeUndefined();
|
||||
|
||||
expect(
|
||||
source.observe(taskStarted({ tool_use_id: "toolu_resumed", prompt: "Resumed prompt" })),
|
||||
).toEqual([
|
||||
{ kind: "status", id: "toolu_original", status: "running" },
|
||||
{
|
||||
kind: "timeline",
|
||||
id: "toolu_original",
|
||||
item: { type: "user_message", text: "Resumed prompt" },
|
||||
},
|
||||
]);
|
||||
expect(source.resolveSubagentId("toolu_original")).toBe("toolu_original");
|
||||
expect(source.resolveSubagentId("toolu_resumed")).toBe("toolu_original");
|
||||
expect(
|
||||
source.observeSidechainFrame(
|
||||
{ type: "assistant", message: { model: "claude-opus-5" } } as unknown as SDKMessage,
|
||||
"toolu_original",
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "subtitle", id: "toolu_original", subtitle: "general-purpose · Opus 5 · High" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("declares a distinct task separately", () => {
|
||||
const source = new ClaudeTaskProtocolSource();
|
||||
source.observe(taskStarted({ task_id: "first", tool_use_id: "toolu_first" }));
|
||||
|
||||
@@ -121,6 +121,16 @@ function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A subagent a reopened session rebuilt from its transcript. Its `taskId` (the transcript's
|
||||
* agentId) is the task id Claude announces again when SendMessage resumes the child.
|
||||
*/
|
||||
export interface ClaudeRestoredSubagent {
|
||||
taskId: string;
|
||||
id: string;
|
||||
presentation: ClaudeSubagentPresentationFacts;
|
||||
}
|
||||
|
||||
export interface ClaudeTaskProtocolSourceInput {
|
||||
/**
|
||||
* The parent's Task tool input, by tool_use id.
|
||||
@@ -162,6 +172,14 @@ export class ClaudeTaskProtocolSource {
|
||||
private readonly backgroundedIds = new Set<string>();
|
||||
/** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */
|
||||
private readonly lastStatusById = new Map<string, ProviderSubagentStatus>();
|
||||
/**
|
||||
* Children a reopened session restored from its transcript, by task id. Claude resumes one under
|
||||
* its old task id with a new tool id, while its frames keep the original Task call as parent.
|
||||
* Without this the resume reads as a new task: a second row, and every frame dropped as
|
||||
* undeclared. Held apart from the declared table so a restored child is left alone until
|
||||
* Claude announces it again.
|
||||
*/
|
||||
private readonly restoredByTaskId = new Map<string, ClaudeRestoredSubagent>();
|
||||
/** Claude facts stay inside the provider boundary; clients receive one compact subtitle. */
|
||||
private readonly presentationById = new Map<string, ClaudeSubagentPresentationFacts>();
|
||||
private readonly lastSubtitleById = new Map<string, string>();
|
||||
@@ -246,6 +264,14 @@ export class ClaudeTaskProtocolSource {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remember the children a reopened session restored, for Claude to resume by task id. */
|
||||
restore(subagents: Iterable<ClaudeRestoredSubagent>): void {
|
||||
for (const subagent of subagents) {
|
||||
if (this.subagentIdByTaskId.has(subagent.taskId)) continue;
|
||||
this.restoredByTaskId.set(subagent.taskId, subagent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the session.
|
||||
*
|
||||
@@ -259,6 +285,7 @@ export class ClaudeTaskProtocolSource {
|
||||
this.ownerSubagentIdByToolUseId.clear();
|
||||
this.ownerSubagentIdByTaskId.clear();
|
||||
this.declaredIds.clear();
|
||||
this.restoredByTaskId.clear();
|
||||
this.workflowTaskIds.clear();
|
||||
this.lastWorkflowResultByTaskId.clear();
|
||||
this.idsWithExistingParentToolCard.clear();
|
||||
@@ -315,7 +342,8 @@ export class ClaudeTaskProtocolSource {
|
||||
if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return [];
|
||||
|
||||
this.sawTaskStarted = true;
|
||||
const existingId = this.subagentIdByTaskId.get(message.task_id);
|
||||
const existingId =
|
||||
this.subagentIdByTaskId.get(message.task_id) ?? this.adoptRestored(message.task_id);
|
||||
this.recordBackgrounded(existingId ?? id, message.is_backgrounded);
|
||||
if (existingId) {
|
||||
return this.observeExistingTaskStart(message, id, existingId);
|
||||
@@ -324,6 +352,20 @@ export class ClaudeTaskProtocolSource {
|
||||
return this.observeNewTaskStart(message, id, parentSubagentId);
|
||||
}
|
||||
|
||||
/** Declare a restored child under the id its transcript gave it, with what it already showed. */
|
||||
private adoptRestored(taskId: string): string | undefined {
|
||||
const restored = this.restoredByTaskId.get(taskId);
|
||||
if (!restored) return undefined;
|
||||
this.restoredByTaskId.delete(taskId);
|
||||
this.subagentIdByTaskId.set(taskId, restored.id);
|
||||
this.canonicalIdByToolUseId.set(restored.id, restored.id);
|
||||
this.declaredIds.add(restored.id);
|
||||
this.presentationById.set(restored.id, restored.presentation);
|
||||
const subtitle = buildClaudeSubagentSubtitle(restored.presentation);
|
||||
if (subtitle) this.lastSubtitleById.set(restored.id, subtitle);
|
||||
return restored.id;
|
||||
}
|
||||
|
||||
private observeExistingTaskStart(
|
||||
message: TaskStartedMessage,
|
||||
toolUseId: string,
|
||||
|
||||
@@ -85,6 +85,50 @@ describe("parseClaudeSubagentMeta", () => {
|
||||
});
|
||||
|
||||
describe("observeReplaySubagents", () => {
|
||||
it("restores a resumed child on its original row after consuming async history", async () => {
|
||||
async function* entries(): AsyncGenerator<ClaudeReplayEntry> {
|
||||
yield { type: "assistant", message: { model: "claude-opus-5" } };
|
||||
yield {
|
||||
type: "assistant",
|
||||
effort: "high",
|
||||
message: { model: "claude-sonnet-5", usage: { input_tokens: 10, output_tokens: 2 } },
|
||||
};
|
||||
}
|
||||
const replay = await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: entries() }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: async () => [],
|
||||
});
|
||||
expect(replay.restored).toEqual([
|
||||
{
|
||||
taskId: AGENT_ID,
|
||||
id: TOOL_USE_ID,
|
||||
presentation: {
|
||||
title: "general-purpose",
|
||||
model: "claude-sonnet-5",
|
||||
effort: "high",
|
||||
usage: { totalTokens: 12 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const live = new ClaudeTaskProtocolSource();
|
||||
live.restore(replay.restored);
|
||||
expect(
|
||||
live.observe({
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
task_id: AGENT_ID,
|
||||
tool_use_id: "resumed-tool",
|
||||
description: "Resume the captured task",
|
||||
task_type: "local_agent",
|
||||
is_backgrounded: true,
|
||||
uuid: "00000000-0000-0000-0000-000000000000",
|
||||
session_id: "reopened-session",
|
||||
}),
|
||||
).toEqual([{ kind: "status", id: TOOL_USE_ID, status: "running" }]);
|
||||
expect(live.resolveSubagentId("resumed-tool")).toBe(TOOL_USE_ID);
|
||||
});
|
||||
|
||||
it("links a subagent to its Task call through meta.toolUseId", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeProviderReplayTimestamp } from "../../../provider-history-time
|
||||
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
|
||||
import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js";
|
||||
import { resolveObservedClaudeModelId } from "../models.js";
|
||||
import type { ClaudeRestoredSubagent } from "./live-source.js";
|
||||
import type { SubagentObservation } from "./observation.js";
|
||||
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
|
||||
|
||||
@@ -246,6 +247,7 @@ async function observeSubagent(
|
||||
parent: ClaudeReplayParentFacts,
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
|
||||
toolOwners: Map<string, string>,
|
||||
restored: ClaudeRestoredSubagent[],
|
||||
parentSubagentId?: string,
|
||||
): Promise<SubagentObservation[]> {
|
||||
const link = resolveParentLink(subagent, parent);
|
||||
@@ -276,13 +278,14 @@ async function observeSubagent(
|
||||
const observations = [
|
||||
declareSubagent(subagent, link, toolCall, parentSubagentId, firstTimestamp),
|
||||
];
|
||||
const subtitle = observeSubtitle(
|
||||
link,
|
||||
toolCall?.title ?? subagent.meta?.agentType,
|
||||
runtime,
|
||||
usage,
|
||||
lastTimestamp,
|
||||
);
|
||||
const title = toolCall?.title ?? subagent.meta?.agentType;
|
||||
// The agentId is the task_id Claude resumes this child under, so live can find it again.
|
||||
restored.push({
|
||||
taskId: subagent.agentId,
|
||||
id: link.id,
|
||||
presentation: { ...(title ? { title } : {}), ...runtime, ...(usage ? { usage } : {}) },
|
||||
});
|
||||
const subtitle = observeSubtitle(link, title, runtime, usage, lastTimestamp);
|
||||
if (subtitle) observations.push(subtitle);
|
||||
for (const observation of timeline) observations.push(observation);
|
||||
const terminalStatus = link.status ?? childTerminalStatus;
|
||||
@@ -337,9 +340,14 @@ export async function observeReplaySubagents(input: {
|
||||
subagents: readonly ClaudeReplaySubagentInput[];
|
||||
parent: ClaudeReplayParentFacts;
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
|
||||
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
|
||||
}): Promise<{
|
||||
observations: SubagentObservation[];
|
||||
toolOwners: ReadonlyMap<string, string>;
|
||||
restored: readonly ClaudeRestoredSubagent[];
|
||||
}> {
|
||||
const observations: SubagentObservation[] = [];
|
||||
const toolOwners = new Map<string, string>();
|
||||
const restored: ClaudeRestoredSubagent[] = [];
|
||||
const unresolved = [...input.subagents].sort(
|
||||
(left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1),
|
||||
);
|
||||
@@ -364,6 +372,7 @@ export async function observeReplaySubagents(input: {
|
||||
parent,
|
||||
input.convertEntry,
|
||||
toolOwners,
|
||||
restored,
|
||||
ownerId,
|
||||
)) {
|
||||
observations.push(observation);
|
||||
@@ -373,7 +382,7 @@ export async function observeReplaySubagents(input: {
|
||||
madeProgress = true;
|
||||
}
|
||||
}
|
||||
return { observations, toolOwners };
|
||||
return { observations, toolOwners, restored };
|
||||
}
|
||||
|
||||
function resolveReplayOwner(
|
||||
|
||||
@@ -264,6 +264,98 @@ describe("Codex app-server provider (local e2e)", () => {
|
||||
30_000,
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"reopens a promptless agent repeatedly before its first model request",
|
||||
async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-promptless-cwd-"));
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(path.join(cwd, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
let agentId: string | undefined;
|
||||
try {
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
agentId = created.id;
|
||||
// A real app-server rejects an unsaved thread on resume. No model request is
|
||||
// needed to prove that reopening the agent remains safe before its first turn.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const reopened = await manager.reloadAgentSession(agentId);
|
||||
expect(reopened.lifecycle).toBe("idle");
|
||||
expect(reopened.persistence).toBeNull();
|
||||
}
|
||||
await storage.flush();
|
||||
expect((await storage.get(agentId))?.persistence).toBeNull();
|
||||
} finally {
|
||||
if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId);
|
||||
await storage.flush();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"reopens an agent that was never prompted without resuming its unsaved thread",
|
||||
async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-cwd-"));
|
||||
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-home-"));
|
||||
const mockServer = await startMockResponsesServer([
|
||||
assistantMessageSse("first"),
|
||||
assistantMessageSse("second"),
|
||||
]);
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(path.join(cwd, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
let agentId: string | undefined;
|
||||
try {
|
||||
writeMockCodexConfig(codexHome, mockServer.url);
|
||||
vi.stubEnv("CODEX_HOME", codexHome);
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
agentId = created.id;
|
||||
// Codex writes a thread's rollout at its first turn, so there is nothing to resume yet.
|
||||
expect(manager.getAgent(agentId)?.persistence).toBeNull();
|
||||
await storage.flush();
|
||||
expect((await storage.get(agentId))?.persistence).toBeNull();
|
||||
|
||||
await manager.reloadAgentSession(agentId);
|
||||
await manager.runAgent(agentId, "first prompt after reopening");
|
||||
const threadId = manager.getAgent(agentId)?.persistence?.sessionId;
|
||||
expect(threadId).toEqual(expect.any(String));
|
||||
await storage.flush();
|
||||
expect((await storage.get(agentId))?.persistence?.sessionId).toBe(threadId);
|
||||
|
||||
const reloaded = await manager.reloadAgentSession(agentId);
|
||||
expect(reloaded.persistence?.sessionId).toBe(threadId);
|
||||
await manager.runAgent(agentId, "continue the saved thread");
|
||||
expect(manager.getAgent(agentId)?.lifecycle).toBe("idle");
|
||||
expect(mockServer.requestBodies.at(-1)).toContain("first prompt after reopening");
|
||||
} finally {
|
||||
if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId);
|
||||
await storage.flush();
|
||||
vi.unstubAllEnvs();
|
||||
await mockServer.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"surfaces request_user_input from the app-server as question permissions and timeline tool calls",
|
||||
async () => {
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
createFakeCodexAppServer,
|
||||
fakeCodexThreadHistory,
|
||||
type FakeCodexAppServer,
|
||||
waitForNextEvent,
|
||||
waitForNextPermission,
|
||||
waitForNextTimelineItem,
|
||||
waitForProviderSubagent,
|
||||
@@ -1287,6 +1288,9 @@ describe("Codex app-server provider", () => {
|
||||
);
|
||||
await firstSession.startTurn("first turn");
|
||||
await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
|
||||
const saved = waitForNextEvent(firstSession, "thread_started");
|
||||
beforeRestart.startsTurn({ threadId: "thread-1" });
|
||||
await saved;
|
||||
const handle = firstSession.describePersistence()!;
|
||||
await firstSession.close();
|
||||
|
||||
@@ -1319,6 +1323,9 @@ describe("Codex app-server provider", () => {
|
||||
try {
|
||||
await session.startTurn("persist the configured session");
|
||||
await appServer.waitForTurnStart();
|
||||
const saved = waitForNextEvent(session, "thread_started");
|
||||
appServer.startsTurn({ threadId: "thread-1" });
|
||||
await saved;
|
||||
const handle = session.describePersistence()!;
|
||||
expect(handle.metadata).not.toHaveProperty("providerOptions");
|
||||
appServer.assertNoErrors();
|
||||
@@ -1327,6 +1334,55 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("reports a new thread only after Codex announces its first turn", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = await createProviderWithFakeAppServer(appServer).createSession(createConfig());
|
||||
try {
|
||||
// Codex has started the thread but writes its rollout at the first turn, so another
|
||||
// app-server could not resume it yet.
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: null });
|
||||
expect(session.describePersistence()).toBeNull();
|
||||
expect(
|
||||
appServer.requests().filter((request) => request.method === "thread/start"),
|
||||
).toHaveLength(1);
|
||||
|
||||
await session.startTurn("first prompt");
|
||||
await appServer.waitForTurnStart();
|
||||
expect(session.describePersistence()).toBeNull();
|
||||
|
||||
const saved = waitForNextEvent(session, "thread_started");
|
||||
appServer.startsTurn({ threadId: "thread-1" });
|
||||
await expect(saved).resolves.toMatchObject({
|
||||
type: "thread_started",
|
||||
provider: "codex",
|
||||
sessionId: "thread-1",
|
||||
});
|
||||
expect(session.describePersistence()).toMatchObject({
|
||||
sessionId: "thread-1",
|
||||
nativeHandle: "thread-1",
|
||||
});
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: "thread-1" });
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reports a resumed thread before its next turn", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = await createProviderWithFakeAppServer(appServer).resumeSession(
|
||||
{ provider: "codex", sessionId: "saved-thread" },
|
||||
createConfig(),
|
||||
);
|
||||
try {
|
||||
expect(session.describePersistence()).toMatchObject({ sessionId: "saved-thread" });
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: "saved-thread" });
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("preapproves only granted tools on the injected Codex MCP server", async () => {
|
||||
const session = createSession({
|
||||
modeId: undefined,
|
||||
@@ -2604,7 +2660,12 @@ describe("Codex app-server provider", () => {
|
||||
clientMessageId: "client-message",
|
||||
},
|
||||
});
|
||||
expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]);
|
||||
// The first turn of a new thread also reports the thread, now that Codex has saved it.
|
||||
expect(events.slice(0, 3).map((event) => event.type)).toEqual([
|
||||
"thread_started",
|
||||
"turn_started",
|
||||
"timeline",
|
||||
]);
|
||||
appServer.completeTurn();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
@@ -150,6 +150,15 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
|
||||
|
||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||
const INTERRUPT_TIMEOUT_MS = 2_000;
|
||||
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
|
||||
// Each terminate is bounded by the first constant. A turn start waits for the
|
||||
// interrupted turn's commands to end, at most the interrupt's own bound plus one
|
||||
// terminate's and a margin, so it gives up only when a request outlives its timeout.
|
||||
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
|
||||
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
|
||||
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
|
||||
// Turns whose commands are remembered; older turns are forgotten first.
|
||||
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
|
||||
const CODEX_PROVIDER = "codex" as const;
|
||||
// These native provider identities belong to Codex, not to an older custom
|
||||
// Paseo provider. Never include them in a custom provider's legacy import scan.
|
||||
@@ -3435,6 +3444,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
> | null = null;
|
||||
private resolvedSandboxPolicy: Record<string, unknown> | null = null;
|
||||
private currentThreadId: string | null = null;
|
||||
// Codex writes a new thread's rollout at its first turn. Until then the thread exists only
|
||||
// in this app-server, so no other app-server can resume it and it is not reported.
|
||||
private savedThreadId: string | null = null;
|
||||
private currentTurnId: string | null = null;
|
||||
private pendingForegroundTurnIdentification: {
|
||||
foregroundTurnId: string;
|
||||
@@ -3473,6 +3485,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||
private pendingAssistantMessageBoundary = false;
|
||||
private terminalCommandByProcessId = new Map<string, string>();
|
||||
// The root thread's still-running command process ids, keyed by the native turn that
|
||||
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
|
||||
// `item/started` is the only record of which turn owns a process.
|
||||
private terminalProcessIdsByTurnId = new Map<
|
||||
string,
|
||||
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
|
||||
>();
|
||||
private interruptedTurnCleanup: Promise<void> | null = null;
|
||||
private pendingUnlabeledTerminalInteractions = new Map<
|
||||
string,
|
||||
Array<{ callId: string; stdin: string | null }>
|
||||
@@ -3572,6 +3592,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
if (this.resumeHandle?.sessionId) {
|
||||
this.currentThreadId = this.resumeHandle.sessionId;
|
||||
this.savedThreadId = this.resumeHandle.sessionId;
|
||||
this.historyIndexed = false;
|
||||
// Resuming can start a goal before the caller receives the session.
|
||||
if (this.initialResumePurpose === "interactive") this.startupEvents = [];
|
||||
@@ -4427,6 +4448,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
||||
hasCodexConfig: turnStart.hasCodexConfig,
|
||||
});
|
||||
// The interrupted turn's commands end before this turn begins, so none of them
|
||||
// outlives it into the new turn.
|
||||
await this.waitForInterruptedTurnCleanup();
|
||||
if (pendingStart.cancelRequested) {
|
||||
throw new Error("Codex turn start was interrupted before reaching Codex");
|
||||
}
|
||||
@@ -4605,7 +4629,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
const info: AgentRuntimeInfo = {
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.currentThreadId,
|
||||
sessionId: this.savedCurrentThreadId(),
|
||||
model: this.config.model ?? null,
|
||||
thinkingOptionId: normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null,
|
||||
modeId: this.currentMode ?? null,
|
||||
@@ -4936,17 +4960,18 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
nativeHandle: string;
|
||||
metadata: Record<string, unknown>;
|
||||
} | null {
|
||||
if (!this.currentThreadId) return null;
|
||||
const threadId = this.savedCurrentThreadId();
|
||||
if (!threadId) return null;
|
||||
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null;
|
||||
return {
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.currentThreadId,
|
||||
nativeHandle: this.currentThreadId,
|
||||
sessionId: threadId,
|
||||
nativeHandle: threadId,
|
||||
metadata: {
|
||||
provider: CODEX_PROVIDER,
|
||||
cwd: this.config.cwd,
|
||||
title: this.config.title ?? null,
|
||||
threadId: this.currentThreadId,
|
||||
threadId,
|
||||
modeId: this.config.modeId,
|
||||
model: this.config.model ?? null,
|
||||
thinkingOptionId,
|
||||
@@ -4958,6 +4983,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
private savedCurrentThreadId(): string | null {
|
||||
return this.currentThreadId !== null && this.currentThreadId === this.savedThreadId
|
||||
? this.currentThreadId
|
||||
: null;
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
@@ -4982,6 +5013,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
threadRollbackAvailable: this.threadRollbackAvailable,
|
||||
setThreadId: async (threadId) => {
|
||||
this.currentThreadId = threadId;
|
||||
// Codex writes a forked thread's rollout when it forks it, even before any turn.
|
||||
this.savedThreadId = threadId;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.stagedHistory = await this.readThreadHistory(this.client);
|
||||
this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline);
|
||||
@@ -5023,15 +5056,27 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
||||
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
||||
}
|
||||
const client = this.client;
|
||||
const threadId = this.currentThreadId;
|
||||
const interruptRequest = client.request(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId,
|
||||
turnId,
|
||||
},
|
||||
INTERRUPT_TIMEOUT_MS,
|
||||
);
|
||||
// Once Codex accepts the interrupt, end the commands this turn started. Registered
|
||||
// before the await, so a turn start racing this interrupt waits for it. An already-idle
|
||||
// turn was not interrupted, and what it left running stays.
|
||||
this.trackInterruptedTurnCleanup(
|
||||
interruptRequest.then(
|
||||
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await this.client.request(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: this.currentThreadId,
|
||||
turnId,
|
||||
},
|
||||
INTERRUPT_TIMEOUT_MS,
|
||||
);
|
||||
await interruptRequest;
|
||||
} catch (error) {
|
||||
if (!isCodexAlreadyIdleInterrupt(error)) {
|
||||
throw error;
|
||||
@@ -5072,6 +5117,137 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return !this.client.hasPendingRequests();
|
||||
}
|
||||
|
||||
// Remember which turn started a command, so an interrupt ends that turn's commands and
|
||||
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
|
||||
// A command that starts after its turn's interrupt was accepted is ended as soon as it
|
||||
// is seen.
|
||||
private recordTurnTerminalProcess(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
|
||||
): void {
|
||||
const processId = parsed.item.processId;
|
||||
if (
|
||||
parsed.source !== "item" ||
|
||||
!parsed.turnId ||
|
||||
!parsed.threadId ||
|
||||
parsed.threadId !== this.currentThreadId ||
|
||||
typeof processId !== "string" ||
|
||||
!processId ||
|
||||
normalizeCodexThreadItemType(
|
||||
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
|
||||
) !== "commandExecution"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
|
||||
if (parsed.kind === "item_completed") {
|
||||
// Only a completed command has exited. A failed one can still be running:
|
||||
// Codex reports an interrupted turn's live command as failed.
|
||||
if (parsed.item.status === "completed") record?.processIds.delete(processId);
|
||||
return;
|
||||
}
|
||||
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
|
||||
current.processIds.add(processId);
|
||||
if (current.interruptAccepted && this.client) {
|
||||
this.trackInterruptedTurnCleanup(
|
||||
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// An accepted interrupt needs a record even before any of its turn's commands is seen:
|
||||
// an `item/started` can still be queued behind an earlier notification's conversion.
|
||||
private getTurnTerminalRecord(turnId: string) {
|
||||
const existing = this.terminalProcessIdsByTurnId.get(turnId);
|
||||
if (existing) return existing;
|
||||
const record = {
|
||||
processIds: new Set<string>(),
|
||||
terminatedProcessIds: new Set<string>(),
|
||||
interruptAccepted: false,
|
||||
};
|
||||
this.terminalProcessIdsByTurnId.set(turnId, record);
|
||||
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
|
||||
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
|
||||
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
// A later sweep joins the pending one, so a turn start waits for both.
|
||||
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
|
||||
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
|
||||
});
|
||||
this.interruptedTurnCleanup = tracked;
|
||||
}
|
||||
|
||||
// Terminate each process the interrupted turn started, by its own id, once each.
|
||||
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
|
||||
// an earlier turn's included. A process already gone answers `terminated: false`. Never
|
||||
// rejects; failures are logged.
|
||||
private async endInterruptedTurnProcesses(
|
||||
client: CodexAppServerClient,
|
||||
threadId: string | null,
|
||||
turnId: string,
|
||||
): Promise<void> {
|
||||
if (!threadId) return;
|
||||
const record = this.getTurnTerminalRecord(turnId);
|
||||
record.interruptAccepted = true;
|
||||
const processIds = [...record.processIds].filter(
|
||||
(processId) => !record.terminatedProcessIds.has(processId),
|
||||
);
|
||||
for (const processId of processIds) record.terminatedProcessIds.add(processId);
|
||||
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
|
||||
await Promise.all(
|
||||
processIds.map(async (processId) => {
|
||||
try {
|
||||
const response = await client.request(
|
||||
"thread/backgroundTerminals/terminate",
|
||||
{ threadId, processId },
|
||||
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
|
||||
);
|
||||
this.logger.info(
|
||||
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
|
||||
"provider.codex.interrupt.terminal_terminated",
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ ...logContext, processId, err: error },
|
||||
"provider.codex.interrupt.terminal_terminate_failed",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// A new turn starts only after every pending sweep of interrupted turns' commands has
|
||||
// ended, or after the bound, whichever is first.
|
||||
private async waitForInterruptedTurnCleanup(): Promise<void> {
|
||||
if (!this.interruptedTurnCleanup) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = await Promise.race([
|
||||
(async () => {
|
||||
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
|
||||
return false;
|
||||
})(),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
this.logger.warn(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
|
||||
},
|
||||
"provider.codex.interrupt.terminal_cleanup_timed_out",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.clearPendingPermissions();
|
||||
@@ -6155,6 +6331,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true });
|
||||
return;
|
||||
}
|
||||
this.markCurrentThreadSaved(parsed.threadId);
|
||||
this.currentTurnId = parsed.turnId;
|
||||
const pendingIdentification = this.pendingForegroundTurnIdentification;
|
||||
if (
|
||||
@@ -6168,6 +6345,17 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
|
||||
}
|
||||
|
||||
// Codex has written the rollout by the time it announces a thread's turn, including a
|
||||
// goal's or a compaction's. From here on the thread can be reported and resumed.
|
||||
private markCurrentThreadSaved(threadId: string | null): void {
|
||||
const current = this.currentThreadId;
|
||||
if (!current || (threadId !== null && threadId !== current)) return;
|
||||
if (this.savedThreadId === current) return;
|
||||
this.savedThreadId = current;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: current });
|
||||
}
|
||||
|
||||
private handleTurnCompletedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "turn_completed" }>,
|
||||
): void {
|
||||
@@ -6184,6 +6372,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.completePendingRootCompactions();
|
||||
// What a turn that was not interrupted leaves running, it left on purpose; no later
|
||||
// interrupt may end it.
|
||||
if (parsed.status !== "interrupted" && this.currentTurnId) {
|
||||
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
|
||||
}
|
||||
if (parsed.status === "failed") {
|
||||
this.emitEvent({
|
||||
type: "turn_failed",
|
||||
@@ -6588,6 +6781,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private async handleItemCompletedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||
): Promise<void> {
|
||||
this.recordTurnTerminalProcess(parsed);
|
||||
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
||||
// and canonical `item/*`. Render ordinary items only from the canonical
|
||||
// channel, but accept a legacy-only child announcement so it can establish
|
||||
@@ -6750,6 +6944,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private async handleItemStartedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
||||
): Promise<void> {
|
||||
this.recordTurnTerminalProcess(parsed);
|
||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { AgentSession } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
type FakeCodexAppServer,
|
||||
waitForNextEvent,
|
||||
waitForTimelineToolCall,
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
|
||||
const THREAD = "thread-1";
|
||||
const TERMINATE = "thread/backgroundTerminals/terminate";
|
||||
|
||||
type Handler = (params: unknown) => unknown;
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function paramsOf(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
let sessions: AgentSession[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions) await session.close().catch(() => undefined);
|
||||
sessions = [];
|
||||
});
|
||||
|
||||
/**
|
||||
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
|
||||
* turn completion, then the response. `terminate` answers per process.
|
||||
*/
|
||||
function createHarness(
|
||||
options: {
|
||||
terminate?: (processId: string) => unknown;
|
||||
interruptResponse?: (completeTurn: () => void) => unknown;
|
||||
} = {},
|
||||
) {
|
||||
const handlers: Record<string, Handler> = {
|
||||
"turn/interrupt": () => {
|
||||
const completeTurn = () =>
|
||||
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
|
||||
if (options.interruptResponse) return options.interruptResponse(completeTurn);
|
||||
completeTurn();
|
||||
return {};
|
||||
},
|
||||
[TERMINATE]: (params) =>
|
||||
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
|
||||
};
|
||||
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: "/workspace/project",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
sessions.push(session);
|
||||
|
||||
function requests(method: string): Record<string, unknown>[] {
|
||||
return appServer
|
||||
.requests()
|
||||
.filter((message) => message.method === method && "id" in message)
|
||||
.map((message) => paramsOf(message.params));
|
||||
}
|
||||
|
||||
function terminated(): string[] {
|
||||
return requests(TERMINATE)
|
||||
.map((params) => String(params.processId))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Starts a turn and lets Codex identify it as `nativeTurnId`.
|
||||
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
|
||||
const before = requests("turn/start").length;
|
||||
const started = session.startTurn(prompt);
|
||||
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
|
||||
timeout: 5_000,
|
||||
});
|
||||
const turnStarted = waitForNextEvent(session, "turn_started");
|
||||
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
|
||||
await started;
|
||||
await turnStarted;
|
||||
}
|
||||
|
||||
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
|
||||
const itemId = `exec-${params.processId}`;
|
||||
const shown = waitForTimelineToolCall(session, itemId);
|
||||
appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: params.turnId,
|
||||
itemId,
|
||||
processId: params.processId,
|
||||
command: `sleep ${params.processId}`,
|
||||
});
|
||||
await shown;
|
||||
}
|
||||
|
||||
async function completesTurn(): Promise<void> {
|
||||
const completed = waitForNextEvent(session, "turn_completed");
|
||||
appServer.completeTurn({ threadId: THREAD });
|
||||
await completed;
|
||||
}
|
||||
|
||||
// Interrupts the running turn and waits for the session to report it canceled, as a
|
||||
// caller does before it starts the next turn: Codex notifications are handled in order
|
||||
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
|
||||
async function interrupts(): Promise<void> {
|
||||
const canceled = waitForNextEvent(session, "turn_canceled");
|
||||
await session.interrupt();
|
||||
await canceled;
|
||||
}
|
||||
|
||||
return {
|
||||
appServer,
|
||||
session,
|
||||
requests,
|
||||
terminated,
|
||||
startTurn,
|
||||
runsCommand,
|
||||
completesTurn,
|
||||
interrupts,
|
||||
};
|
||||
}
|
||||
|
||||
// Turn A leaves a server running on purpose and completes. Turn B starts two
|
||||
// commands, and a subagent on its own thread starts a third, then B is interrupted.
|
||||
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
|
||||
await harness.startTurn("start a server", "native-A");
|
||||
await harness.runsCommand({ turnId: "native-A", processId: "101" });
|
||||
await harness.completesTurn();
|
||||
|
||||
await harness.startTurn("run two commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
// A subagent thread's command shows no row here; the next command's row shows it
|
||||
// was read, because notifications are read in order.
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: "child-thread",
|
||||
turnId: "native-B",
|
||||
itemId: "exec-209",
|
||||
processId: "209",
|
||||
command: "sleep 209",
|
||||
});
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
||||
await harness.interrupts();
|
||||
}
|
||||
|
||||
describe("an interrupted Codex turn's commands end before the next turn", () => {
|
||||
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
|
||||
const harness = createHarness();
|
||||
await interruptTurnB(harness);
|
||||
await harness.startTurn("correction", "native-C");
|
||||
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
|
||||
// `clean` would end every terminal on the thread, an earlier turn's included.
|
||||
expect(
|
||||
harness.appServer
|
||||
.requests()
|
||||
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
|
||||
.map((message) => message.method),
|
||||
).toEqual([TERMINATE, TERMINATE]);
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
|
||||
const acknowledge = deferred();
|
||||
const harness = createHarness({
|
||||
// Codex ends B, starts a turn of its own (a goal continuation), and only then
|
||||
// answers the interrupt.
|
||||
interruptResponse: (completeTurn) => {
|
||||
completeTurn();
|
||||
return acknowledge.promise.then(() => ({}));
|
||||
},
|
||||
});
|
||||
await harness.startTurn("run two commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
||||
const interrupting = harness.session.interrupt();
|
||||
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
|
||||
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
|
||||
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
|
||||
await autonomousTurn;
|
||||
await harness.runsCommand({ turnId: "native-C", processId: "301" });
|
||||
|
||||
acknowledge.resolve();
|
||||
await interrupting;
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
});
|
||||
|
||||
test("sends the next turn/start only after the terminates are answered", async () => {
|
||||
const answer = deferred();
|
||||
const harness = createHarness({
|
||||
terminate: () => answer.promise.then(() => ({ terminated: true })),
|
||||
});
|
||||
await interruptTurnB(harness);
|
||||
const next = harness.startTurn("correction", "native-C");
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(harness.requests("turn/start")).toHaveLength(2);
|
||||
|
||||
answer.resolve();
|
||||
await next;
|
||||
expect(harness.requests("turn/start")).toHaveLength(3);
|
||||
});
|
||||
|
||||
test.each(["initial", "late"] as const)(
|
||||
"tracks both cleanup sweeps when the %s answer arrives first",
|
||||
async (answeredFirst) => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
const harness = createHarness({
|
||||
terminate: (processId) =>
|
||||
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
|
||||
});
|
||||
await harness.startTurn("run commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
await harness.interrupts();
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
|
||||
const next = harness.startTurn("correction", "native-C");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||
// The next turn is already waiting when another process is announced.
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "late-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "duplicate-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
(answeredFirst === "initial" ? first : second).resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||
(answeredFirst === "initial" ? second : first).resolve();
|
||||
await next;
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
// Accepted state survives the completed sweep and a newer foreground turn.
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "late-203",
|
||||
processId: "203",
|
||||
command: "sleep 203",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
|
||||
harness.appServer.assertNoErrors();
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
|
||||
const first = deferred();
|
||||
const harness = createHarness({
|
||||
terminate: (processId) =>
|
||||
processId === "201"
|
||||
? first.promise.then(() => ({ terminated: true }))
|
||||
: { terminated: true },
|
||||
});
|
||||
await harness.startTurn("run commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
await harness.interrupts();
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "late-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
const next = harness.startTurn("correction", "native-C");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||
first.resolve();
|
||||
await next;
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("forgets completed commands but retains failed commands at interrupt", async () => {
|
||||
const harness = createHarness();
|
||||
await harness.startTurn("run commands", "native-B");
|
||||
for (const processId of ["201", "202"]) {
|
||||
await harness.runsCommand({ turnId: "native-B", processId });
|
||||
harness.appServer.child.stdout.write(
|
||||
JSON.stringify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
item: {
|
||||
id: `exec-${processId}`,
|
||||
type: "commandExecution",
|
||||
processId,
|
||||
command: `sleep ${processId}`,
|
||||
status: processId === "201" ? "completed" : "failed",
|
||||
exitCode: processId === "201" ? 0 : null,
|
||||
},
|
||||
},
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
// A following command's row is a barrier for both completion handlers.
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "203" });
|
||||
await harness.interrupts();
|
||||
await harness.startTurn("correction", "native-C");
|
||||
expect(harness.terminated()).toEqual(["202", "203"]);
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("retains accepted interrupts for only the latest sixteen turns", async () => {
|
||||
const harness = createHarness();
|
||||
for (let index = 0; index < 17; index++) {
|
||||
await harness.startTurn("run a command", `native-${index}`);
|
||||
await harness.interrupts();
|
||||
}
|
||||
await harness.startTurn("correction", "native-current");
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-0",
|
||||
itemId: "evicted-201",
|
||||
processId: "201",
|
||||
command: "sleep 201",
|
||||
});
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-16",
|
||||
itemId: "retained-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
await harness.runsCommand({ turnId: "native-current", processId: "301" });
|
||||
expect(harness.terminated()).toEqual(["202"]);
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
|
||||
const harness = createHarness({
|
||||
// 201 is refused while the next turn waits; 202 is never answered.
|
||||
terminate: (processId) =>
|
||||
processId === "201"
|
||||
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
|
||||
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
|
||||
}))
|
||||
: new Promise(() => {}),
|
||||
});
|
||||
await interruptTurnB(harness);
|
||||
const startedAt = Date.now();
|
||||
await harness.startTurn("correction", "native-C");
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(5_000);
|
||||
expect(harness.requests("turn/start")).toHaveLength(3);
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -4,6 +4,102 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
||||
import type { V2Api } from "./api.js";
|
||||
import { OpenCodeV2AgentClient } from "./agent.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
|
||||
|
||||
describe("OpenCode v2 denial reasons", () => {
|
||||
test.each<{
|
||||
response: AgentPermissionResponse;
|
||||
decision: "reject" | "once" | "always";
|
||||
message?: string;
|
||||
}>([
|
||||
{
|
||||
response: { behavior: "deny", message: "Use the existing file instead." },
|
||||
decision: "reject",
|
||||
message: "Use the existing file instead.",
|
||||
},
|
||||
{ response: { behavior: "deny" }, decision: "reject" },
|
||||
{ response: { behavior: "allow" }, decision: "once" },
|
||||
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
|
||||
])("preserves a permission response: $response", async ({ response, decision, message }) => {
|
||||
const harness = new V2Harness();
|
||||
let pending = true;
|
||||
harness.api.permission.list = async () =>
|
||||
pending
|
||||
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
||||
: [];
|
||||
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
||||
harness.api.permission.reply = async (input) => {
|
||||
replies.push(input);
|
||||
pending = false;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
await session.respondToPermission("approval", response);
|
||||
expect(replies).toEqual([
|
||||
{
|
||||
sessionID: "session",
|
||||
requestID: "approval",
|
||||
decision,
|
||||
...(message === undefined ? {} : { message }),
|
||||
},
|
||||
]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"explains cards superseded by a steer when clearing is %s",
|
||||
async (clearPendingPermissions) => {
|
||||
const harness = new V2Harness();
|
||||
harness.autoComplete = false;
|
||||
let pending = true;
|
||||
harness.api.permission.list = async () =>
|
||||
pending
|
||||
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
||||
: [];
|
||||
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
||||
harness.api.permission.reply = async (input) => {
|
||||
replies.push(input);
|
||||
pending = false;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
const { turnId } = await session.startTurn("Run pwd");
|
||||
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
|
||||
await expect(
|
||||
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
|
||||
expect(replies).toEqual(
|
||||
clearPendingPermissions
|
||||
? [
|
||||
{
|
||||
sessionID: "session",
|
||||
requestID: "approval",
|
||||
decision: "reject",
|
||||
message:
|
||||
"The user answered with a message instead of approving. Their message follows.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("OpenCode v2 questions", () => {
|
||||
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
|
||||
|
||||
@@ -68,6 +68,9 @@ export class SessionPermissions {
|
||||
sessionID: this.permissionOwners.get(requestId) ?? this.id,
|
||||
requestID: requestId,
|
||||
decision: permissionReply(response),
|
||||
...(response.behavior === "deny" && response.message !== undefined
|
||||
? { message: response.message }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
this.resolvePending(requestId, response);
|
||||
|
||||
@@ -100,7 +100,11 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
),
|
||||
clearPermissions: async () => {
|
||||
for (const request of this.permissions.list())
|
||||
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
|
||||
await this.permissions.respondToPermission(request.id, {
|
||||
behavior: "deny",
|
||||
message:
|
||||
"The user answered with a message instead of approving. Their message follows.",
|
||||
});
|
||||
},
|
||||
});
|
||||
this.usage = new SessionUsage({
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||
@@ -23,6 +25,7 @@ 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";
|
||||
@@ -2719,6 +2722,7 @@ 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(
|
||||
@@ -3532,6 +3536,320 @@ 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 { join, resolve as resolvePath } from "node:path";
|
||||
import { basename, join, resolve as resolvePath } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { z } from "zod";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type AgentPersistenceHandle,
|
||||
type AgentPromptInput,
|
||||
type AgentProvider,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
@@ -75,8 +76,13 @@ import {
|
||||
piExtensionRuntimeBridge,
|
||||
type PiExtensionEventOutput,
|
||||
} from "./extensions/index.js";
|
||||
import { resolveResumeProcessCwd } from "../resume-process-cwd.js";
|
||||
import { revertPiConversation } from "./rewind.js";
|
||||
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
|
||||
import {
|
||||
copyPiSessionFileWithCwd,
|
||||
listPiImportableSessions,
|
||||
readPiImportSessionConfig,
|
||||
} from "./session-descriptor.js";
|
||||
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
|
||||
import type {
|
||||
PiAgentSessionEvent,
|
||||
@@ -245,6 +251,7 @@ interface PiRpcAgentSessionOptions {
|
||||
initialState: PiSessionState;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
currentModeId?: string | null;
|
||||
persistedSessionFile?: string;
|
||||
cleanup?: () => void;
|
||||
extensionTimeoutMs?: number;
|
||||
logger: Logger;
|
||||
@@ -492,13 +499,14 @@ function buildResumeConfig(
|
||||
|
||||
function buildResumeStartInput(input: {
|
||||
resumeConfig: PiResumeConfig;
|
||||
processCwd: string;
|
||||
sessionFile: string;
|
||||
launchContext: AgentLaunchContext | undefined;
|
||||
mcpConfigFile: PiTempFile | null;
|
||||
paseoExtension: PiTempFile | null;
|
||||
}): PiStartSessionInput {
|
||||
return {
|
||||
cwd: input.resumeConfig.cwd,
|
||||
cwd: input.processCwd,
|
||||
env: input.launchContext?.env,
|
||||
session: input.sessionFile,
|
||||
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
|
||||
@@ -618,6 +626,19 @@ 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 = {},
|
||||
@@ -1232,6 +1253,7 @@ 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;
|
||||
@@ -1262,6 +1284,7 @@ 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;
|
||||
|
||||
@@ -1496,7 +1519,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.state.sessionId,
|
||||
nativeHandle: this.state.sessionFile,
|
||||
nativeHandle: this.persistedSessionFile ?? this.state.sessionFile,
|
||||
metadata: {
|
||||
cwd: this.config.cwd,
|
||||
...(this.config.model ? { model: this.config.model } : {}),
|
||||
@@ -2645,6 +2668,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentResumeSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const sessionFile = handle.nativeHandle;
|
||||
if (!sessionFile) {
|
||||
@@ -2653,6 +2677,7 @@ 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 ?? {},
|
||||
@@ -2663,7 +2688,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
...launchContext?.env,
|
||||
};
|
||||
const mcp = await this.prepareMcpInjection(
|
||||
resumeConfig.cwd,
|
||||
processCwd,
|
||||
resumeConfig.config.mcpServers,
|
||||
mcpEnv,
|
||||
runtime,
|
||||
@@ -2676,20 +2701,29 @@ 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,
|
||||
sessionFile,
|
||||
processCwd,
|
||||
sessionFile: sessionCopy?.path ?? sessionFile,
|
||||
launchContext,
|
||||
mcpConfigFile,
|
||||
paseoExtension,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
mcpConfigFile?.cleanup();
|
||||
paseoExtension?.cleanup();
|
||||
cleanupTempFiles();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -2702,15 +2736,15 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
},
|
||||
initialState,
|
||||
capabilities: capabilitiesForSession(mcp !== null),
|
||||
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
|
||||
persistedSessionFile: sessionCopy ? sessionFile : undefined,
|
||||
cleanup: cleanupTempFiles,
|
||||
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
|
||||
logger: this.logger,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
});
|
||||
} catch (error) {
|
||||
await runtimeSession.close().catch(() => undefined);
|
||||
mcpConfigFile?.cleanup();
|
||||
paseoExtension?.cleanup();
|
||||
cleanupTempFiles();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
|
||||
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
|
||||
import {
|
||||
copyPiSessionFileWithCwd,
|
||||
listPiImportableSessions,
|
||||
readPiImportSessionConfig,
|
||||
} from "./session-descriptor.js";
|
||||
|
||||
async function writeSession(root: string, lines: unknown[]): Promise<string> {
|
||||
const sessionsDir = path.join(root, "sessions", "project");
|
||||
@@ -216,3 +220,97 @@ 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}`);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user