Compare commits

..
Author SHA1 Message Date
Mohamed Boudra aae97b2bdc fix(app): regenerate iOS Markdown inline code props
(cherry picked from commit b3ca2327a8)
2026-10-27 16:45:05 +01:00
434 changed files with 4211 additions and 24847 deletions
-19
View File
@@ -1,19 +0,0 @@
---
name: store-screenshots
description: Refresh the App Store and Google Play screenshots for phones and tablets. Use when the user says "store screenshots", "app store screenshots", "play store screenshots", "iPad screenshots", "recapture the screenshots", or wants the store panels changed.
user-invocable: true
---
# Store screenshots
Follow `assets/store-screenshots/README.md`. It owns the panels, the creative direction, the render command and the recapture steps. Change copy, crops and layout in `assets/store-screenshots/render.mjs`, not in the exports.
Working rules:
- Capture at native density: `--pixel-density 3` on the iPhone 16 Pro Max, `--pixel-density 2` on the iPad Pro 13-inch. The default is 1x, which is too soft for the store.
- Every headline wraps to two lines and every subline fits on one. The renderer enforces this; rewrite the copy rather than shrink the type.
- Use SF Pro. Never substitute another font.
- Open `output/preview.png` and a full-size panel from each store before you report a render. Show the user the preview.
- Do not upload screenshots to a store, a PR, or anywhere public, and do not commit, unless the user asks.
- Stage the demo host on its own port and pick a free Metro port. Do not restart the main daemons.
- When you learn something the next capture needs, fix the README rather than this skill.
+19 -12
View File
@@ -3,7 +3,7 @@ name: CI
on:
push:
branches: [main]
# Every base branch, so stacked PRs and PRs into any base get the same checks.
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
pull_request:
merge_group:
workflow_dispatch:
@@ -133,12 +133,11 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run typecheck:examples --workspace=@getpaseo/client
# The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04.
typecheck:
name: typecheck
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -396,7 +395,7 @@ jobs:
name: app-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -418,11 +417,19 @@ jobs:
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Run Android module tests
run: npm run test:android --workspace=@getpaseo/app
playwright-1:
name: playwright (shard 1/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "1/8"
PLAYWRIGHT_ARTIFACT: "1"
@@ -478,7 +485,7 @@ jobs:
name: playwright (shard 2/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "2/8"
PLAYWRIGHT_ARTIFACT: "2"
@@ -488,7 +495,7 @@ jobs:
name: playwright (shard 3/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "3/8"
PLAYWRIGHT_ARTIFACT: "3"
@@ -498,7 +505,7 @@ jobs:
name: playwright (shard 4/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "4/8"
PLAYWRIGHT_ARTIFACT: "4"
@@ -508,7 +515,7 @@ jobs:
name: playwright (shard 5/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "5/8"
PLAYWRIGHT_ARTIFACT: "5"
@@ -518,7 +525,7 @@ jobs:
name: playwright (shard 6/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "6/8"
PLAYWRIGHT_ARTIFACT: "6"
@@ -528,7 +535,7 @@ jobs:
name: playwright (shard 7/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "7/8"
PLAYWRIGHT_ARTIFACT: "7"
@@ -538,7 +545,7 @@ jobs:
name: playwright (shard 8/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "8/8"
PLAYWRIGHT_ARTIFACT: "8"
-25
View File
@@ -203,31 +203,6 @@
"readFileSync",
"rmSync"
],
// Terminal child process: launcher readiness and PTY record ownership run outside the daemon.
"terminal/terminal-worker-process.ts": [
"chmodSync",
"closeSync",
"existsSync",
"openSync",
"rmSync",
"writeSync"
],
// Worker startup only: choose and secure the local listening socket before accepting a controller.
"terminal/terminal-worker-socket-path.ts": ["lstatSync", "mkdirSync"],
// Startup only: build an independent worker installation before launching; Electron ASAR sources require plain fs reads.
"terminal/terminal-worker-runtime.ts": [
"chmodSync",
"existsSync",
"mkdirSync",
"readdirSync",
"readFileSync",
"renameSync",
"rmSync",
"statSync",
"writeFileSync"
],
// TODO: CLI shim lookup moved out of terminal.ts; migrate the executable-resolution boundary together.
"terminal/paseo-cli-path.ts": ["existsSync"],
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
"terminal/terminal-ts-loader.mjs": ["existsSync"],
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
+5 -19
View File
@@ -1,17 +1,6 @@
# Changelog
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
Added:
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
Improved:
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0 - 2026-10-29
## 0.14.0-beta.1 - 2026-10-27
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
@@ -22,7 +11,7 @@ Voice settings now let you configure speech providers and download local models
### Added
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- Added Chat display settings that group tool calls or collapse completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
@@ -34,7 +23,6 @@ Voice settings now let you configure speech providers and download local models
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093))
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
@@ -43,6 +31,7 @@ Voice settings now let you configure speech providers and download local models
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### Fixed
@@ -59,20 +48,17 @@ Voice settings now let you configure speech providers and download local models
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- Fixed model discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### Notes
- Voice mode now defaults to off; enable it in Voice settings
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by 0.14.0
- Install the optional [Claude Code entrypoint plugin](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env) to launch Claude sessions with `CLAUDE_CODE_ENTRYPOINT=Paseo`, a community workaround for subscription limits draining faster ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
## 0.13.2 - 2026-10-26
+5 -19
View File
@@ -1,17 +1,6 @@
# 更新日志
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
新增:
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
改进:
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0 - 2026-10-29
## 0.14.0-beta.1 - 2026-10-27
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
@@ -22,7 +11,7 @@
### 新增
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- 新增“聊天显示”设置,可将工具调用分组或折叠已完成的对话轮次 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
@@ -34,7 +23,6 @@
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093))
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
@@ -43,6 +31,7 @@
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### 修复
@@ -59,20 +48,17 @@
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### 说明
- 语音模式默认关闭,可在语音设置中启用
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 0.14.0 保存的语音凭据和空闲清理设置
- 安装可选的 [Claude Code entrypoint 插件](https://paseo.sh/plugins/getpaseo/claude-code-entrypoint-env),即可使用 `CLAUDE_CODE_ENTRYPOINT=Paseo` 启动 Claude 会话;这是针对订阅额度消耗过快问题的社区变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976), [#8164](https://github.com/getpaseo/paseo/pull/8164) by [@david828019-oss](https://github.com/david828019-oss))
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
## 0.13.2 - 2026-10-26
+4 -4
View File
@@ -66,7 +66,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-worker.md](docs/terminal-worker.md) | Terminal worker process model, socket/record/secret, protocol-bump rule and draining, the worker's file copy, Windows desktop |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
@@ -117,9 +116,10 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
## Release branches
Merge normal PRs into `main`, including while a beta soaks or a release is being finalized. Each
release builds in a short-lived release workspace from `origin/main` or a release tag; see
[release base and source](docs/release.md#release-base-and-source).
When the user says "this goes to next", create or
retarget the PR to `next` and preserve that destination through delivery. Follow
[release branch discipline](docs/release.md#release-branch-discipline) for creating
and updating `next`, integrating it after a release, and releasing a hotfix from a tag.
## Critical rules
-1
View File
@@ -1 +0,0 @@
output/preview.png
-102
View File
@@ -1,102 +0,0 @@
# Store screenshots
Five panels for the App Store and Google Play, in phone and tablet sets, made from native captures of the real app. Both sets tell the same story with the same copy; only the capture, the device frame and the callouts change.
| # | Headline | Capture | Phone callout | Tablet callout |
| --- | --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------- |
| 1 | Run many coding agents at once | Sidebar grouped by status: Needs input, Ready to review, Working, Done | — (agent marks in the background) | — (agent marks in the background) |
| 2 | Your full dev environment on your phone | Agent chat: reasoning, tool calls, final summary | — | — (Changes pane open beside the chat) |
| 3 | Ask an agent to change the app itself | A chat asking for a plugin, the turn collapsed, the new Deploys pill | The plugin's Preview deploys sheet | The plugin's Deploys popover |
| 4 | Review the diff before you merge | `daemon-client.ts` diff with removed and added lines | Jump to file: four changed files with counts | — (the full-width diff reads as is) |
| 5 | A real terminal in every workspace | `git status` and a vitest run in the same worktree | The passing test tree | The passing test tree |
On tablets, panel 2 says "on your tablet" and panel 3 calls the popover a panel. A scene overrides its copy per device in `render.mjs`.
## Creative direction
The panels follow paseo.sh:
- **Canvas:** the site's `#101615` background.
- **Type:** centred SF Pro headlines in sentence case, with no closing period. Every headline wraps to exactly two lines.
- **Sublines:** one plain grey sentence on one line that says what you can do. The renderer fails if a headline or subline wraps differently, because the device is placed from that fixed text height.
- **Agent marks:** panel 1 scatters faded, rotated, monochrome marks of the agents featured on the homepage behind the device. They are rendered from `packages/website/src/components/agent-icons.tsx`, so they follow the site.
- **Device:** dark-mode captures in a plain black frame; the iPhone frame has a Dynamic Island. The device starts a fixed gap below the text and runs just off the bottom edge, so a taller canvas gets a larger device instead of a larger gap. A canvas too narrow for that, like a 3:4 iPad on Google's 9:16 tablet canvas, shows the whole device at full width with the text and device centred.
- **Callouts:** a callout lifts one region of a capture out of the device, larger than the device around it, so the detail reads at store thumbnail size. On tablets, place callouts over empty screen so they do not hide the context they explain.
Everything on screen is real app UI showing one story. The hero agent adds jitter to the reconnect backoff in this repository. Its diff, test run and terminal come from a real worktree. The other workspaces fill the sidebar's status groups.
Panel 3 shows a real plugin. `demo/preview-deploys/` is a trusted local plugin that adds a Deploys composer pill; its popover opens as a sheet on phones and as a popover on tablets. The deploys it lists are sample data. The demo host installs it, and a scripted agent in the "Build a preview deploys plugin" workspace plays the turn that writes it.
## Files
- `raw/iphone/`: native 1320 × 2868 captures from an iPhone 16 Pro Max simulator. `files.png` and `plugin-sheet.png` are the sources for the Jump to file and Preview deploys callouts.
- `raw/ipad/`: native 2064 × 2752 captures from an iPad Pro 13-inch (M4) simulator. `plugin-sheet.png` is the source for the Deploys popover callout.
- `render.mjs`: the layout, copy and callout crops. Edit `SCENES`, `DEVICES` and `STORES` there.
- `demo/`: the staged demo host the captures come from.
- `output/apple/`: 1320 × 2868 for the iPhone 6.9-inch slot.
- `output/google/`: 1440 × 2560 (9:16) phone screenshots, reusing the iPhone captures.
- `output/apple-ipad/`: 2064 × 2752 for the iPad 13-inch slot.
- `output/google-tablet/`: 1800 × 3200 (9:16) tablet screenshots, reusing the iPad captures.
- `output/preview.png`: contact sheet with one row per store, for review only. It is ignored by Git; do not upload it to a store.
All exports are opaque RGB PNGs.
## Render
```sh
SF_FONT_DIR=/path/to/fonts node assets/store-screenshots/render.mjs
```
`SF_FONT_DIR` must hold `SFNS.ttf` copied from a Mac's `/System/Library/Fonts`. On macOS the renderer finds it without the variable. SF Pro is not redistributable, so it is not in the repository. The renderer fails rather than fall back to another font.
Each capture and callout is resized with Lanczos to the exact pixels it fills, and the page renders at device scale 1. Do not render at a higher scale and downsample: Chrome's image downscaling is what made earlier exports look soft and aliased.
Open `output/preview.png` and at least one full-size panel per store before you call a render done. The line guard catches wrapping, not a callout that covers the wrong thing.
Callout crops are in native capture pixels. To find them, overlay a 100 px grid on the capture with sharp and read the coordinates off it.
## Recapture
1. Stage the demo host. It runs an in-process daemon on port 7791 with scripted Claude Code and Codex clients, a local clone of this repository as the `paseo` project, real worktrees, and a terminal in the hero worktree:
```sh
npx tsx assets/store-screenshots/demo/stage.mts
```
Each run re-clones the repository into the demo home, so stale worktrees from an earlier run do not block it. The hero agent is paced so it reports a realistic "Worked for" time. Wait for `READY` in its output.
2. Start Metro against it on a free port. When the simulator runs on another Mac, forward Metro and the daemon to it. Other agents may already hold a Metro port; pick another one rather than stopping theirs.
```sh
EXPO_PUBLIC_LOCAL_DAEMON=127.0.0.1:7791 npm run start:expo --workspace=@getpaseo/app -- --port <port>
ssh -o ExitOnForwardFailure=yes -R <port>:127.0.0.1:<port> -R 7791:127.0.0.1:7791 <mac-host>
```
3. On the simulator, install and open the dev client (`sh.paseo.debug`) at `http://127.0.0.1:<port>`. Use an iPhone 16 Pro Max for the phone set and an iPad Pro 13-inch (M4) in portrait for the tablet set.
- Set dark appearance and override the status bar to 9:41 with full battery and signal (`xcrun simctl status_bar <device> override`).
- On first launch, accept the open-in-app dialog, decline notifications, turn off the dev client's Tools button, and close any error toast. `agent-device react-native dismiss-overlay` clears overlays.
- Name the host "MacBook Pro" and set the sidebar badge to Hidden.
- Group the sidebar by status.
- If you reset the simulator, reinstall the dev client.
4. Capture the sidebar first. Opening a "Ready to review" workspace marks it done. Select a Working row before you capture.
5. Capture every screen at native density. `agent-device screenshot` defaults to 1x, which is too soft for the store:
```sh
agent-device screenshot /tmp/sidebar.png --pixel-density 3 --session store # iPhone: 1320 × 2868
agent-device screenshot /tmp/sidebar.png --pixel-density 2 --session store # iPad: 2064 × 2752
```
- **Chat:** expand the tool-call group and scroll to the bottom. On iPad, open Explorer → Changes beside the chat.
- **Diff:** on iPhone, Explorer → Changes, then use Jump to file to reach `daemon-client.ts`. On iPad, collapse the sidebar, open a Diff tab from the main area's **+** menu and scroll to the `daemon-client.ts` hunks.
- **Terminal:** the second workspace tab. Type the commands with `npm run cli -- terminal send-keys --host 127.0.0.1:7791 <terminal-id> "<text>" Enter` instead of the simulator keyboard, then dismiss the software keyboard.
- **Plugin:** the "Build a preview deploys plugin" workspace with the turn collapsed, then tap **Deploys** for `plugin-sheet.png`. After editing the plugin, reload it with `npm run cli -- plugin reload --host 127.0.0.1:7791 preview-deploys`.
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
## Publish
The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports.
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
@@ -1,84 +0,0 @@
// Applies the demo change to a checkout of the Paseo repository.
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const repo = process.argv[2];
const client = path.join(repo, "packages/client/src");
copyFileSync(
path.join(here, "reconnect-delay.ts"),
path.join(client, "connection/reconnect-delay.ts"),
);
copyFileSync(
path.join(here, "reconnect-delay.test.ts"),
path.join(client, "connection/reconnect-delay.test.ts"),
);
function edit(file, pairs) {
let text = readFileSync(file, "utf8");
for (const [from, to] of pairs) {
if (!text.includes(from)) throw new Error(`Missing anchor in ${file}: ${from.slice(0, 60)}`);
text = text.replace(from, to);
}
writeFileSync(file, text);
}
edit(path.join(client, "daemon-client.ts"), [
[
`import { CreationClient } from "./creation/index.js";\n`,
`import { CreationClient } from "./creation/index.js";\nimport { getReconnectDelayMs } from "./connection/reconnect-delay.js";\n`,
],
[
` private reconnectAttempt = 0;\n`,
` private reconnectAttempt = 0;\n private nextReconnectDelay: { attempt: number; delayMs: number } | null = null;\n`,
],
[
` nextRetryAt: new Date(
Date.now() +
Math.min(
(this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
2 ** this.reconnectAttempt,
this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
),
).toISOString(),
`,
` nextRetryAt: new Date(Date.now() + this.getNextReconnectDelayMs()).toISOString(),
`,
],
[
` private armReconnectTimer(): void {
const attempt = this.reconnectAttempt;
const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
`,
` // The countdown and the timer must agree, so each attempt draws its jitter once.
private getNextReconnectDelayMs(): number {
const attempt = this.reconnectAttempt;
if (this.nextReconnectDelay?.attempt !== attempt) {
this.nextReconnectDelay = {
attempt,
delayMs: getReconnectDelayMs(attempt, {
baseDelayMs: this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS,
maxDelayMs: this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
}),
};
}
return this.nextReconnectDelay.delayMs;
}
private armReconnectTimer(): void {
const attempt = this.reconnectAttempt;
const delay = this.getNextReconnectDelayMs();
`,
],
]);
edit(path.join(repo, "CHANGELOG.md"), [
[
`# Changelog\n\n`,
`# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Spread reconnect attempts after a relay restart instead of retrying every client at once\n\n`,
],
]);
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import { getReconnectDelayMs } from "./reconnect-delay";
const options = { baseDelayMs: 1500, maxDelayMs: 30_000 };
describe("getReconnectDelayMs", () => {
it("doubles the ceiling on each attempt", () => {
const top = () => 1;
expect(getReconnectDelayMs(0, options, top)).toBe(1500);
expect(getReconnectDelayMs(1, options, top)).toBe(3000);
expect(getReconnectDelayMs(2, options, top)).toBe(6000);
});
it("never waits longer than the max delay", () => {
expect(getReconnectDelayMs(12, options, () => 1)).toBe(30_000);
});
it("spreads retries between half and the full ceiling", () => {
expect(getReconnectDelayMs(3, options, () => 0)).toBe(6000);
expect(getReconnectDelayMs(3, options, () => 0.5)).toBe(9000);
expect(getReconnectDelayMs(3, options, () => 1)).toBe(12_000);
});
it("keeps the first retry under two seconds", () => {
for (let i = 0; i < 100; i += 1) {
expect(getReconnectDelayMs(0, options)).toBeLessThanOrEqual(1500);
}
});
});
@@ -1,18 +0,0 @@
export interface ReconnectDelayOptions {
baseDelayMs: number;
maxDelayMs: number;
}
/**
* Exponential backoff with equal jitter. Half of each delay is fixed and half
* is random, so a relay restart spreads reconnects out instead of every client
* retrying on the same tick.
*/
export function getReconnectDelayMs(
attempt: number,
options: ReconnectDelayOptions,
random: () => number = Math.random,
): number {
const ceiling = Math.min(options.baseDelayMs * 2 ** attempt, options.maxDelayMs);
return Math.round(ceiling / 2 + random() * (ceiling / 2));
}
@@ -1,204 +0,0 @@
// The plugin the demo agent "builds" in the store screenshots: a Deploys pill on
// every composer that opens the latest preview deploy for each branch. The
// deploys are fixed sample data; the UI is a real plugin surface.
import type {
PluginButtonContentProps,
PluginButtonRegistration,
PluginClientContext,
} from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { Text, View } from "react-native";
type DeployStatus = "ready" | "building" | "failed";
interface Deploy {
branch: string;
status: DeployStatus;
detail: string;
age: string;
progress?: number;
}
const DEPLOYS: Deploy[] = [
{ branch: "fix/reconnect-jitter", status: "ready", detail: "Built in 48s", age: "2m" },
{
branch: "perf/provider-snapshot-cache",
status: "building",
detail: "Bundling app",
age: "now",
progress: 0.64,
},
{ branch: "feat/remember-terminal", status: "ready", detail: "Built in 51s", age: "18m" },
{
branch: "i18n/ko-settings",
status: "failed",
detail: "Missing key settings.notifications",
age: "26m",
},
];
function DeploysSheet({ theme, layout }: PluginButtonContentProps) {
const mono = layout.platform === "ios" ? "Menlo" : "monospace";
const styles = useMemo(
() => ({
// Popovers on tablets and desktop are narrow by default; give the
// branch names room. Phones show the sheet at full width.
body: { gap: 14, width: layout.compact ? undefined : 400 },
production: {
padding: 14,
gap: 6,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
},
productionTop: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
label: { color: theme.colors.foregroundMuted, fontSize: 13 },
version: { color: theme.colors.foreground, fontSize: 22, fontWeight: "600" as const },
live: { color: theme.colors.statusSuccess, fontSize: 13, fontWeight: "600" as const },
heading: { color: theme.colors.foregroundMuted, fontSize: 13, marginTop: 4 },
list: {
borderRadius: 12,
borderWidth: 1,
borderColor: theme.colors.border,
overflow: "hidden" as const,
},
row: {
flexDirection: "row" as const,
alignItems: "center" as const,
gap: 12,
paddingVertical: 12,
paddingHorizontal: 14,
},
divider: { height: 1, backgroundColor: theme.colors.border },
text: { flex: 1, gap: 4 },
branch: { color: theme.colors.foreground, fontSize: 14, fontFamily: mono },
detail: { color: theme.colors.foregroundMuted, fontSize: 13 },
failed: { color: theme.colors.statusDanger, fontSize: 13 },
age: { color: theme.colors.foregroundMuted, fontSize: 13 },
track: {
height: 4,
marginTop: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface2,
overflow: "hidden" as const,
},
}),
[theme, mono, layout.compact],
);
const dotColor: Record<DeployStatus, string> = {
ready: theme.colors.statusSuccess,
building: theme.colors.statusWarning,
failed: theme.colors.statusDanger,
};
return (
<View style={styles.body}>
<View style={styles.production}>
<View style={styles.productionTop}>
<Dot color={theme.colors.statusSuccess} />
<Text style={styles.live}>Live</Text>
<Text style={styles.label}>Production · main</Text>
</View>
<Text style={styles.version}>v0.14.0-beta.1</Text>
<Text style={styles.label}>Deployed 3h ago</Text>
</View>
<Text style={styles.heading}>Previews</Text>
<View style={styles.list}>
{DEPLOYS.map((deploy, index) => (
<View key={deploy.branch}>
{index > 0 ? <View style={styles.divider} /> : null}
<View style={styles.row}>
<Dot color={dotColor[deploy.status]} />
<View style={styles.text}>
<Text style={styles.branch} numberOfLines={1}>
{deploy.branch}
</Text>
<Text style={deploy.status === "failed" ? styles.failed : styles.detail}>
{deploy.detail}
</Text>
{deploy.progress !== undefined ? (
<Progress
value={deploy.progress}
track={styles.track}
color={theme.colors.statusWarning}
/>
) : null}
</View>
<Text style={styles.age}>{deploy.age}</Text>
</View>
</View>
))}
</View>
</View>
);
}
function Progress({ value, track, color }: { value: number; track: object; color: string }) {
const fill = useMemo(
() => ({ width: `${value * 100}%` as const, height: 4, backgroundColor: color }),
[value, color],
);
return (
<View style={track}>
<View style={fill} />
</View>
);
}
function Dot({ color }: { color: string }) {
const style = useMemo(
() => ({ width: 8, height: 8, borderRadius: 4, backgroundColor: color }),
[color],
);
return <View style={style} />;
}
export default function contribute(client: PluginClientContext) {
const pills = new Map<string, PluginButtonRegistration>();
const lifetime = new AbortController();
const register = (agent: { id: string; workspaceId?: string | null }) => {
if (lifetime.signal.aborted || !agent.workspaceId) return;
pills.get(agent.id)?.remove();
pills.set(
agent.id,
client.addComposerPill({
id: "deploys",
workspaceId: agent.workspaceId,
agentId: agent.id,
button: {
title: "Preview deploys",
icon: "Rocket",
label: "Deploys",
behavior: { kind: "popover", Content: DeploysSheet },
},
}),
);
};
void client.paseo.agents
.list({ subscribe: {}, signal: lifetime.signal })
.then(({ subscription }) => {
subscription.subscribe({
snapshot: ({ entries }) => {
for (const pill of pills.values()) pill.remove();
pills.clear();
for (const { agent } of entries) register(agent);
},
update: (message) => {
if (message.type !== "agent_update") return;
const update = message.payload;
if (update.kind === "remove") {
pills.get(update.agentId)?.remove();
pills.delete(update.agentId);
} else register(update.agent);
},
});
return undefined;
})
.catch((error) => {
if (!lifetime.signal.aborted) console.error("Agent observation failed", error);
});
return () => {
lifetime.abort();
for (const pill of pills.values()) pill.remove();
pills.clear();
};
}
@@ -1,4 +0,0 @@
{
"id": "preview-deploys",
"requirements": { "paseo": ">=0.8.0" }
}
@@ -1,281 +0,0 @@
// Scripted Claude Code and Codex clients for staging store screenshots.
// Each agent replays a fixed conversation through the real daemon, so the app
// renders real timeline rows, permission prompts, and running states.
import { randomUUID } from "node:crypto";
import type {
AgentCapabilityFlags,
AgentClient,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
AgentTimelineItem,
ToolCallDetail,
} from "../../../packages/server/src/server/agent/agent-sdk-types.js";
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export type ScriptStep =
| { kind: "say"; text: string }
| { kind: "think"; text: string }
| { kind: "tool"; name: string; detail: ToolCallDetail }
| { kind: "todo"; items: { text: string; completed: boolean }[] }
| { kind: "wait"; ms: number }
| { kind: "running-tool"; name: string; detail: ToolCallDetail }
| { kind: "ask"; name: string; title: string; detail: ToolCallDetail };
export interface AgentScript {
/** Matched against the start of the prompt that opens the turn. */
prompt: string;
steps: ScriptStep[];
/** Leave the turn open after the last step. */
keepRunning?: boolean;
/** Pause between steps, so "Worked for" reflects a believable duration. */
stepDelayMs?: number;
}
const CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
};
const MODELS: Record<string, AgentModelDefinition[]> = {
claude: [
{
provider: "claude",
id: "claude-opus-5-5",
label: "Opus 5.5",
isDefault: true,
thinkingOptions: [
{ id: "medium", label: "Medium", isDefault: true },
{ id: "high", label: "High" },
],
defaultThinkingOptionId: "medium",
},
{ provider: "claude", id: "claude-sonnet-5-5", label: "Sonnet 5.5" },
],
codex: [
{
provider: "codex",
id: "gpt-5.6-sol",
label: "GPT-5.6-Sol",
isDefault: true,
thinkingOptions: [
{ id: "medium", label: "Medium", isDefault: true },
{ id: "high", label: "High" },
],
defaultThinkingOptionId: "medium",
},
],
};
function promptText(prompt: AgentPromptInput): string {
if (typeof prompt === "string") return prompt;
return prompt
.map((block) => ("text" in block && typeof block.text === "string" ? block.text : ""))
.join("");
}
class ScriptedSession implements AgentSession {
readonly capabilities = CAPABILITIES;
readonly id = randomUUID();
private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
private readonly history: AgentStreamEvent[] = [];
private pending: AgentPermissionRequest[] = [];
private activeTurnId: string | null = null;
constructor(
readonly provider: string,
private readonly config: AgentSessionConfig,
private readonly scripts: AgentScript[],
) {}
private emit(event: AgentStreamEvent): void {
const tagged = this.activeTurnId ? { ...event, turnId: this.activeTurnId } : event;
this.history.push(tagged);
for (const listener of this.listeners) listener(tagged);
}
private timeline(item: AgentTimelineItem): void {
this.emit({ type: "timeline", provider: this.provider, item });
}
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
const turnId = randomUUID();
this.activeTurnId = turnId;
const text = promptText(prompt);
const script = this.scripts.find((candidate) => text.startsWith(candidate.prompt));
setTimeout(() => void this.play(text, script), 50);
return { turnId };
}
private async play(text: string, script: AgentScript | undefined): Promise<void> {
this.emit({ type: "turn_started", provider: this.provider });
this.timeline({ type: "user_message", text, messageId: randomUUID() });
const delay = script?.stepDelayMs ?? 400;
for (const step of script?.steps ?? [{ kind: "say", text: "Done." } as const]) {
await new Promise((resolve) => setTimeout(resolve, step.kind === "wait" ? step.ms : delay));
this.playStep(step);
if (step.kind === "ask") return;
}
if (script?.keepRunning) return;
this.emit({
type: "turn_completed",
provider: this.provider,
usage: { inputTokens: 48_210, outputTokens: 3_904 },
});
this.activeTurnId = null;
}
private playStep(step: ScriptStep): void {
switch (step.kind) {
case "say":
this.timeline({ type: "assistant_message", text: step.text, messageId: randomUUID() });
return;
case "think":
this.timeline({ type: "reasoning", text: step.text });
return;
case "todo":
this.timeline({ type: "todo", items: step.items });
return;
case "wait":
return;
case "tool":
case "running-tool": {
const callId = randomUUID();
this.timeline({
type: "tool_call",
callId,
name: step.name,
status: "running",
error: null,
detail: step.detail,
});
if (step.kind === "tool") {
this.timeline({
type: "tool_call",
callId,
name: step.name,
status: "completed",
error: null,
detail: step.detail,
});
}
return;
}
case "ask": {
const request: AgentPermissionRequest = {
id: randomUUID(),
provider: this.provider,
name: step.name,
kind: "tool",
title: step.title,
detail: step.detail,
};
this.pending = [request];
this.emit({ type: "permission_requested", provider: this.provider, request });
}
}
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield* this.history;
}
async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: this.config.model ?? MODELS[this.provider]?.[0]?.id ?? null,
thinkingOptionId: this.config.thinkingOptionId ?? "medium",
modeId: this.config.modeId ?? null,
};
}
async getAvailableModes(): Promise<AgentMode[]> {
return getAgentProviderDefinition(this.provider).modes;
}
async getCurrentMode(): Promise<string | null> {
return this.config.modeId ?? null;
}
async setMode(): Promise<void> {}
getPendingPermissions(): AgentPermissionRequest[] {
return this.pending;
}
async respondToPermission(requestId: string, resolution: AgentPermissionResponse) {
this.pending = [];
this.emit({ type: "permission_resolved", provider: this.provider, requestId, resolution });
}
describePersistence(): AgentPersistenceHandle {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {
if (!this.activeTurnId) return;
this.emit({ type: "turn_canceled", provider: this.provider, reason: "Interrupted" });
this.activeTurnId = null;
}
async close(): Promise<void> {}
}
export class ScriptedAgentClient implements AgentClient {
readonly capabilities = CAPABILITIES;
constructor(
readonly provider: string,
private readonly scripts: AgentScript[],
) {}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new ScriptedSession(this.provider, config, this.scripts);
}
async resumeSession(
_handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
return new ScriptedSession(
this.provider,
{ provider: this.provider, cwd: overrides?.cwd ?? process.cwd(), ...overrides },
this.scripts,
);
}
async fetchCatalog() {
return {
models: MODELS[this.provider] ?? [],
modes: getAgentProviderDefinition(this.provider).modes,
};
}
async isAvailable(): Promise<boolean> {
return true;
}
}
-556
View File
@@ -1,556 +0,0 @@
// Stages the demo host used for store screenshots.
//
// npx tsx assets/store-screenshots/demo/stage.mts
//
// Starts an in-process daemon on DEMO_PORT (default 7791) with scripted Claude
// Code and Codex clients, adds a local clone of this repository as the `paseo`
// project, cuts real worktrees, applies a real change to the hero worktree, and
// opens a terminal there. Leave it running while you capture.
import { execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createTestPaseoDaemon } from "../../../packages/server/src/server/test-utils/paseo-daemon.js";
import { DaemonClient } from "../../../packages/server/src/server/test-utils/daemon-client.js";
import { type AgentScript, ScriptedAgentClient } from "./scripted-agents.mjs";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../../..");
const demoRoot = path.join(repoRoot, ".dev/store-capture/demo");
const codeRoot = path.join(demoRoot, "code");
const pluginDir = path.join(here, "preview-deploys");
const port = Number(process.env.DEMO_PORT ?? 7791);
const minute = 60_000;
const claudeScripts: AgentScript[] = [];
const codexScripts: AgentScript[] = [];
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", ["-c", "user.name=Mo", "-c", "user.email=mo@paseo.sh", ...args], {
cwd,
encoding: "utf8",
});
}
function run(cwd: string, command: string): string {
return execSync(command, { cwd, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0" } });
}
async function prepareRepos(): Promise<void> {
// A fresh clone each run: worktrees from an earlier run stay registered in it.
// Worktree setup (npm ci, builds) is dropped so new worktrees open instantly.
const paseo = path.join(codeRoot, "paseo");
await rm(paseo, { recursive: true, force: true });
await mkdir(codeRoot, { recursive: true });
execFileSync("git", ["clone", "-q", "--local", repoRoot, paseo]);
git(paseo, "checkout", "-q", "-B", "main");
git(paseo, "remote", "remove", "origin");
const config = JSON.parse(await readFile(path.join(paseo, "paseo.json"), "utf8"));
delete config.worktree;
await writeFile(path.join(paseo, "paseo.json"), `${JSON.stringify(config, null, 2)}\n`);
git(paseo, "commit", "-qam", "chore: skip worktree setup in demo");
const dotfiles = path.join(codeRoot, "dotfiles");
if (!existsSync(dotfiles)) {
await mkdir(dotfiles, { recursive: true });
await writeFile(path.join(dotfiles, "tmux.conf"), "set -g mouse on\nset -g base-index 1\n");
await writeFile(path.join(dotfiles, "zshrc"), "export EDITOR=nvim\nalias g=git\n");
git(dotfiles, "init", "-q", "-b", "main");
git(dotfiles, "add", ".");
git(dotfiles, "commit", "-qm", "Initial dotfiles");
}
}
async function main(): Promise<void> {
await prepareRepos();
const home = path.join(demoRoot, "home");
await rm(home, { recursive: true, force: true });
await mkdir(home, { recursive: true });
const daemon = await createTestPaseoDaemon({
agentClients: {
claude: new ScriptedAgentClient("claude", claudeScripts),
codex: new ScriptedAgentClient("codex", codexScripts),
},
listen: "127.0.0.1",
listenPort: port,
paseoHomeRoot: home,
mcpEnabled: false,
cleanup: false,
pluginsEnabled: true,
plugins: {
"preview-deploys": { source: "directory", path: pluginDir, enabled: true },
},
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.14.0",
});
await client.connect();
await client.fetchAgents({ subscribe: {} });
const paseo = await client.addProject(path.join(codeRoot, "paseo"));
const dotfiles = await client.addProject(path.join(codeRoot, "dotfiles"));
const paseoProjectId = paseo.project!.projectId;
async function worktree(title: string, branchName: string) {
const result = await client.createWorkspace({
title,
source: {
kind: "worktree",
projectId: paseoProjectId,
action: "branch-off",
baseBranch: "main",
branchName,
},
});
if (!result.workspace) throw new Error(`No workspace for ${title}: ${JSON.stringify(result)}`);
return result.workspace;
}
async function agent(input: {
provider: "claude" | "codex";
workspace: { id: string; workspaceDirectory?: string | null };
cwd: string;
title: string;
script: AgentScript;
}) {
(input.provider === "claude" ? claudeScripts : codexScripts).push(input.script);
return client.createAgent({
provider: input.provider,
cwd: input.cwd,
workspaceId: input.workspace.id,
title: input.title,
initialPrompt: input.script.prompt,
});
}
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Done: finished earlier and already read.
const release = await worktree("Release v0.14.0-beta.1", "release/0.14.0-beta.1");
const releaseAgent = await agent({
provider: "codex",
workspace: release,
cwd: release.workspaceDirectory!,
title: "Release v0.14.0-beta.1",
script: {
prompt: "Cut 0.14.0-beta.1 from main and draft the release notes.",
steps: [
{
kind: "tool",
name: "shell",
detail: {
type: "shell",
command: "npm run release:beta",
output: "Tagged v0.14.0-beta.1\n",
exitCode: 0,
},
},
{
kind: "say",
text: "Tagged `v0.14.0-beta.1` and drafted the release notes. CI is building the desktop and mobile artifacts.",
},
],
},
});
const dotfilesWorkspace = (
await client.createWorkspace({
title: "Add a tmux status line",
source: {
kind: "directory",
path: path.join(codeRoot, "dotfiles"),
projectId: dotfiles.project!.projectId,
},
})
).workspace!;
const tmuxAgent = await agent({
provider: "claude",
workspace: dotfilesWorkspace,
cwd: path.join(codeRoot, "dotfiles"),
title: "Add a tmux status line",
script: {
prompt: "Add a minimal tmux status line with the session name and battery.",
steps: [
{
kind: "tool",
name: "Edit",
detail: {
type: "edit",
filePath: "tmux.conf",
oldString: "",
newString: "set -g status-right '#S · #{battery_percentage}'",
},
},
{
kind: "say",
text: "Added a status line with the session name on the left and battery on the right.",
},
],
},
});
await pause(3000);
await client.clearAgentAttention([releaseAgent.id, tmuxAgent.id]);
// Working: turns that stay open while you capture.
const ipad = await worktree(
"Keep the composer above the iPad keyboard",
"fix/ipad-keyboard-inset",
);
await agent({
provider: "codex",
workspace: ipad,
cwd: ipad.workspaceDirectory!,
title: "Keep the composer above the iPad keyboard",
script: {
prompt:
"On iPad with a hardware keyboard attached, the composer jumps up as if the software keyboard were open. Find out why and fix it.",
keepRunning: true,
steps: [
{
kind: "tool",
name: "shell",
detail: {
type: "shell",
command: 'rg -n "keyboardHeight" packages/app/src',
output:
"packages/app/src/keyboard/shift/internal/provider.ts:41: const keyboardHeight = useSharedValue(0);\n",
exitCode: 0,
},
},
{
kind: "tool",
name: "read",
detail: {
type: "read",
filePath: "packages/app/src/keyboard/shift/internal/provider.ts",
},
},
{
kind: "say",
text: "The shift provider trusts the keyboard frame event. With a hardware keyboard, iPadOS still reports the shortcuts bar as a keyboard frame, so we lift the composer by its full height. Checking the frame against the screen bottom next.",
},
{
kind: "running-tool",
name: "shell",
detail: { type: "shell", command: "npx vitest run packages/app/src/keyboard" },
},
],
},
});
const snapshots = await worktree(
"Cache provider snapshots on disk",
"perf/provider-snapshot-cache",
);
await agent({
provider: "claude",
workspace: snapshots,
cwd: snapshots.workspaceDirectory!,
title: "Cache provider snapshots on disk",
script: {
prompt:
"Cold start spends about two seconds probing providers. Cache the last provider snapshot on disk and refresh it in the background.",
keepRunning: true,
steps: [
{
kind: "todo",
items: [
{ text: "Find where snapshots are built", completed: true },
{ text: "Persist the last snapshot per host", completed: false },
{ text: "Refresh in the background after boot", completed: false },
],
},
{
kind: "tool",
name: "Read",
detail: {
type: "read",
filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts",
},
},
{
kind: "running-tool",
name: "Edit",
detail: {
type: "edit",
filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts",
},
},
],
},
});
// Needs input: waiting on a permission.
const korean = await worktree("Korean translations for Settings", "i18n/ko-settings");
await agent({
provider: "claude",
workspace: korean,
cwd: korean.workspaceDirectory!,
title: "Korean translations for Settings",
script: {
prompt: "Translate the new Settings strings into Korean and open a PR.",
steps: [
{
kind: "tool",
name: "Read",
detail: { type: "read", filePath: "packages/app/src/i18n/resources/en.ts" },
},
{
kind: "tool",
name: "Edit",
detail: { type: "edit", filePath: "packages/app/src/i18n/resources/ko.ts" },
},
{
kind: "tool",
name: "Bash",
detail: {
type: "shell",
command: "npm run test --workspace=@getpaseo/app -- i18n",
output: "Test Files 3 passed (3)\n Tests 41 passed (41)\n",
exitCode: 0,
},
},
{ kind: "say", text: "Translated 18 strings. I'll push the branch so I can open the PR." },
{
kind: "ask",
name: "Bash",
title: "Bash",
detail: { type: "shell", command: "git push -u origin i18n/ko-settings" },
},
],
},
});
// Ready to review: a second finished agent.
const terminals = await worktree(
"Remember the last terminal per workspace",
"feat/remember-terminal",
);
await agent({
provider: "codex",
workspace: terminals,
cwd: terminals.workspaceDirectory!,
title: "Remember the last terminal per workspace",
script: {
prompt:
"When I switch back to a workspace, open the terminal I was using last instead of the first one.",
steps: [
{
kind: "tool",
name: "shell",
detail: {
type: "shell",
command: 'rg -n "activeTerminalId" packages/app/src',
output: "",
exitCode: 0,
},
},
{
kind: "say",
text: "Workspaces now remember their last focused terminal and reopen it when you switch back. The choice is stored per host, so it survives restarts.",
},
],
},
});
// Plugin: the agent "built" the preview-deploys plugin this daemon runs.
const plugin = await worktree("Build a preview deploys plugin", "plugins/preview-deploys");
const pluginSource = (file: string) => run(pluginDir, `cat ${file}`);
await agent({
provider: "claude",
workspace: plugin,
cwd: plugin.workspaceDirectory!,
title: "Build a preview deploys plugin",
script: {
prompt: "Build me a plugin that shows our preview deploys right next to the composer.",
stepDelayMs: 6_000,
steps: [
{ kind: "tool", name: "Read", detail: { type: "read", filePath: "docs/plugins.md" } },
{
kind: "tool",
name: "Write",
detail: {
type: "write",
filePath: "plugins/preview-deploys/paseo-plugin.json",
content: pluginSource("paseo-plugin.json"),
},
},
{
kind: "tool",
name: "Write",
detail: {
type: "write",
filePath: "plugins/preview-deploys/index.client.tsx",
content: pluginSource("index.client.tsx"),
},
},
{
kind: "tool",
name: "Bash",
detail: {
type: "shell",
command: "paseo plugin install ./plugins/preview-deploys",
output: "Installed preview-deploys\n",
exitCode: 0,
},
},
{
kind: "say",
text: "Done. **Deploys** now sits above the composer in every workspace. Tap it to see production and the latest preview for each branch, with build progress and failures.",
},
],
},
});
// Hero: Claude finished a real change in a real worktree.
const jitter = await worktree("Add jitter to reconnect backoff", "fix/reconnect-jitter");
const cwd = jitter.workspaceDirectory!;
await symlink(path.join(repoRoot, "node_modules"), path.join(cwd, "node_modules"));
execFileSync("node", [path.join(here, "change/apply.mjs"), cwd]);
const clientDiff = git(cwd, "diff", "--", "packages/client/src/daemon-client.ts");
const vitest = run(
cwd,
"npx vitest run packages/client/src/connection/reconnect-delay.test.ts",
).replace(/^ RUN .*\n/m, "");
const readExcerpt = run(cwd, "sed -n '7067,7086p' packages/client/src/daemon-client.ts");
await agent({
provider: "claude",
workspace: jitter,
cwd,
title: "Add jitter to reconnect backoff",
script: {
prompt:
'When the relay restarts, every phone reconnects at the same moment and we get a spike. Add jitter to the reconnect backoff, keep the 30s cap, and make sure the "Retrying in" countdown still matches the real delay.',
stepDelayMs: 7_000,
steps: [
{
kind: "tool",
name: "Grep",
detail: {
type: "search",
toolName: "grep",
query: "reconnectAttempt",
filePaths: ["packages/client/src/daemon-client.ts"],
numFiles: 1,
numMatches: 9,
mode: "files_with_matches",
},
},
{
kind: "tool",
name: "Read",
detail: {
type: "read",
filePath: "packages/client/src/daemon-client.ts",
offset: 7040,
limit: 60,
content: readExcerpt,
},
},
{
kind: "say",
text: "The delay is calculated twice: once for `nextRetryAt`, which drives the countdown, and again when the timer is armed. Both use plain doubling, so every client retries on the same schedule. If I add randomness in both places the countdown will be wrong, so I'll draw the jitter once per attempt and share it.",
},
{
kind: "tool",
name: "Write",
detail: {
type: "write",
filePath: "packages/client/src/connection/reconnect-delay.ts",
content: run(cwd, "cat packages/client/src/connection/reconnect-delay.ts"),
},
},
{
kind: "tool",
name: "Edit",
detail: {
type: "edit",
filePath: "packages/client/src/daemon-client.ts",
unifiedDiff: clientDiff,
},
},
{
kind: "tool",
name: "Write",
detail: {
type: "write",
filePath: "packages/client/src/connection/reconnect-delay.test.ts",
content: run(cwd, "cat packages/client/src/connection/reconnect-delay.test.ts"),
},
},
{
kind: "tool",
name: "Bash",
detail: {
type: "shell",
command: "npx vitest run packages/client/src/connection/reconnect-delay.test.ts",
output: vitest,
exitCode: 0,
},
},
{
kind: "tool",
name: "Edit",
detail: {
type: "edit",
filePath: "CHANGELOG.md",
unifiedDiff: git(cwd, "diff", "--", "CHANGELOG.md"),
},
},
{
kind: "say",
text: [
"Reconnects now back off with jitter.",
"",
"- `getReconnectDelayMs` picks a delay between half and the full backoff, still capped at 30s.",
'- `DaemonClient` draws the delay once per attempt, so the "Retrying in" countdown matches the actual retry.',
"- Four new tests cover the doubling, the cap, the jitter range, and a quick first retry.",
"",
"Tests pass. After a relay restart, clients now spread their reconnects across the backoff window instead of arriving together.",
].join("\n"),
},
],
},
});
const terminal = await client.createTerminal(cwd, "zsh", undefined, {
workspaceId: jitter.id,
command: "bash",
args: ["--rcfile", path.join(here, "terminal-rc.sh"), "-i"],
});
const terminalId = terminal.terminal?.id;
if (terminalId) {
await pause(1500);
for (const line of [
"git status --short",
"cd packages/client",
"npx vitest run reconnect --reporter=tree",
]) {
client.sendTerminalInput(terminalId, { type: "input", data: `${line}\r` });
await pause(4000);
}
}
console.log(`READY port=${daemon.port} home=${daemon.paseoHome}`);
await writeFile(
path.join(demoRoot, "state.json"),
JSON.stringify(
{ port: daemon.port, home: daemon.paseoHome, hero: { workspaceId: jitter.id, cwd } },
null,
2,
),
);
const stop = async () => {
await client.close();
await daemon.close();
process.exit(0);
};
process.on("SIGTERM", stop);
process.on("SIGINT", stop);
void minute;
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -1,19 +0,0 @@
# Shell profile for the demo terminal: a Pure-style two-line prompt that fits a
# phone-width terminal, and vitest output without the absolute checkout path
# or the long timing breakdown. Vitest drops its colors when piped, so the
# wrapper puts its usual green checks and grey timings back.
export FORCE_COLOR=1
__where() { local top; top=$(git rev-parse --show-toplevel 2>/dev/null); printf '~/paseo%s' "${PWD#"$top"}"; }
__branch() { git branch --show-current 2>/dev/null; }
__dirty() { [ -n "$(git status --porcelain 2>/dev/null)" ] && printf '*'; }
PROMPT_COMMAND='printf "\e]0;zsh\a"'
PS1='\n\[\e[34m\]$(__where)\[\e[0m\] \[\e[90m\]$(__branch)$(__dirty)\[\e[0m\]\n\[\e[35m\]❯\[\e[0m\] '
npx() {
command npx "$@" 2>&1 | sed -E \
-e '1{/^$/d}' -e '/ RUN /d' -e '/Start at/d' -e 's/ \(transform.*\)//' \
-e $'s/✓/\e[32m✓\e[0m/' \
-e $'s/ ([0-9]+ms)$/ \e[90m\\1\e[0m/' \
-e $'s/ (\\([0-9]+( tests)?\\))/ \e[90m\\1\e[0m/' \
-e $'s/([0-9]+ passed)/\e[1;32m\\1\e[0m/'
}
clear
Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

-413
View File
@@ -1,413 +0,0 @@
// Renders the App Store and Google Play screenshots from the native captures in
// raw/. Every image is resized with Lanczos to the exact pixel size it occupies
// on the canvas, and the page renders at device scale 1, so the browser never
// resamples a capture.
//
// node assets/store-screenshots/render.mjs
//
// SF Pro is not redistributable. On macOS it comes from /System/Library/Fonts;
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { build } from "esbuild";
import { chromium } from "playwright";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import sharp from "sharp";
const root = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(root, "../..");
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
const output = path.join(root, "output");
const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images");
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
const DEVICES = {
iphone: {
capture: { width: 1320, height: 2868 },
radius: 186,
bezel: 22,
island: { width: 378, height: 112, top: 33 },
},
ipad: { capture: { width: 2064, height: 2752 }, radius: 44, bezel: 30, island: null },
};
// Every title wraps to two lines and every subline fits on one; the renderer
// fails otherwise, because the device is placed from that fixed text height.
//
// Each scene names its capture per device, and a device entry may override the
// title or subline where the copy depends on the form factor.
//
// A callout lifts a region of a capture out of the device. `crop` is in native
// capture pixels, `zoom` is its size relative to the device screen (capped to
// the canvas width), and the callout is centred on the spot the crop comes
// from, moved by `shift` native pixels down the screen.
const SCENES = [
{
id: "sidebar",
backdrop: "agents",
title: "Run many coding agents at once",
body: "See which agents are working and which need you.",
iphone: { capture: "sidebar" },
ipad: { capture: "sidebar" },
},
{
id: "chat",
title: "Your full dev environment on your phone",
body: "Start agents and follow every edit and command.",
iphone: { capture: "chat" },
ipad: { capture: "chat", title: "Your full dev environment on your tablet" },
},
{
id: "plugins",
title: "Ask an agent to change the app itself",
body: "This Deploys sheet is a plugin an agent wrote.",
iphone: {
capture: "plugin",
callout: { from: "plugin-sheet", crop: [0, 1340, 1320, 1400], zoom: 1.04, shift: -1400 },
},
ipad: {
capture: "plugin",
body: "This Deploys panel is a plugin an agent wrote.",
callout: { from: "plugin-sheet", crop: [670, 1486, 844, 970], zoom: 1.3, shift: -900 },
},
},
{
id: "review",
title: "Review the diff before you merge",
body: "Every file the agent changed, on its own branch.",
iphone: {
capture: "diff",
callout: { from: "files", crop: [0, 1060, 1320, 870], zoom: 1.26, shift: 110 },
},
ipad: { capture: "diff" },
},
{
id: "terminal",
title: "A real terminal in every workspace",
body: "Run the tests yourself, on your own machine.",
iphone: {
capture: "terminal",
callout: { from: "terminal", crop: [0, 950, 1320, 460], zoom: 1.35, shift: 110 },
},
ipad: {
capture: "terminal",
callout: { from: "terminal", crop: [596, 640, 1010, 412], zoom: 1.35, shift: 650 },
},
},
];
// `textTop` is where the text starts when the device is sized by the canvas
// height. A canvas too narrow for that, like a tablet on 9:16, sizes the device
// by its width and centres the text and device instead.
const STORES = {
apple: {
device: "iphone",
width: 1320,
height: 2868,
textTop: 230,
titleSize: 100,
bodySize: 46,
textWidth: 1120,
},
google: {
device: "iphone",
androidScreenshots: "phoneScreenshots",
width: 1440,
height: 2560,
textTop: 150,
titleSize: 96,
bodySize: 44,
textWidth: 1200,
},
"apple-ipad": {
device: "ipad",
width: 2064,
height: 2752,
textTop: 200,
titleSize: 124,
bodySize: 56,
textWidth: 1400,
},
"google-tablet": {
device: "ipad",
androidScreenshots: "tenInchScreenshots",
width: 1800,
height: 3200,
textTop: 200,
titleSize: 136,
bodySize: 60,
textWidth: 1560,
},
};
const TITLE_LINE = 1.06;
const BODY_LINE = 1.3;
const BODY_GAP = 30;
// The device starts this far below the text and runs BLEED off the bottom.
const GAP = 140;
const BLEED = 40;
const MARGIN = 56;
const COLORS = { canvas: "#101615", title: "#ffffff", body: "rgba(255, 255, 255, 0.58)" };
function findFont() {
const dirs = [process.env.SF_FONT_DIR, "/System/Library/Fonts", "/Library/Fonts"].filter(Boolean);
for (const dir of dirs) {
const file = path.join(dir, "SFNS.ttf");
if (existsSync(file)) return file;
}
throw new Error("SF Pro not found. Set SF_FONT_DIR to a folder containing SFNS.ttf from macOS.");
}
// The device fills the canvas from GAP below the text to just past the bottom
// edge, so a taller canvas gets a larger device rather than a larger gap. When
// that would be wider than the canvas, the device takes the full width and the
// text and the whole device sit centred, still GAP apart.
function deviceLayout(store, device) {
const { capture, bezel } = device;
const textHeight = 2 * store.titleSize * TITLE_LINE + BODY_GAP + store.bodySize * BODY_LINE;
const tallest = store.height + BLEED - (store.textTop + textHeight + GAP) - 2 * bezel;
const widest = store.width - 2 * MARGIN - 2 * bezel;
const screenWidth = Math.min(Math.round((tallest * capture.width) / capture.height), widest);
const screenHeight = Math.round((screenWidth * capture.height) / capture.width);
const scale = screenWidth / capture.width;
const width = screenWidth + 2 * bezel;
const height = screenHeight + 2 * bezel;
const textTop =
screenWidth < widest
? store.height + BLEED - height - GAP - textHeight
: (store.height - height - GAP - textHeight) / 2;
const island = device.island && {
width: Math.round(device.island.width * scale),
height: Math.round(device.island.height * scale),
top: Math.round(device.island.top * scale),
};
return {
textTop: Math.round(textTop),
left: Math.round((store.width - width) / 2),
top: Math.round(textTop + textHeight + GAP),
width,
height,
bezel,
screenWidth,
screenHeight,
scale,
radius: Math.round(device.radius * scale),
island,
};
}
function calloutLayout(callout, frame, store) {
const [cropX, cropY, cropWidth, cropHeight] = callout.crop;
const scale = Math.min(frame.scale * callout.zoom, (store.width - 2 * MARGIN) / cropWidth);
const width = Math.round(cropWidth * scale);
const height = Math.round(cropHeight * scale);
const centreX = frame.left + frame.bezel + (cropX + cropWidth / 2) * frame.scale;
const centreY = frame.top + frame.bezel + (cropY + cropHeight / 2 + callout.shift) * frame.scale;
const clamp = (value, max) => Math.round(Math.min(Math.max(value, MARGIN), max - MARGIN));
return {
width,
height,
left: clamp(centreX - width / 2, store.width - width),
top: clamp(centreY - height / 2, store.height - height),
};
}
async function resize(source, crop, width, height, file) {
let image = sharp(source);
if (crop) image = image.extract({ left: crop[0], top: crop[1], width: crop[2], height: crop[3] });
await image.resize(width, height, { kernel: "lanczos3" }).png().toFile(file);
return pathToFileURL(file).href;
}
// Agent marks scattered behind the first panel, from the homepage's featured
// agents. Each entry is [mark, centre x, centre y, size, rotation], with the
// centre as a fraction of the canvas and the size as a fraction of its width.
const AGENT_MARKS = [
["ClaudeCodeIcon", 0.11, 0.075, 0.12, -14],
["CodexIcon", 0.89, 0.1, 0.13, 12],
["PiIcon", 0.07, 0.34, 0.1, 10],
["OmpIcon", 0.93, 0.37, 0.11, -10],
["OpenCodeIcon", 0.08, 0.6, 0.11, -8],
["CursorIcon", 0.92, 0.63, 0.1, 16],
["MuseCodeIcon", 0.07, 0.85, 0.11, 14],
["AntigravityIcon", 0.92, 0.88, 0.12, -12],
];
async function loadAgentMarks(work) {
const bundle = path.join(work, "agent-icons.mjs");
await build({
entryPoints: [path.join(repo, "packages/website/src/components/agent-icons.tsx")],
outfile: bundle,
bundle: true,
format: "esm",
platform: "node",
jsx: "automatic",
});
const icons = await import(pathToFileURL(bundle).href);
return Object.fromEntries(
AGENT_MARKS.map(([name]) => [name, renderToStaticMarkup(createElement(icons[name]))]),
);
}
function backdrop(store, marks) {
return AGENT_MARKS.map(([name, x, y, size, rotation]) => {
const side = Math.round(size * store.width);
const left = Math.round(x * store.width - side / 2);
const top = Math.round(y * store.height - side / 2);
return `<div class="mark" style="left:${left}px;top:${top}px;width:${side}px;height:${side}px;transform:rotate(${rotation}deg)">${marks[name]}</div>`;
}).join("");
}
function page({ store, shot, frame, screen, callout, calloutImage, font, marks }) {
const behind = shot.backdrop === "agents" ? backdrop(store, marks) : "";
const { island } = frame;
const notch = island
? `<div class="island" style="top:${island.top}px;width:${island.width}px;height:${island.height}px;margin-left:-${island.width / 2}px;border-radius:${island.height / 2}px"></div>`
: "";
const lifted = callout
? `<img class="callout" src="${calloutImage}" style="left:${callout.left}px;top:${callout.top}px;width:${callout.width}px;height:${callout.height}px">`
: "";
return `<!doctype html>
<meta charset="utf-8">
<style>
@font-face { font-family: "SF Pro"; src: url("${pathToFileURL(font).href}"); font-weight: 1 1000; }
* { margin: 0; box-sizing: border-box; }
html, body { width: ${store.width}px; height: ${store.height}px; overflow: hidden; background: ${COLORS.canvas}; }
body { position: relative; font-family: "SF Pro"; -webkit-font-smoothing: antialiased; }
.text { position: absolute; top: ${frame.textTop}px; left: 0; right: 0; margin: 0 auto; width: ${store.textWidth}px; text-align: center; }
.mark { position: absolute; color: #fff; opacity: 0.09; filter: grayscale(1) brightness(0) invert(1); }
.mark svg { display: block; width: 100%; height: 100%; }
h1 { color: ${COLORS.title}; font-size: ${store.titleSize}px; font-weight: 600; line-height: ${TITLE_LINE}; letter-spacing: -0.022em; text-wrap: balance; }
p { margin-top: ${BODY_GAP}px; color: ${COLORS.body}; font-size: ${store.bodySize}px; font-weight: 400; line-height: ${BODY_LINE}; letter-spacing: -0.005em; text-wrap: balance; }
.device { position: absolute; left: ${frame.left}px; top: ${frame.top}px; width: ${frame.width}px; height: ${frame.height}px; padding: ${frame.bezel}px; border-radius: ${frame.radius + frame.bezel}px; background: #040505; box-shadow: 0 0 0 3px #343b39, 0 0 0 5px #161b1a, 0 60px 140px rgba(0, 0, 0, 0.55); }
.screen { position: relative; width: ${frame.screenWidth}px; height: ${frame.screenHeight}px; border-radius: ${frame.radius}px; overflow: hidden; }
.screen img { display: block; width: ${frame.screenWidth}px; height: ${frame.screenHeight}px; }
.island { position: absolute; left: 50%; background: #000; }
.callout { position: absolute; display: block; border-radius: 36px; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.14), 0 40px 100px rgba(0, 0, 0, 0.6); }
</style>
${behind}
<div class="text"><h1>${shot.title}</h1><p>${shot.body}</p></div>
<div class="device"><div class="screen"><img src="${screen}">${notch}</div></div>
${lifted}`;
}
async function renderStore(browser, name, store, font, marks, work) {
const dir = path.join(output, name);
await mkdir(dir, { recursive: true });
const view = await browser.newPage({
viewport: { width: store.width, height: store.height },
deviceScaleFactor: 1,
});
const device = DEVICES[store.device];
const frame = deviceLayout(store, device);
const files = [];
for (const [index, scene] of SCENES.entries()) {
const stem = `${name}-${scene.id}`;
const shot = { ...scene, ...scene[store.device] };
const screen = await resize(
raw(store.device, shot.capture),
null,
frame.screenWidth,
frame.screenHeight,
path.join(work, `${stem}-screen.png`),
);
const callout = shot.callout ? calloutLayout(shot.callout, frame, store) : null;
const calloutImage = callout
? await resize(
raw(store.device, shot.callout.from),
shot.callout.crop,
callout.width,
callout.height,
path.join(work, `${stem}-callout.png`),
)
: null;
const html = path.join(work, `${stem}.html`);
await writeFile(html, page({ store, shot, frame, screen, callout, calloutImage, font, marks }));
await view.goto(pathToFileURL(html).href);
await view.evaluate(async () => {
await document.fonts.ready;
await Promise.all(Array.from(document.images, (image) => image.decode()));
});
await expectLines(view, "h1", 2, `${stem} title`);
await expectLines(view, "p", 1, `${stem} subline`);
const file = path.join(dir, `${String(index + 1).padStart(2, "0")}-${scene.id}.png`);
await sharp(await view.screenshot())
.removeAlpha()
.png()
.toFile(file);
files.push(file);
console.log(path.relative(repo, file));
}
await view.close();
return files;
}
async function expectLines(view, selector, expected, label) {
const lines = await view.$eval(selector, (element) =>
Math.round(element.clientHeight / parseFloat(getComputedStyle(element).lineHeight)),
);
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
}
// Google Play and F-Droid read their screenshots from the Android metadata,
// which holds links to these renders. Rewriting the links on every render keeps
// added, removed and renamed panels in step with the App Store.
async function linkAndroidScreenshots(folder, files) {
const dir = path.join(androidImages, folder);
await rm(dir, { recursive: true, force: true });
await mkdir(dir, { recursive: true });
for (const [index, file] of files.entries()) {
await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`));
}
}
async function renderPreview(rows) {
const tile = 440;
const gap = 40;
const resized = [];
let top = gap;
let width = 0;
for (const files of rows) {
let left = gap;
let rowHeight = 0;
for (const file of files) {
const meta = await sharp(file).metadata();
const height = Math.round((meta.height * tile) / meta.width);
const input = await sharp(file).resize(tile, height, { kernel: "lanczos3" }).toBuffer();
resized.push({ input, left, top });
left += tile + gap;
rowHeight = Math.max(rowHeight, height);
}
width = Math.max(width, left);
top += rowHeight + gap;
}
const file = path.join(output, "preview.png");
await sharp({ create: { width, height: top, channels: 3, background: "#070a09" } })
.composite(resized)
.png()
.toFile(file);
console.log(path.relative(repo, file));
}
const font = findFont();
const work = await mkdtemp(path.join(os.tmpdir(), "paseo-store-"));
const browser = await chromium.launch({ headless: true });
try {
const marks = await loadAgentMarks(work);
const rows = [];
for (const [name, store] of Object.entries(STORES)) {
const files = await renderStore(browser, name, store, font, marks, work);
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
rows.push(files);
}
await renderPreview(rows);
} finally {
await browser.close();
await rm(work, { recursive: true, force: true });
}
+3 -25
View File
@@ -12,26 +12,6 @@ initializing → idle → running → idle (or error → closed)
Each live agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, or `error`. `closed` is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.
## Daemon restart recovery
Hosts resume interrupted agents by default after graceful shutdown or a crash. The daemon
reopens the saved provider conversation and settings, then sends `Resume working.`. Change
`agents.resumePrompt` or disable `agents.resumeOnStartup` in host config or Host settings → Agents.
Disabling recovery consumes pending intent; enabling it later does not revive old work.
Only agents recorded as running qualify. Idle and initializing agents stay inactive. Archived
agents or workspaces, private helpers and Hub-owned executions retain their own lifecycle.
Explicit creation environment overrides are saved with the agent and reapplied when reopening.
Startup chooses candidates before anything else reads or repairs persisted running state, then
opens connections and resumes every candidate in the background. A provider that is slow to reopen
holds back only its own agent; a failed reopen is logged. Graceful shutdown records intent before
closing runtimes.
This is best-effort continuation. A crash between saving intent and dispatching a prompt can lose
or repeat a continuation. Provider resume owns conversation restoration; there is no recovery retry
loop or exactly-once turn guarantee.
## Runtime residency
An unarchived agent may be `closed` without being deleted or archived. Closing releases its provider
@@ -159,10 +139,8 @@ caller, child, and host into one watch.
Bootstrap restores these watches after repairing stale running records to `closed` and before opening
ingress. Reading a closed child's result does not load its provider. A finish notification reports that
the child's run has settled; the parent decides whether its task is complete. A child chosen for restart
recovery is observed only after its resume was sent, so the parent never hears that it was closed
in between. Archived or deleted parents suppress delivery;
parent-owned creation watches also stop
the child's run has settled; the parent decides whether its task is complete. Recovery never resumes
child work. Archived or deleted parents suppress delivery; parent-owned creation watches also stop
reporting after detachment.
Intent remains stored until the parent's notification dispatch is acknowledged. Dispatch failure
@@ -310,7 +288,7 @@ parentAgentId === thisAgent.id AND !archivedAt
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) instead, replacing the subagents list in place (Back returns to the list); the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
+2 -5
View File
@@ -98,7 +98,7 @@ The older `android`, `android:development`, and `ios` commands remain aliases fo
`android:production` still builds and installs a release through Expo; use `build:apk` when you
need an artifact. `android:clear` removes the generated Android project.
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
### Diagnostic logs without ADB
@@ -215,8 +215,7 @@ fastlane/metadata/android/
│ ├── images/
│ │ ├── icon.png (512x512)
│ │ ├── featureGraphic.png (1024x500)
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
│ └── changelogs/ (generated — see below)
├── ja/
└── zh-CN/
@@ -224,8 +223,6 @@ fastlane/metadata/android/
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots).
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
-3
View File
@@ -66,9 +66,6 @@ WebSocket message schemas. Shared configuration schemas belong in protocol leaf
retaining the daemon's runtime and wire-schema allocations. The server export tests guard
these dependency trees with tree-shaking disabled to match unbundled production imports.
Terminals do not live in the daemon process: their PTYs run in a detached terminal worker that
survives daemon restarts, crashes and upgrades. See [terminal-worker.md](terminal-worker.md).
Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's
project Git observer keeps one non-recursive watch on each lexically equivalent active project root
and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs
+4 -6
View File
@@ -150,20 +150,18 @@ One row off the rail makes the whole card look unconsidered.
Compact-first. The small case is designed; the large case adds chrome around it.
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
- On wide layouts: a sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
On a narrow window using the wide layout, app navigation yields when the remaining width cannot accommodate the content topology. Settings prefers a 720px split (320px list + 400px detail); its list shrinks first to keep forms readable without reducing the detail below 400px. Explorer reserves at least 240px plus a 360px workspace. These are local space budgets, not compact breakpoints. The app sidebar shrinks from its preferred 320px toward a 200px minimum to leave a 400px center target; its preferred width fits at 720px. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
+13 -24
View File
@@ -33,36 +33,25 @@ keeps only agents and terminals. A panel kind opts in with `compactSheet` in its
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet lands on
top of it, and closing the top sheet (drag, Close, backdrop, or Android Back) uncovers the one below.
A subagent chosen from the subagents list is the exception. It opens as a page of the list's own
sheet and replaces the list in place: Back returns to the list, Close ends both, and Open parent
closes the list, landing on the pane the list was opened from. The store's `selectSheetTab` describes
the `SheetTab` a reveal would show without opening it, so the page never enters `sheetsByWorkspace`.
See [menus.md](menus.md#a-page-that-fills-the-sheet) for the page contract.
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
Both kinds render the same `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`): the
header (icon, title, Back when there is a list to return to, Open as tab, Close), the pane, its
bottom inset, and its focus. Only the container differs, a workspace sheet in `sheet-tabs.tsx` or a
menu page in `sheet-pane/page.tsx`, so a fix to what a sheet shows lands in one place. The
container owns Android Back; the pane never subscribes to it.
Sheets are modal and cover both compact sidebars as well as the workspace. The header drags the
sheet through `SheetDragArea`, because the panel scrolls on its own and content panning would fight
it. Its trailing rail holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the
full-screen glyph beside Close, so the title keeps its width. Compact sheets use the app overlay
host: a workspace-local host sits behind the app-level left sidebar. The sheet pane bridge
(`useSheetPaneBridge`) preserves existing editing sessions, caches, file-link resolution and
supported navigation context without moving their providers. Keep this capability at the workspace
boundary; new sheet callers must not assemble provider internals. Wide layouts retain local hosting
and pane geometry. Both hosts forward the same Gorhom modal queue; neither creates another stack.
See
Sheets are modal and cover both compact sidebars as well as the workspace. A sheet's header is its drag
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local
host sits behind the app-level left sidebar. The workspace environment bridge preserves existing
editing sessions, caches, file-link resolution and supported navigation context without moving
their providers. Keep this capability at the workspace boundary; new sheet callers must not
assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts
forward the same Gorhom modal queue; neither creates another stack. See
[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site).
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
-13
View File
@@ -410,19 +410,6 @@ bridges the menu's two contexts; the rest pass `null`.
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
## Gotcha 8 — Read safe-area insets where the overlay draws
A compact sheet tab's body (`SheetBottomInset` in `adaptive-modal-sheet.tsx`) pads the bottom
inset and gives its content a bottom inset of 0, so the composer dock sits the same in a sheet as
in a tab. An overlay opened from that content and calling `useSafeAreaInsets()` at its call site
gets that 0 and draws over the navigation bar.
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
inside it and read the inset below that. The modal is its own window, and the provider measures
it. See `attachment-lightbox.tsx`.
## Recipe for a new anchored panel
Before you write a new one, ask:
+1 -2
View File
@@ -22,7 +22,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. A subagent chosen from the subagents list shows as a page of the list's sheet instead of a sheet of its own. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -34,7 +34,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Hook** — A plugin lifecycle handler registered with `server.addHook`, named `subject.operation.position` with `before` or `after`; each hook declares whether Paseo waits. See [hooks](../public-docs/plugins/hooks.md).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
+11 -32
View File
@@ -52,12 +52,10 @@ is another, with its own icons and hover-revealed actions — but both hand the
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
A row whose result opens a sheet opens it as a page of the menu's own sheet, never as a second
sheet over it: the row names a [fill page](#a-page-that-fills-the-sheet) with `opensPage`, the page
replaces the list in place, Back returns to the list, and Close ends both. A row that opens a tab
A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
store which sheet tab a row would show (`useSubagentSheetTab`), since the answer changes with the
layout; a row with none opens its tab.
store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
## Pages
@@ -103,23 +101,6 @@ The sheet keeps `keyboardBehavior="interactive"`. `extend` grows a sheet to its
point, and with `enableDynamicSizing` that point is the content's own height — a short page does
not grow, and the keyboard comes up over the field you are typing into.
## A page that fills the sheet
A page can carry its own content instead of rows: `{ id, fill: true, content }`. Only the sheet
renders one. It takes the full-sheet snap point (`FULL_SHEET_SNAP_POINTS`) instead of sizing to
content, and the sheet stops panning on content drags, because the page scrolls on its own and a
drag would fight that scroll. The page's header drags the sheet instead, through `SheetDragArea`.
The content renders on the far side of the sheet's portal, so it brings its own context bridge,
built where the page is defined. Sheet tabs are the one user: `useSheetPanePage` turns a `SheetTab`
into a fill page around the same `SheetPane` the workspace's own sheets render. See
[explorer-sidebar.md](explorer-sidebar.md#sheets).
Back pops one page. The header's Back and Android Back both call `goBack`, which answers `false` at
the root so the sheet closes instead. A page whose definition disappears while it is open, such as
a subagent archived from elsewhere, is pruned from the path, and the sheet falls back to the page
that opened it.
## Hover intent
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
@@ -200,17 +181,15 @@ its own.
## Gotchas
- **Web enters without Reanimated.** For a custom keyframe, Reanimated's web path snapshots the
surface's rect when the animation ends and writes it back as inline top/left/width/height 750ms
after the start. Whatever moved or resized the surface in between was undone: content that
grew once its data arrived left the menu clipped, or hanging over its anchor, until it moved
again. `AnchoredSurface` plays the same keyframes through the Web Animations API on web, which
leaves no inline style; native keeps the Reanimated `Keyframe`.
- **Animate only once placed.** `AnchoredSurface` remounts the surface when its position resolves,
so the entering animation runs at the final position rather than ending unseen at the
off-screen measuring position.
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
identity changes — a pushed page taller than the one it replaced is clipped without that.
- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back
750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the
entering animation never ends at the off-screen measuring position; on a slow machine that
write moved the open menu back off-screen.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it. A fill page is the one exception.
rarely the height of the page before it.
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
+19 -35
View File
@@ -99,10 +99,6 @@ Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.ser
`packages/server/src/server/plugins/builtin/index.ts`; the workspace, build copy, and CI
checks cover that registry. Unlisted directories do not load.
The `worktree-include` built-in copies gitignored files selected by
[`.worktreeinclude`](../public-docs/worktrees.md#copy-local-files-with-worktreeinclude)
before setup and agents, through `worktree.create.after`.
Desktop packaging ships the entire built-in plugin directory as an external resource,
including declarations. The external esbuild compiler cannot read Electron's `app.asar`
filesystem, and packaging dependencies excludes `.d.ts` files needed for import validation.
@@ -337,10 +333,11 @@ export default function contribute(client: PluginClientContext) {
}
```
The contribution function must return cleanup for resources created by plugin code. Server cleanup
may be async; Paseo waits for it when stopping the server plugin. Cleanup errors are logged and do
not interrupt host teardown. Client installation retention and disposal follow the public
[hosts and lifecycle contract](../public-docs/plugins/reference.md#hosts-and-lifecycle).
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
@@ -385,15 +382,6 @@ grace. During daemon startup, plugin sessions may connect while application WebS
paused; the daemon accepts clients only after configured plugins have settled and the initial
catalog is complete.
The app retains client bundles per host in its file cache (IndexedDB on web), keyed by plugin ID
and the daemon's SHA-256. A cold client verifies stored contents before advertising their hashes;
missing or corrupt files request full bundles. Only the latest catalog's bundles remain. Hashing
uses Expo's native async digest on mobile, avoiding a JavaScript hash loop on Hermes; reading and
passing the bundle string still costs memory and time once per client lifetime.
Persistence runs after catalog reconstruction without delaying installation. One write runs at a
time; while storage is busy, only the newest pending catalog is kept. A failed write leaves the
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).
@@ -440,22 +428,21 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
## Lifecycle hooks
Server entries register async handlers with `server.addHook(name, handler)`, returning a cleanup
function. Names follow `subject.operation.position`, ending in `before` or `after`. Waiting is a per-hook
registry property, independent of position. The [hooks reference](../public-docs/plugins/hooks.md)
owns payloads, ordering, failure behavior, and examples. `agent.create.before` edits saved
configuration; `agent.open.before` contributes launch-only `env`, `tools`, and `mcpServers` on
every open. Executable tool definitions stay in the plugin process; only metadata crosses the boundary.
Worktree placement is resolved before Git source planning, so a hook can change the branch and base
without undoing fetched refs. Restores use the recorded destination and bypass creation hooks.
Workspace records decide ownership, including explicit non-ownership after recovery. Without a record,
ownership checks prefer the marker-verified worktree root over the legacy
path shape: a plugin can place a worktree several directories below the default worktrees root.
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
re-derived on each open and following the plugin's lifecycle. See
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
`plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
executable definitions stay in the plugin process.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
answer a permission through its own daemon session. Awaiting it there deadlocks that command.
## Contribute a provider
@@ -626,10 +613,7 @@ Request observation with `client.paseo.agents.list({ subscribe: {} })` and consu
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
also run after reconnect; replace your view before applying its subsequent updates. An installation
stays loaded during a dropped connection while the app retains its `DaemonClient` object. An
unchanged installation does not rerun setup on reconnect, so refresh state from those snapshots
rather than from setup. Switching connections replaces the client object and reinstalls plugins. The installation
also run after reconnect; replace your view before applying its subsequent updates. The installation
owns every observation created through its API and releases them on unload, including setup failure.
Mounted surfaces and command invocations have shorter API lifetimes.
+74 -147
View File
@@ -6,10 +6,10 @@ All workspaces share one version and release together.
A release has exactly two steps. The agent does the first, the user authorizes the second.
**Preparation** (reversible — agent does this):
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all green
- resolve the [release base](#release-base-and-source), get the [release inventory](#release-inventory) approved when one applies, and confirm CI for the resulting release source is green
- resolve the release source to one commit and confirm that commit's existing CI is green
- classify the diff from the previous stable to the release source as patch or minor, then show the
target version and rationale to the user
- draft the changelog, show it to the user, wait for review
@@ -17,10 +17,9 @@ A release has exactly two steps. The agent does the first, the user authorizes t
**Go-ahead** (user says "go ahead"):
- commit the approved release inputs locally on the release branch
- run the release, which publishes npm and pushes the release branch and tag
- commit the approved release inputs locally
- run the release, which publishes npm and pushes the prepared branch and tag
- create the release heartbeat immediately and babysit it to completion
- [reconcile `main`](#reconcile-main) with what shipped
Rules that apply to both steps:
@@ -28,128 +27,56 @@ Rules that apply to both steps:
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
- An approval the user already gave covers what it names, such as an explicit PR selection or authorization to release autonomously. Do not ask for it again.
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
## Main stays open
## Release source and CI
Merge normal PRs into `main` at any time, including while a beta soaks or a release is being
finalized. Each release builds in a short-lived release workspace, and its published tag records
the shipped source.
The default release source is `origin/main`. Fetch `origin`, then record the
resolved commit. The default release checkout is a clean local `main` whose
`HEAD` equals `origin/main`.
## Choosing a path
An explicit user instruction can select another ref, such as a hotfix commit or
tag. Resolve that ref once and apply every source, diff, and CI check to that
commit instead of `origin/main`.
The user decides when the changes on `main` warrant a release and how much risk it takes:
Before making release-preparation commits, confirm the existing CI run for the
resolved commit is green. Pending CI is watched to completion. Release
preparation then stays local through the changelog, any explicitly requested ACP
catalog update, lockfile preparation, and the version commit. After approval,
commit the prepared inputs locally and run the release command. Its branch and
tag push is the one remote release batch and starts CI for the complete release
commit.
1. **Direct stable release** for a low-risk batch of polish and bug fixes. Stable desktop updates
already ramp over 36 hours (see [Staged rollout](#staged-rollout-stable-channel)), and store
review often takes about a day, with no guaranteed timing.
2. **Beta flow**, the default for changes that need testing. Release candidates on the `beta`
channel. Each beta folds into the series' single changelog entry, publishes npm only on the
explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
## Release branch discipline
A beta follows these defaults, which the user can explicitly override:
While you finalize a release on `main`, use a temporary `next` branch for work intended for the following
release. This applies to both beta and stable releases.
- Soak a beta at least 24 hours before promotion. Soak longer for risky or breaking changes, or
while feedback is open. Changes added in a later beta get their own soak; earlier soak time does
not count for them.
- A follow-up beta carries regressions, fixes, and polish for what that beta delivers. Unrelated
work on `main` and pre-existing bugs wait for a later release unless the user explicitly selects
them.
- Promotion ships the tested beta's product content unchanged and does not pull from `main`.
Adding a product change needs the user's explicit selection and a new beta, which soaks again.
- During a soak, ship urgent stable regressions and compatibility or provider model updates as a
[stable hotfix](#release-base-and-source) from the latest stable tag. The beta keeps its own
pace.
- Create each new `next` from freshly fetched `origin/main`. Reuse it while active.
- "This goes to next" means create the PR against `next` or retarget an existing
PR, and keep that destination through delivery.
- Keep `next` current by merging `origin/main` into it as release fixes land.
Avoid rebasing this shared branch because agents and open PRs depend on its history.
- After the release ships, bring `next` up to date and open a `next` → `main` PR.
Pass CI and merge without squashing away the individual PR commits needed for
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
`next`. Create it fresh when needed again.
npm dist-tags are independent pointers:
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
push runs target only `main`, and GitHub permits only squash merges. Enable
required-check protection for `next`, CI on its pushes, and merge commits for the
integration PR. Handle PR
base changes (`edited` events) so retargeting runs checks against the new base;
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
- A beta release publishes with `--tag beta` and moves only `beta`; `latest` remains on the
newest stable.
- A stable release publishes to `latest`. Move `beta` to it as well when the stable version is
newer than the version `beta` points to, which covers promotions and direct stable releases, so
`@getpaseo/cli@beta` users never sit behind stable. A stable hotfix on an older line leaves
`beta` on the active newer beta.
### Hotfix from a release tag
## Release base and source
The release base is the tag or commit a release workspace starts from. The release source is the
base plus any approved cherry-picks, and every source, diff, and CI check uses it.
| Release | Base | Changes on top |
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------- |
| Starting release: first beta of a line, or direct stable | `origin/main`, captured once | None |
| Follow-up beta | The selected published beta tag, normally the series' latest | Approved commits from `main` |
| Promotion | The beta tag being promoted | None by default |
| Stable hotfix | The latest appropriate stable tag | Approved commits from `main` |
An explicit user instruction can select another base.
1. Fetch `origin` with tags and resolve the base to one commit.
2. Create the release workspace through Paseo MCP on a new branch from the base, named
`release/vX.Y.Z` or `release/vX.Y.Z-beta.N` for the version being cut. Run the release from
this workspace: `release:push` pushes `HEAD` to the current branch.
3. Cherry-pick the approved commits in their `main` order with `git cherry-pick -x`, so each
commit records the `main` commit it came from. Include the prerequisites the inventory listed.
Resolve conflicts within the approved changes and report how. A resolution that needs new
behavior is a scope addition: surface it for selection.
4. Confirm CI for the release source. When the source is a commit whose CI already passed (the
captured `origin/main`, or the beta tag being promoted), use that run and watch pending CI to
completion. Otherwise push the release branch and run `gh workflow run ci.yml --ref <branch>`,
because CI push runs target only `main`.
5. Keep the changelog, any explicitly requested ACP catalog update, lockfile preparation, and the
version commit local until the go-ahead. The release command pushes the release branch and tag
as one batch, and the tag push starts the release workflows. Then run
`gh workflow run ci.yml --ref <branch>` so CI covers the complete release commit.
6. [Reconcile `main`](#reconcile-main).
A stable hotfix can take the version a patch beta line would promote to, such as `0.13.3`
shipping while `0.13.3-beta.2` soaks. Never republish or reuse a shipped version. Continue the line
as the next unused patch beta, `0.13.4-beta.1`, from the tested beta tag. No `version:all:*` mode
computes that from a prerelease, so follow the [manual steps](#manual-step-by-step) with
`npm version 0.13.4-beta.1 --include-workspace-root --message "chore(release): cut %s"` and
`npm run release:publish:beta`. Record the tested beta tag as its source so its soak still counts
for the unchanged content.
## Release inventory
When the user asks for a follow-up beta or a promotion, list what `main` has that the base does
not, then get the batch approved. There is no release label or queue.
1. Find the commits on `origin/main` whose patches are not in the base tag:
```bash
git log --cherry-pick --right-only --no-merges --format='%h %s' <base-tag>...origin/main
```
Patch equivalence recognizes commits already cherry-picked into the tag, so trust the tag
content over PR state or branch names.
2. Present a short include/defer list. Link each PR and give one plain reason. List separately the
prerequisites an included PR depends on and anything that adds scope beyond what the beta
delivers. For each fix that shipped in a stable hotfix after the beta's base, confirm the beta
preserves its behavior, directly or through a superseding change. List the missing ones for
explicit selection; adding one to a promotion needs a new beta.
3. Wait for the user to approve the batch, unless an explicit selection or autonomous
authorization they already gave covers it.
For a promotion, every item defaults to defer.
## Reconcile main
After the release push, open a PR into `main` that brings in what shipped and keeps newer
development on `main`:
- The shipped `CHANGELOG.md` and `CHANGELOG.zh-CN.md` entries, exactly as tagged. A beta or
promotion entry replaces the series entry on `main`. A stable hotfix on an older line goes
below any newer entries.
- The shipped version in every `package.json` and `package-lock.json`, unless `main` already
carries a newer version, plus the F-Droid changelog files a stable release generates.
- No claims about unshipped work: a shipped entry describes only the tagged content, and notes
`main` holds for changes that have not shipped stay out of it.
Delete the release branch once the PR merges. The tag is the record of shipped source; Paseo
maintains no release track besides `main`.
If `main` contains changes you do not want to release, branch from the affected
release tag and cherry-pick only the required fixes. Run CI on that branch, then
use the normal release flow with it as the explicit source, choosing a new patch
or beta version. Ensure the fixes and changelog also reach `main` and any active
`next`, preserving newer development and version changes there. This is a
short-lived hotfix branch, not another maintained release track.
## ACP catalog updates
@@ -164,6 +91,21 @@ ACP catalog work enters a release through an explicit user request:
The release authorization covers the requested ACP commit. It ships in the same
release push as the changelog and version commit.
## Two paths
There are two supported release paths:
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision
Every fresh release starts by classifying the full diff from the previous
@@ -191,7 +133,7 @@ as described in **Fixing a failed release build**.
Before running any stable release command:
- Make sure the release source passed CI, the approved release inputs are committed locally on the release branch, and the working tree is clean.
- Make sure the resolved release source passed CI, the approved release inputs are committed locally on the intended branch, and the working tree is clean.
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
- Do not use a release command as a substitute for checking whether the current commit is actually ready.
@@ -203,9 +145,9 @@ npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
After the stable release succeeds, check `npm view @getpaseo/cli dist-tags`. When the new stable
is newer than `beta`, move `beta` to it for every published package. This changes dist-tags only;
do not republish the packages:
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
@@ -214,8 +156,8 @@ for package in highlight relay protocol client plugin server cli; do
done
```
Verify `latest` resolves to `PASEO_VERSION`, and `beta` resolves to `PASEO_VERSION` or the
active newer beta, before considering the stable release complete.
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
@@ -233,7 +175,7 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version when it is newer than beta (see above).
# Then move npm's beta dist-tag to this stable version using the command above.
```
## Beta flow
@@ -363,8 +305,8 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots.
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane uploads the store screenshots and submits that same build for App Store review.
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
@@ -375,17 +317,6 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
### Store screenshots
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
- When `submit_review` fails with `appStoreVersions … is not in valid state`, the reason is in the API error's `meta.associatedErrors`, which fastlane does not print. Read it with a `POST /v1/reviewSubmissionItems` against the version. A required age-rating question that Apple added, such as `socialMedia`, fails this way; answer it under App Information > Age Rating in App Store Connect, then resubmit.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the `en-US` phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches. The lane stages only `en-US`: supply uploads every locale folder in one edit, and Play rejects F-Droid locales such as `ja`, which fails the whole edit. `Upload Google Play screenshots` reruns the step for the current release without a rebuild.
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
### Watching mobile builds from the terminal
Use the EAS CLI from `packages/app/`:
@@ -428,7 +359,6 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
- `build_android` — Android store binary built
- `submit_android` — Android binary submitted to the Play Store
- `upload_android_screenshots` — Google Play screenshots replaced from the repo
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
@@ -547,7 +477,7 @@ git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
```
This ensures the checkout ref matches the code you want built, with the fix included.
This ensures the checkout ref matches the actual code on `main` with the fix included.
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
@@ -690,7 +620,7 @@ Rules:
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
When listing PR numbers, `git log --cherry-pick --right-only --no-merges --format='%H %s' v<previous>...<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects and skips commits the previous stable already shipped as cherry-picks.
When listing PR numbers, `git log --format='%H %s' v<previous>..<release-source-sha> | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
## Changelog ordering
@@ -722,11 +652,11 @@ Every entry covers `previous stable release → release source`: first beta, lat
### Beta release
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it with its [`CHANGELOG.zh-CN.md` translation](#changelog-translations) before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
@@ -737,24 +667,22 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] GitHub `Docker` workflow is green and the versioned beta image is published without moving `latest`
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
- [ ] EAS `Release iOS Beta` completed its build, TestFlight distribution, external beta group, and Beta App Review path
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] The release base and changes match the [release base table](#release-base-and-source) and any approved [inventory](#release-inventory), and CI for the release source is green
- [ ] A promotion ships the tested beta's product content after its soak, unless the user explicitly overrode either default
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release. Commit the matching [`CHANGELOG.zh-CN.md` entry](#changelog-translations) with it
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] The changelog, ACP, lockfile, and version commits stayed local until the approved release command pushed the release branch and tag
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] `latest` resolves to the new stable, and `beta` resolves to it too unless an active newer beta holds `beta`
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
@@ -768,5 +696,4 @@ Every entry covers `previous stable release → release source`: first beta, lat
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
- [ ] The [reconcile PR](#reconcile-main) brought the shipped changelog entries and version into `main`
- [ ] The release heartbeat was created after the tag push and deleted only after every item above passed
-69
View File
@@ -1,69 +0,0 @@
# Terminal worker
Every terminal's PTY lives in the terminal worker, a process separate from the daemon. The worker outlives the daemon, so restarts, crashes and upgrades keep terminals running. This doc covers what the code can't tell you: why the process model is shaped this way, the rules nothing enforces, and the known limits.
## Process model
```
supervisor (paseo.pid) ──► daemon worker ──► launcher ──► terminal worker ──► PTY programs
exits (re-parented to init)
```
- The daemon starts the worker through a launcher (`terminal-worker-process.ts --launch`). The launcher spawns the worker `detached`, waits for `ready` on fd 3, prints the pid and exits, so the worker is re-parented and leaves the daemon's process tree. The supervisor's forced kill uses `tree-kill`, which walks parent pids: a worker that stayed a child, even detached, would die with a hung daemon.
- The worker talks to one daemon at a time over a local socket (named pipe on Windows). Losing the daemon means "no controller", not exit: PTYs keep running, events are dropped, and the headless terminal keeps the screen for the next daemon's `listTerminals`.
- A socket disconnect triggers reconnection to the same worker and a screen replay. Only confirmed process death retires its sessions; the next terminal creation shares one replacement launch. Dead-worker cleanup stays scoped to that worker, so a healthy draining peer keeps running. Shutdown and detach suppress recovery.
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
## Record, socket and secret
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
## Protocol versions
`TERMINAL_WORKER_PROTOCOL_VERSION` (`terminal-worker-protocol.ts`) is bumped whenever a request, response or event changes shape. A daemon classifies each live worker at start:
| Worker reports | Daemon does |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| its own version | adopts it |
| one version older | adopts it to drain: it keeps its terminals, takes no new ones, and is let go after its last terminal exits |
| two or more older | shuts it down; its terminals close (logged) |
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. When you bump the version, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
Tests stand up other versions with `PASEO_TERMINAL_WORKER_PROTOCOL_OVERRIDE`, read by the worker. It is test-only.
## Running from its own copy
A worker outlives the installation that started it. An npm or desktop update replaces that installation's files, and the next terminal the old worker creates would load node-pty's `spawn-helper` and the zsh integration from paths that no longer exist. So a built daemon does not launch the worker from its install:
- `npm run build` bundles the worker with esbuild (`packages/server/scripts/build-terminal-worker.mjs`) into `dist/server/terminal-worker/` with its `shell-integration/` beside it. Only node-pty stays external. A package loaded through a module-local `createRequire` is invisible to esbuild and would be missing at run time; `terminal-worker-runtime.test.ts` fails if the bundle loads anything but built-ins and node-pty.
- Before launching, the daemon copies the bundle, the shell integration and node-pty (this platform's native build only) into `$PASEO_HOME/terminal-worker/<paseoVersion>-<hash>/` and launches from there. The hash covers the bundle and node-pty, so a local rebuild of the same version gets its own copy.
- A copy is deleted once no record names it: after a draining worker exits, after a kill cleanup, and at daemon start. The daemon and the reaper delete copies, never the worker itself, so a worker exiting cannot remove a copy a new worker is about to launch from.
- Dev (`.ts` source) launches from source and copies nothing.
Each create carries the controlling daemon's environment and Paseo CLI path (`baseEnv`, `paseoCliPath`), so a terminal opened after an upgrade gets the new installation's `PASEO_CLI` while older terminals keep theirs.
## Desktop
Desktop app updates and version-mismatch restarts stop the daemon with "keep terminals" on macOS and Linux, through `daemon stop --keep-terminals` (the shutdown RPC; SIGTERM always means "stop and close"). A daemon that predates the feature cannot keep terminals: the CLI refuses before sending anything, and desktop logs that and stops it with its terminals closed.
On Windows every desktop stop closes terminals. In the desktop app the worker runs as `Paseo.exe` with `ELECTRON_RUN_AS_NODE`, and a surviving one would lock the install folder the NSIS update replaces. Restarts, crashes and npm self-updates keep terminals on Windows too.
## Known limits
- On macOS `ps` has no session id, so a HUP-ignoring child of a shell that had already exited before cleanup started (re-parented to launchd) is not found. Linux reaches it through the session; a program that `exec`s keeps its entry on every OS.
- On Linux, a member of a gone shell's session is signaled only if its environment carries that terminal's `PASEO_TERMINAL_ID`. A job that scrubbed its environment (`env -i`) is reported as unconfirmed, with its pid, and never signaled.
- The agent-hook activity URL is baked into each shell when it starts. If the daemon's listen address changes across a restart, older shells report to the old address.
- Restoring terminals after a reboot is out of scope: records from an earlier boot are retired without signaling anything.
-5
View File
@@ -10,11 +10,6 @@ then replaces the previous tool state or merges into the current text item. Inte
are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes.
Pagination bounds each read, not total resident history. Active and idle agents retain their
projected transcript; archive releases it and later history reads rebuild it from the provider.
Streaming overhead must remain proportional to the retained transcript. Cumulative text snapshots
must be released after their deltas have been consumed.
The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
@@ -1,10 +0,0 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
@@ -1,10 +0,0 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
@@ -1,10 +0,0 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
@@ -1,10 +0,0 @@
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
Added
- Let plugins give tools to specific agents
Full notes: https://paseo.sh/changelog
@@ -1 +1 @@
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
../../../../../../packages/website/public/phone-1.png
@@ -1 +1 @@
../../../../../../assets/store-screenshots/output/google/02-chat.png
../../../../../../packages/website/public/phone-2.png
@@ -1 +1 @@
../../../../../../assets/store-screenshots/output/google/03-plugins.png
../../../../../../packages/website/public/phone-3.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google/04-review.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google/05-terminal.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
@@ -1 +0,0 @@
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
+1 -1
View File
@@ -1 +1 @@
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37590,7 +37590,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39793,12 +39793,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/server": "0.14.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -40045,10 +40045,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -40060,7 +40060,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40304,7 +40304,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40350,7 +40350,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40379,15 +40379,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40397,8 +40397,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40420,7 +40420,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40447,7 +40447,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40668,16 +40668,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/highlight": "0.14.0-beta.1",
"@getpaseo/plugin": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41598,7 +41598,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43208,7 +43208,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.14.0",
"version": "0.14.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -42,20 +42,6 @@ jobs:
build_id: ${{ needs.build_android.outputs.build_id }}
profile: production
upload_android_screenshots:
name: Upload Google Play screenshots
needs: [build_android, submit_android]
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Upload screenshots
run: |
export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}"
bundle exec fastlane android upload_screenshots
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [build_ios, distribute_ios]
@@ -1,27 +0,0 @@
name: Upload Google Play screenshots
# Standalone re-trigger for the release workflow's Google Play screenshot step.
# Replaces the en-US phone and tablet screenshots with the committed renders,
# without rebuilding or resubmitting the app.
on:
workflow_dispatch:
inputs:
android_version_code:
type: string
required: false
description: "versionCode of the production release to attach the change to, e.g. 14000. Leave empty when the production track has a single release."
jobs:
upload_android_screenshots:
name: Upload Google Play screenshots
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Upload screenshots
run: |
export ANDROID_VERSION_CODE="${{ inputs.android_version_code }}"
bundle exec fastlane android upload_screenshots
@@ -5,16 +5,12 @@ import { expectComposerVisible } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
expectSubagentRowGone,
expectSubagentRowVisible,
openSubagentFromCompactTrack,
seedSubagent,
} from "../support/helpers/subagents";
import {
backToList,
closeSheet,
expectNoSheetOpen,
expectOneSheetOpen,
expectOpenTabCount,
openSheetAsTab,
expectDeclinedDiscardPrompt,
@@ -56,10 +52,6 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Sheet child" },
);
const grandchild = await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: child.id, title: "Sheet grandchild" },
);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expectOpenTabCount(page, 1);
@@ -87,14 +79,12 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await tapSheetBackdrop(page);
});
await test.step("a subagent opens in place of the subagents list", async () => {
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await expect(childSheet).toBeVisible();
await expectSheetAtSnap(page, child.title);
await expectSubagentRowGone(page, child.id);
await expectOneSheetOpen(page);
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
// seeded agent has no context usage yet.
@@ -104,51 +94,23 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expectOpenTabCount(page, 1);
});
await test.step("Back returns the sheet to the list", async () => {
await backToList(page, child.title);
await expectSubagentRowVisible(page, child.id);
await expectOneSheetOpen(page);
});
await test.step("Close closes the list with the subagent", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await test.step("closing the subagent's sheet uncovers the list", async () => {
await closeSheet(page, child.title);
await expectSubagentRowGone(page, child.id);
await expectNoSheetOpen(page);
await expectSubagentRowVisible(page, child.id);
});
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
await openSubagentFromCompactTrack(page, child.id);
await page.getByTestId(`subagents-track-row-${child.id}`).click();
const childSheet = sheet(page, child.title);
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
await childSheet.getByTestId("parent-agent-callout-open").click();
await expect(childSheet).toHaveCount(0);
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 1);
});
await test.step("a nested subagent's parent box lands on its parent's sheet", async () => {
await openSubagentFromCompactTrack(page, child.id);
const childSheet = sheet(page, child.title);
await openSubagentFromCompactTrack(page, grandchild.id, childSheet);
const grandchildSheet = sheet(page, grandchild.title);
await expectSheetAtSnap(page, grandchild.title);
await expect(grandchildSheet.getByTestId("parent-agent-callout")).toContainText(child.title);
await grandchildSheet.getByTestId("parent-agent-callout-open").click();
await expect(grandchildSheet).toHaveCount(0);
await expect(childSheet).toBeVisible();
await expectOneSheetOpen(page);
await childSheet.getByTestId("parent-agent-callout-open").click();
await expect(childSheet).toHaveCount(0);
await expectNoSheetOpen(page);
await expectOpenTabCount(page, 1);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await openSubagentFromCompactTrack(page, child.id);
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await expectSheetAtSnap(page, child.title);
await openSheetAsTab(page, child.title);
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
@@ -161,7 +123,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
});
await test.step("once its tab closes, the subagent reopens in place of the list", async () => {
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
await page
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
@@ -179,7 +141,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await backToList(page, child.title);
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
@@ -8,11 +8,7 @@ import {
openDaemonOverview,
restartDaemonInSettings,
expectDaemonRestartComplete,
expectDaemonRestartFailed,
plantUnclosableTerminalProgram,
readWorkerPid,
openLongLivedTerminal,
listTerminalIds,
} from "../support/helpers/daemon-lifecycle";
test("settings restart waits for a replacement worker while retaining the supervisor", async ({
@@ -33,57 +29,6 @@ test("settings restart waits for a replacement worker while retaining the superv
}
});
test("settings restart keeps a terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
const terminalId = await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toContain(terminalId);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals closes the terminal", async ({ page }) => {
const daemon = await startIsolatedHostDaemon(randomUUID());
try {
await openLongLivedTerminal(daemon);
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
expect(await listTerminalIds(daemon)).toEqual([]);
} finally {
await daemon.close();
}
});
test("settings restart and close terminals shows the processes it could not close", async ({
page,
}, testInfo) => {
test.skip(process.platform === "win32", "The unclosable program is a POSIX zombie");
const daemon = await startIsolatedHostDaemon(randomUUID());
let program: Awaited<ReturnType<typeof plantUnclosableTerminalProgram>> | null = null;
try {
program = await plantUnclosableTerminalProgram(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page, { closeTerminals: true });
await expectDaemonRestartFailed(page, `pid ${program.pid}`);
await page.screenshot({
path: testInfo.outputPath("restart-cleanup-failed.png"),
fullPage: true,
});
} finally {
program?.release();
await daemon.close();
}
});
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
page,
}) => {
@@ -1,13 +1,3 @@
import {
withRetainedPlugin,
editRetainedDraft,
expectRetainedDraft,
expectStoredPluginTitle,
hideRetainedPreferences,
showRetainedPreferences,
expectChangedPlugin,
expectRemovedPlugin,
} from "../support/helpers/plugin-reconnect";
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -198,51 +188,3 @@ test("plugin content and host labels follow app language without losing an open
test.setTimeout(180_000);
await verifyPluginTranslations(page, info);
});
test("retains plugin state offline and reconciles settings, changed code and removal", async ({
page,
}) => {
test.setTimeout(180_000);
await withRetainedPlugin(page, async (probe) => {
await probe.open();
await editRetainedDraft(page, "Connected draft", 0);
await test.step("local plugin UI remains mounted and usable while disconnected", async () => {
await probe.disconnect();
await expectRetainedDraft(page, "Connected draft", 1);
await editRetainedDraft(page, "Offline draft", 1);
await probe.changeStoredTitle("Saved by another client");
await expectStoredPluginTitle(page, "Agent monitor");
await probe.reconnect();
await expectStoredPluginTitle(page, "Saved by another client");
await expectRetainedDraft(page, "Offline draft", 2);
});
await test.step("inactive settings refresh without replacing the installation", async () => {
await hideRetainedPreferences(page);
await probe.disconnect();
await probe.changeStoredTitle("Saved while preferences hidden");
await probe.reconnect();
await showRetainedPreferences(page);
await expectStoredPluginTitle(page, "Saved while preferences hidden");
await expectRetainedDraft(page, "Offline draft", 2);
});
await test.step("changed code replaces the retained installation after reconnect", async () => {
await probe.disconnect();
await probe.changeCode();
await expectRetainedDraft(page, "Offline draft", 2);
await probe.reconnect();
await expectChangedPlugin(page);
await editRetainedDraft(page, "New code draft", 0);
});
await test.step("removal is reconciled after reconnect", async () => {
await probe.disconnect();
await probe.remove();
await expectRetainedDraft(page, "New code draft", 1);
await probe.reconnect();
await expectRemovedPlugin(page);
});
});
});
@@ -1,5 +1,4 @@
import { test } from "../support/fixtures";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
withTimelinePlugin,
viewInlineThinkingExample,
@@ -7,8 +6,6 @@ import {
interactWithStreamingCard,
expectWholeCompletedCard,
expectBothConsecutiveTools,
trackPluginChatRecovery,
reconnectPluginChat,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
@@ -16,24 +13,13 @@ for (const width of [1100, 390]) {
page,
}, info) => {
await page.setViewportSize({ width, height: 900 });
const gate = await installDaemonWebSocketGate(page);
await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent);
await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff.
const recovery = trackPluginChatRecovery(page);
gate.holdTimelineResponses(agent.agentId);
await page.reload({ waitUntil: "domcontentloaded" });
await gate.waitForHeldTimelineResponse();
await recovery.expectCatalogWithoutRetransmission();
gate.releaseHeldTimelineResponses();
await expectWholeCompletedCard(page);
await recovery.expectRecoveredWithoutRetransmission();
recovery.reset();
await reconnectPluginChat(page, gate);
await recovery.expectRecoveredWithoutRetransmission();
await expectWholeCompletedCard(page);
});
});
@@ -204,26 +204,19 @@ test.describe("Projects settings", () => {
}) => {
const repo = await createTempGitRepo("projects-settings-empty-");
const client = await connectSeedClient();
let projectViewKey: string | null = null;
let projectId: string | null = null;
try {
await gotoAppShell(page);
projectViewKey = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectViewKey);
projectId = await addProjectFromSidebar(page, repo.path);
await openProjectSettingsFromSidebar(page, projectId);
await expectProjectSettingsFormVisible(page);
await expect(page.getByTestId("project-settings-back-button")).not.toBeVisible();
} finally {
if (projectViewKey) {
const project = (await client.listProjects()).projects.find(
(entry) => entry.projectKey === projectViewKey,
);
expect(project).toBeDefined();
await client.removeProject(project!.projectId);
expect(
(await client.listProjects()).projects.map((entry) => entry.projectKey),
).not.toContain(projectViewKey);
if (projectId) {
await client.removeProject(projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
@@ -1,7 +1,5 @@
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -216,66 +214,3 @@ test.describe("provider settings overlay stack", () => {
}
});
});
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await page.setViewportSize({ width, height: 900 });
await page.getByRole("button", { name: /Select model/ }).click();
if (width < 600) {
await page.getByTestId("agent-controls-model").click();
} else {
await page.getByRole("button", { name: "Back", exact: true }).click();
}
await page.getByTestId("model-provider-picker-timeout").click();
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
}
async function expectErrorHasSideGutters(page: Page) {
const error = page.getByText(/Timed out refreshing Pi timeout/);
const bounds = await error.evaluate((element) => {
const text = element.getBoundingClientRect();
const state = element.parentElement!.getBoundingClientRect();
return { left: text.left - state.left, right: state.right - text.right };
});
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
}
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
catalogRefreshTimeoutMs: 2000,
providers: {
"picker-timeout": {
extends: "pi",
label: "Pi timeout",
enabled: true,
command: [
process.execPath,
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
"--stall-model-discovery",
],
},
},
},
},
});
test.describe("model discovery timeout", () => {
for (const width of [1200, 390]) {
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
page,
withWorkspace,
}, testInfo) => {
await page.setViewportSize({ width: 1200, height: 900 });
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
await openTimedOutProvider(page, workspace.workspaceId, width);
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
await expectErrorHasSideGutters(page);
});
}
});
@@ -30,10 +30,6 @@ import {
addDirectHostFromSettings,
expectHostOnlineWithoutError,
expectIdleAgentRelease,
editRestartRecoveryPrompt,
expectRestartRecoveryEnabled,
expectRestartRecoveryPrompt,
setRestartRecoveryEnabled,
setIdleAgentRelease,
} from "../support/helpers/settings";
@@ -256,20 +252,3 @@ test.describe("Settings host page", () => {
await expectHostActionCards(page, serverId);
});
});
test("restart recovery host controls persist the opt-out and custom prompt", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
await expectRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Resume working.");
await editRestartRecoveryPrompt(page, "Continue the interrupted work.");
await setRestartRecoveryEnabled(page, false);
await page.reload();
await expectRestartRecoveryEnabled(page, false);
await setRestartRecoveryEnabled(page, true);
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
@@ -479,76 +479,3 @@ test.describe("Settings — compact master-detail", () => {
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
});
});
async function measureDockedNavigation(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const history = page.getByRole("button", { name: /^History/ }).filter({ visible: true });
const settings = page
.getByRole("button", { name: "Settings", exact: true })
.filter({ visible: true });
await expect(history).toBeVisible();
await expect(settings).toBeVisible();
return {
width,
history: await history.evaluate((el) => el.getBoundingClientRect().height),
settings: await settings.evaluate((el) => el.getBoundingClientRect().height),
};
}
async function measureSettingsSplit(page: import("@playwright/test").Page, width: number) {
await page.setViewportSize({ width, height: 951 });
const sidebar = page.getByTestId("settings-sidebar");
const detail = page.getByTestId("settings-detail-pane");
await expect(sidebar).toBeVisible();
await expect(detail).toBeVisible();
return {
width,
sidebar: await sidebar.evaluate((el) => el.getBoundingClientRect().width),
detail: await detail.evaluate((el) => el.getBoundingClientRect().width),
};
}
async function expectConsistentDockedNavigation(page: import("@playwright/test").Page) {
const wide = await measureDockedNavigation(page, 951);
for (const width of [600, 669, 719, 720]) {
const narrow = await measureDockedNavigation(page, width);
expect
.soft(narrow.history, `History keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.history, 0);
expect
.soft(narrow.settings, `Settings keeps its wide-layout density at ${width}`)
.toBeCloseTo(wide.settings, 0);
}
}
async function expectSettingsDetailKeepsReadingWidth(page: import("@playwright/test").Page) {
const reference = await measureSettingsSplit(page, 720);
for (const width of [600, 669, 719]) {
const narrow = await measureSettingsSplit(page, width);
expect
.soft(narrow.detail, `Settings preserves its detail budget at ${width}`)
.toBeGreaterThanOrEqual(reference.detail);
expect
.soft(narrow.sidebar, `Settings navigation yields width at ${width}`)
.toBeLessThan(reference.sidebar);
}
const wide = await measureSettingsSplit(page, 951);
expect(wide.sidebar).toBeCloseTo(reference.sidebar, 0);
expect(wide.detail).toBeGreaterThan(reference.detail);
}
test("docked navigation keeps its density and Settings preserves reading width", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "narrow-wide-layout-" });
await gotoWorkspace(page, workspace.workspaceId);
await expectConsistentDockedNavigation(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
await expectAppearanceContent(page);
await expectSettingsDetailKeepsReadingWidth(page);
await page.setViewportSize({ width: 599, height: 951 });
await expectSettingsSidebarHidden(page);
await expectAppearanceContent(page);
});
@@ -53,18 +53,11 @@ async function quickDragFirstRowAfterSecond(
await page.mouse.move(source.x, source.y + 7);
await expect(trailingScrim).toHaveCount(0);
await page.mouse.move(target.x, target.y, { steps: 4 });
await finishSidebarDrag(page);
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
async function finishSidebarDrag(page: Page): Promise<void> {
await page.mouse.up();
// dnd-kit's pointer sensor keeps its document click blocker for 50 ms after drop.
// Row order updates before that blocker is removed; wait before the next user action.
await page.waitForTimeout(60);
}
async function dragWithoutReordering(rows: Locator): Promise<void> {
const before = await rowTestIds(rows);
const source = await visibleBoundingBox(rows.nth(0));
@@ -73,7 +66,7 @@ async function dragWithoutReordering(rows: Locator): Promise<void> {
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
await page.mouse.down();
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 5 });
await finishSidebarDrag(page);
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual(before);
}
@@ -1,134 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { createCreationScenario } from "../support/helpers/creation";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
const TOLERANCE_PX = 2;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX, y, { steps: 8 });
await page.mouse.up();
}
async function dragDividerWithTouch(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const input = await page.context().newCDPSession(page);
try {
await input.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (let step = 1; step <= 8; step++) {
await input.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + (deltaX * step) / 8, y }],
});
}
await input.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
} finally {
await input.detach();
}
}
async function splitIntoFourPanes(page: Page): Promise<void> {
for (const expectedDividers of [1, 2, 3]) {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
}
async function expectDividersTrackPointer(
page: Page,
distance = DRAG_DISTANCE,
drag = dragDivider,
): Promise<void> {
for (const index of [0, 1, 2]) {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await drag(divider, distance);
await expect
.poll(async () => Math.abs((await dividerX(divider)) - before - distance), {
message: `divider ${index + 1} should stay under the pointer`,
})
.toBeLessThanOrEqual(TOLERANCE_PX);
}
}
for (const hasTouch of [false, true]) {
test.describe(`Split pane drag tracking (${hasTouch ? "coarse" : "fine"} pointer)`, () => {
test.use({ hasTouch });
test("a divider moves as far as the pointer in the main row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-drag-tracking-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(
page,
DRAG_DISTANCE,
hasTouch ? dragDividerWithTouch : dragDivider,
);
} finally {
await workspace.cleanup();
}
});
test("dividers track the pointer after app creation, Explorer changes and reload", async ({
page,
}) => {
await page.setViewportSize({ width: 1600, height: 900 });
const creation = await createCreationScenario(page);
try {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Reply briefly.", "Create");
await creation.expectAssistantReply();
await waitForWorkspaceTabsVisible(page);
await splitIntoFourPanes(page);
await expectDividersTrackPointer(page);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
await expectDividersTrackPointer(page, -DRAG_DISTANCE);
await page
.getByTestId("workspace-explorer-toggle")
.filter({ visible: true })
.first()
.click();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeVisible();
await page.reload();
await expect(splitDividers(page)).toHaveCount(3);
await expectDividersTrackPointer(page);
} finally {
await creation.cleanup();
}
});
});
}
@@ -1,15 +1,8 @@
import type { Locator, Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
readReactRenders,
recordReactRenders,
resetReactRenders,
} from "../support/helpers/react-renders";
type WebSocketMessage = string | Buffer;
@@ -122,27 +115,23 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
});
});
function appendCompletedCalls(extraCompletedCalls: number) {
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
throw new Error("Expected the first completed tool call before release");
}
for (let index = 0; index < extraCompletedCalls; index += 1) {
sendToPage?.(
cloneToolCallWithId(
firstCompletedMessage as WebSocketMessage,
`${firstCallId}:${sequenceOffset + index}`,
sequenceOffset + index + 1,
),
);
}
sequenceOffset += extraCompletedCalls;
}
return {
waitForFirstCompleted: () => firstCompleted,
appendCompletedCalls,
release(extraCompletedCalls = 0) {
isHolding = false;
appendCompletedCalls(extraCompletedCalls);
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
throw new Error("Expected the first completed tool call before release");
}
for (let index = 0; index < extraCompletedCalls; index += 1) {
sendToPage?.(
cloneToolCallWithId(
firstCompletedMessage as WebSocketMessage,
`${firstCallId}:${index}`,
index + 1,
),
);
}
sequenceOffset = extraCompletedCalls;
for (const message of heldMessages.splice(0)) {
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
}
@@ -150,24 +139,17 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
};
}
type OverviewChatDisplay = "collapseToolCalls" | "collapseTurns";
// Expand thinking keeps reasoning out of tool call runs, so the first tool call starts alone.
async function configureOverviewToolCalls(page: Page, chatDisplay: OverviewChatDisplay) {
await page.addInitScript((display) => {
async function configureOverviewToolCalls(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ chatDisplay: display, autoExpandReasoning: true }),
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
);
}, chatDisplay);
});
}
async function createOverviewAgent(
page: Page,
title: string,
chatDisplay: OverviewChatDisplay = "collapseToolCalls",
) {
await configureOverviewToolCalls(page, chatDisplay);
async function createOverviewAgent(page: Page, title: string) {
await configureOverviewToolCalls(page);
return seedMockAgentWorkspace({
repoPrefix: "tool-call-overview-sheet-",
title,
@@ -222,36 +204,6 @@ function readScrollMetrics(root: HTMLElement) {
};
}
const FIRST_CALL_DETAILS = "export function ConversationList()";
function firstToolCall(page: Page): Locator {
return page.getByTestId("tool-call-badge").filter({ hasText: "conversation-list.tsx" }).first();
}
async function openToolDetails(badge: Locator): Promise<void> {
await badge.getByRole("button").first().click();
}
async function timelineRowOf(locator: Locator) {
const row = await locator.locator("xpath=ancestor::*[@data-history-row-id][1]").elementHandle();
if (!row) throw new Error("Expected the locator inside a timeline row");
return row;
}
/** Whether the locator renders inside this exact row node, i.e. the row was not remounted. */
async function isInsideRow(
locator: Locator,
row: Awaited<ReturnType<typeof timelineRowOf>>,
): Promise<boolean> {
return locator.evaluate((node, previousRow) => previousRow.contains(node), row);
}
async function topOf(row: Locator): Promise<number> {
const box = await row.boundingBox();
if (!box) throw new Error("Expected a visible timeline row");
return box.y;
}
test.describe("compact overview tool calls", () => {
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
@@ -267,16 +219,8 @@ test.describe("compact overview tool calls", () => {
await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
await gate.waitForFirstCompleted();
const single = firstToolCall(page);
await expect(single).toBeVisible();
await expect(page.getByTestId("tool-call-group")).toHaveCount(0);
await openToolDetails(single);
await expect(page.getByTestId("tool-call-sheet-close")).toBeVisible();
await page.getByTestId("tool-call-sheet-close").click();
gate.appendCompletedCalls(1);
const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible();
await expect(page.getByTestId("tool-call-group-sheet")).toBeHidden();
await group.click();
const sheet = page.getByTestId("tool-call-group-sheet");
@@ -317,86 +261,27 @@ test.describe("compact overview tool calls", () => {
});
});
for (const display of ["collapseToolCalls", "collapseTurns"] as const) {
test(`keeps an opened lone tool call open when it becomes a group (${display})`, async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Desktop overview tool calls", display);
await recordReactRenders(page);
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
await gate.waitForFirstCompleted();
const lone = firstToolCall(page);
const group = page.getByTestId("tool-call-group").first();
const details = page.getByText(FIRST_CALL_DETAILS);
await test.step("a lone tool call shows as itself", async () => {
await expect(lone).toBeVisible();
await expect(group).toHaveCount(0);
});
await gate.waitForSecondRunning();
await test.step("opening it re-renders that tool call and nothing else", async () => {
await resetReactRenders(page);
await openToolDetails(lone);
await expect(details).toBeVisible();
const renders = await readReactRenders(page);
expect(renders.AgentStreamView ?? 0).toBe(0);
expect(renders.StreamRowContent ?? 0).toBe(0);
expect(renders.ToolCall).toBe(1);
});
await test.step("the second call forms an open group around the opened call", async () => {
// Reading above the tail, the row holds still; at the tail the new rows push it up.
await scrollChatAwayFromBottom(page, { deltaY: -160, minDistanceFromBottom: 72 });
const loneTop = await topOf(lone);
const loneRow = await timelineRowOf(lone);
await resetReactRenders(page);
gate.releaseSecondRunning();
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
expect(await isInsideRow(group, loneRow)).toBe(true);
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
expect((await readReactRenders(page)).AgentStreamView).toBeGreaterThan(0);
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
});
await test.step("closing and reopening the group keeps the call open", async () => {
await openToolDetails(group);
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
await expect(details).toHaveCount(0);
await openToolDetails(group);
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
});
} finally {
await agent.cleanup();
}
});
}
test("forms a closed group when the lone tool call was left closed", async ({ page }) => {
test("keeps overview tool calls inline on desktop", async ({ page }) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Closed desktop overview tool calls");
try {
const gate = await gateSecondToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Leave the first tool call closed.");
await gate.waitForFirstCompleted();
const lone = firstToolCall(page);
const group = page.getByTestId("tool-call-group").first();
await expect(lone).toBeVisible();
await expect(group).toHaveCount(0);
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
await gate.waitForSecondRunning();
const loneTop = await topOf(lone);
gate.releaseSecondRunning();
try {
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible();
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
const header = group.getByRole("button").first();
await header.click();
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
gate.release(1);
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
} finally {
await agent.cleanup();
}
@@ -1,9 +1,11 @@
import type { Locator } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
type WebSocketMessage = string | Buffer;
interface ShimmerEvidence {
animationDuration: string;
endPx: number;
@@ -12,6 +14,127 @@ interface ShimmerEvidence {
startPx: number;
}
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function getToolCallStatus(
message: WebSocketMessage,
agentId: string,
): { callId: string; status: string } | null {
const sessionMessage = parseSessionMessage(message);
if (sessionMessage?.type !== "agent_stream") {
return null;
}
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | undefined;
return event?.type === "timeline" &&
item?.type === "tool_call" &&
typeof item.callId === "string" &&
typeof item.status === "string"
? { callId: item.callId, status: item.status }
: null;
}
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw) as {
message?: { payload?: { event?: { item?: { status?: string } } } };
};
const item = envelope.message?.payload?.event?.item;
if (!item) {
throw new Error("Expected a tool-call session message");
}
item.status = status;
return JSON.stringify(envelope);
}
async function gateSecondToolCall(page: Page, agentId: string) {
let firstCallId: string | null = null;
let secondCallId: string | null = null;
let secondRunningMessage: WebSocketMessage | null = null;
let pauseAfterSecondRunning = false;
let releaseSecondRequested = false;
let forwardSecondRunning: (() => void) | null = null;
let resolveFirstCompleted!: () => void;
let resolveSecondRunning!: () => void;
const firstCompleted = new Promise<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((resolve) => {
resolveSecondRunning = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
forwardSecondRunning = () => {
if (secondRunningMessage) {
ws.send(secondRunningMessage);
secondRunningMessage = null;
}
};
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (pauseAfterSecondRunning) {
return;
}
const toolCall = getToolCallStatus(message, agentId);
if (!toolCall) {
ws.send(message);
return;
}
if (!firstCallId) {
firstCallId = toolCall.callId;
}
if (toolCall.callId === firstCallId) {
ws.send(message);
if (toolCall.status === "completed") {
resolveFirstCompleted();
}
return;
}
secondCallId ??= toolCall.callId;
if (toolCall.callId === secondCallId) {
const transformedMessage =
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
secondRunningMessage = transformedMessage;
pauseAfterSecondRunning = true;
resolveSecondRunning();
if (releaseSecondRequested) {
forwardSecondRunning?.();
}
return;
}
ws.send(message);
});
});
return {
waitForFirstCompleted: () => firstCompleted,
waitForSecondRunning: () => secondRunning,
releaseSecondRunning() {
releaseSecondRequested = true;
forwardSecondRunning?.();
},
};
}
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
return locator.evaluate((root) => {
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
+1 -69
View File
@@ -203,47 +203,6 @@ test("received messages show their sender and open that agent", async ({ page })
}
});
test("long sender attribution stays on one line in compact chat", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() =>
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
);
const recipient = await seedMockAgentWorkspace({
repoPrefix: "compact-attribution-",
title: "Implementation",
});
try {
const title = "Reviewer checking the compact chat timeline and sender attribution";
const sender = await recipient.client.createAgent({
provider: "codex",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title,
model: "gpt-6.1-sol",
modeId: "full-access",
});
await recipient.client.sendAgentMessage(
recipient.agentId,
"The sender label stays on one line, even when the agent has a long name.",
{ sourceAgentId: sender.id },
);
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
const attribution = page.getByRole("button", { name: `Sent by ${title}`, exact: true });
await expect(attribution).toBeVisible();
await expectSenderTruncatedWithinMessage(attribution, title);
await expectSenderAlignedWithBubble(attribution);
await attribution.scrollIntoViewIfNeeded();
await page.screenshot({ path: testInfo.outputPath("sender-compact-390.png") });
await page.setViewportSize({ width: 320, height: 740 });
await expectSenderTruncatedWithinMessage(attribution, title);
await expectSenderAlignedWithBubble(attribution);
await page.screenshot({ path: testInfo.outputPath("sender-compact-320.png") });
} finally {
await recipient.cleanup();
}
});
interface MessagePreviewWatch {
/** Frames after the watch started on which the fade or "Show more" control was missing. */
framesWithoutClip: number[];
@@ -357,20 +316,6 @@ test("opening a turn keeps the received message above it clipped on every frame"
}
});
async function expectSenderTruncatedWithinMessage(attribution: Locator, title: string) {
const label = attribution.getByText(`Sent by ${title}`, { exact: true });
await expect(label).toHaveCSS("white-space", "nowrap");
await expect(label).toHaveCSS("text-overflow", "ellipsis");
const bounds = await label.evaluate((element) => ({
textWidth: element.scrollWidth,
visibleWidth: element.clientWidth,
right: element.getBoundingClientRect().right,
viewportWidth: document.documentElement.clientWidth,
}));
expect(bounds.textWidth).toBeGreaterThan(bounds.visibleWidth);
expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth);
}
async function expectSenderAlignedWithBubble(attribution: Locator) {
const edges = await attribution.evaluate((button) => {
const bubble = button.nextElementSibling;
@@ -530,20 +475,7 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
await expect(group).toContainText(/read 1 file/i);
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
await group.getByRole("button").first().click();
// Expand thinking is off: the group opens its rows, but the thought row stays closed
// until the reader opens it.
const thought = group
.getByTestId("tool-call-badge")
.filter({ hasText: /^Thinking/ })
.first();
await expect(thought).toBeVisible();
await expect(reasoning).toHaveCount(0);
// A press right after the group opens occasionally does not register; retry until the
// thought row opens.
await expect(async () => {
await thought.getByRole("button").first().click();
await expect(reasoning).toBeVisible({ timeout: 2_000 });
}).toPass();
await expect(reasoning).toBeVisible();
}
}
+4 -4
View File
@@ -717,15 +717,15 @@ test("open usage clocks advance without refreshing or reopening", async ({ page
await openCompactUsageWithClock(page, now);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
await expect(card.getByText("resets in 30m", { exact: false }).first()).toBeVisible();
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
// After two browser minutes the latest displayed tick is one or two minutes old.
await advanceUsageClock(page, 120_000);
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
await expect(card.getByText(/resets in (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out in (28|29)m/)).toBeVisible();
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
/^resets in (28|29)m\s·\s/,
/^resets (28|29)m$/,
);
} finally {
await login.cleanup();
@@ -6,7 +6,6 @@ for await (const line of lines) {
const command = JSON.parse(line);
let data = {};
if (command.type === "get_available_models") {
if (process.argv.includes("--stall-model-discovery")) continue;
data = {
models: [
{
@@ -1,14 +0,0 @@
# Native user message preview
Open a chat with a user message containing 16 newline-separated lines of
`Preview line N`, with N from 1 to 16. Scroll the message into view on a
phone-sized Android emulator or iOS simulator. Its text must exceed the 240-point preview cap,
while its expanded bubble and toggle fit in the chat viewport. Close any other
expanded messages so the toggle labels identify this message.
Replay `native-user-message-preview.android.ad` or
`native-user-message-preview.ios.ad` through the existing mobile runner or
agent-device. It checks that a clipped message offers Show more and
that two expand/collapse round trips keep both controls reachable. Before the
fix, the first Show more assertion fails because native layout reports the
capped height as the full content height.
@@ -1,14 +0,0 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
close
@@ -1,14 +0,0 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
press "role=\"button\" label=\"Show more\""
wait "role=\"button\" label=\"Show less\"" 10000
press "role=\"button\" label=\"Show less\""
wait "role=\"button\" label=\"Show more\"" 10000
close
@@ -12,22 +12,6 @@ point and Metro workflow in [status-ring](../status-ring/README.md), importing
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
With this fixture open in an Android Agent Device session, run:
```sh
node packages/app/e2e/mobile/markdown-formatting/android.mjs <session>
```
The runner switches to paragraphs with long inline code at a fixed 300 dp width.
It compares the native Markdown host’s bottom with its last TextView’s bottom,
then repeats in compact mode and after restoring regular text. More than one
pixel of surplus fails. Keep the whole fixture visible: clipped accessibility
bounds cannot establish measurement parity.
`scripts/postinstall-patches.test.mjs` regenerates both platforms’ props and
compares them with the shipped headers. This catches missing C++ fields in the
measurement path, which the Kotlin tests cannot exercise.
The package patch contains `InlineCodeFormattingTest`, which exercises actual
Android spans in Robolectric native graphics mode, covering draw and measurement,
span order, code inside paragraph/heading sizes, bold/italic composition, and

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